package service import ( "context" "errors" "fmt" "math" "slices" "strings" "time" "unicode" "fire-safety-ymd/internal/domain" ) const ( statusOK = "ok" statusNoResults = "no_results" allTownsScopeWarning = "results_include_all_towns_in_configured_database" townAllowlistScopeWarning = "results_limited_to_server_authorized_towns" invalidGeometryWarning = "source_records_with_invalid_geometries_are_excluded" ) var ( // ErrInvalidArgument indicates a caller-controlled spatial query is invalid. ErrInvalidArgument = errors.New("invalid spatial query argument") // ErrDataSourceUnavailable indicates a repository query could not complete safely. ErrDataSourceUnavailable = errors.New("spatial data source unavailable") // ErrQueryTimeout indicates the bounded repository query timed out. ErrQueryTimeout = errors.New("spatial query timeout") ) // SpatialRepository is the fixed read-only persistence port used by spatial use cases. type SpatialRepository interface { SearchPlaceCandidates(context.Context, domain.PlaceSearchQuery) ([]domain.PlaceCandidate, error) ResolveIncidentContext(context.Context, domain.Coordinate, domain.SpatialScope) ([]domain.IncidentContext, error) FindNearbyWaterSources(context.Context, domain.NearbyQuery) ([]domain.WaterSource, error) FindCommandPostCandidates(context.Context, domain.NearbyQuery) ([]domain.CommandPostCandidate, error) ListNearbyAccessLines(context.Context, domain.NearbyQuery) ([]domain.AccessLine, error) GetResponsibleUnits(context.Context, domain.Coordinate, domain.SpatialScope) ([]domain.ResponsibleUnit, error) FindNearbyRiskAreas(context.Context, domain.NearbyQuery) ([]domain.RiskArea, error) } // ResultMetadata records the provenance and bounds of a tool result. type ResultMetadata struct { GeneratedAt string `json:"generated_at"` DataSources []string `json:"data_sources"` SpatialReference string `json:"spatial_reference"` ResultCount int `json:"result_count"` SearchRadiusMeters *float64 `json:"search_radius_meters,omitempty"` } // QueryResult is the common structured MCP result envelope. type QueryResult[T any] struct { Status string `json:"status"` Data T `json:"data"` Metadata ResultMetadata `json:"metadata"` Warnings []string `json:"warnings"` } // SpatialService executes bounded read-only forest-fire spatial queries. type SpatialService struct { repository SpatialRepository scope domain.SpatialScope queryTimeout time.Duration now func() time.Time } // NewSpatialService creates a spatial service with a trusted server-side data scope. func NewSpatialService(repository SpatialRepository, scope domain.SpatialScope, queryTimeout time.Duration) (*SpatialService, error) { if repository == nil { return nil, errors.New("spatial repository is required") } if scope.AllTowns && len(scope.AllowedTowns) > 0 { return nil, errors.New("all-towns scope must not include an allowlist") } if !scope.AllTowns && len(scope.AllowedTowns) == 0 { return nil, errors.New("at least one trusted town is required") } if queryTimeout <= 0 { return nil, errors.New("query timeout must be greater than zero") } return &SpatialService{ repository: repository, scope: domain.SpatialScope{ AllTowns: scope.AllTowns, AllowedTowns: slices.Clone(scope.AllowedTowns), }, queryTimeout: queryTimeout, now: time.Now, }, nil } // SearchPlaceCandidates searches existing fire-safety records without treating a match as a confirmed incident point. func (s *SpatialService) SearchPlaceCandidates(ctx context.Context, placeName string, limit int) (QueryResult[[]domain.PlaceCandidate], error) { placeName = strings.TrimSpace(placeName) if count := len([]rune(placeName)); count < 2 || count > 100 || containsControlCharacter(placeName) { return QueryResult[[]domain.PlaceCandidate]{}, fmt.Errorf("%w: place_name must contain 2 to 100 characters without controls", ErrInvalidArgument) } if limit == 0 { limit = 10 } if limit < 1 || limit > 20 { return QueryResult[[]domain.PlaceCandidate]{}, fmt.Errorf("%w: limit must be between 1 and 20", ErrInvalidArgument) } queryCtx, cancel := context.WithTimeout(ctx, s.queryTimeout) defer cancel() items, err := s.repository.SearchPlaceCandidates(queryCtx, domain.PlaceSearchQuery{ PlaceName: placeName, Limit: limit, Scope: s.scope, }) if err != nil { return QueryResult[[]domain.PlaceCandidate]{}, classifyRepositoryError("search place candidates", err) } return result(items, []string{ "water_source", "storage_pool", "fire_access_line", "fire_check_station", "fire_lookout", "fire_grid", "forest_enterprise", "cemetery_area", }, nil, s.now, s.scopeWarning(), []string{ "place_search_is_limited_to_existing_fire_safety_records", "place_candidates_require_user_confirmation", "representative_points_are_not_exact_incident_locations", }), nil } // ResolveIncidentContext returns fire grids covering a WGS84 point. func (s *SpatialService) ResolveIncidentContext(ctx context.Context, point domain.Coordinate) (QueryResult[[]domain.IncidentContext], error) { if err := validateCoordinate(point); err != nil { return QueryResult[[]domain.IncidentContext]{}, err } queryCtx, cancel := context.WithTimeout(ctx, s.queryTimeout) defer cancel() items, err := s.repository.ResolveIncidentContext(queryCtx, point, s.scope) if err != nil { return QueryResult[[]domain.IncidentContext]{}, classifyRepositoryError("resolve incident context", err) } return result(items, []string{"fire_grid"}, nil, s.now, s.scopeWarning(), []string{ "grid_records_may_be_incomplete_or_stale", }), nil } // FindNearbyWaterSources returns bounded water-source candidates by distance. func (s *SpatialService) FindNearbyWaterSources(ctx context.Context, point domain.Coordinate, radiusMeters float64, limit int) (QueryResult[[]domain.WaterSource], error) { query, err := s.nearbyQuery(point, radiusMeters, 10_000, 30_000, limit) if err != nil { return QueryResult[[]domain.WaterSource]{}, err } queryCtx, cancel := context.WithTimeout(ctx, s.queryTimeout) defer cancel() items, err := s.repository.FindNearbyWaterSources(queryCtx, query) if err != nil { return QueryResult[[]domain.WaterSource]{}, classifyRepositoryError("find nearby water sources", err) } return result(items, []string{ "water_source", "storage_pool", }, &query.RadiusMeters, s.now, s.scopeWarning(), []string{ "resource_availability_not_verified", "distance_is_spatial_proximity_not_operational_accessibility", "source_timestamp_raw_unit_and_timezone_are_unconfirmed", }), nil } // FindCommandPostCandidates returns existing nearby facilities that require field assessment. func (s *SpatialService) FindCommandPostCandidates(ctx context.Context, point domain.Coordinate, radiusMeters float64, limit int) (QueryResult[[]domain.CommandPostCandidate], error) { query, err := s.nearbyQuery(point, radiusMeters, 10_000, 20_000, limit) if err != nil { return QueryResult[[]domain.CommandPostCandidate]{}, err } queryCtx, cancel := context.WithTimeout(ctx, s.queryTimeout) defer cancel() items, err := s.repository.FindCommandPostCandidates(queryCtx, query) if err != nil { return QueryResult[[]domain.CommandPostCandidate]{}, classifyRepositoryError("find command post candidates", err) } return result(items, []string{ "fire_check_station", "fire_lookout", }, &query.RadiusMeters, s.now, s.scopeWarning(), []string{ "candidate_only_requires_field_safety_communications_capacity_and_access_review", "facility_availability_not_verified", }), nil } // ListNearbyAccessLines returns nearby mapped fire-access lines, not calculated routes. func (s *SpatialService) ListNearbyAccessLines(ctx context.Context, point domain.Coordinate, radiusMeters float64, limit int) (QueryResult[[]domain.AccessLine], error) { query, err := s.nearbyQuery(point, radiusMeters, 5_000, 10_000, limit) if err != nil { return QueryResult[[]domain.AccessLine]{}, err } queryCtx, cancel := context.WithTimeout(ctx, s.queryTimeout) defer cancel() items, err := s.repository.ListNearbyAccessLines(queryCtx, query) if err != nil { return QueryResult[[]domain.AccessLine]{}, classifyRepositoryError("list nearby access lines", err) } return result(items, []string{"fire_access_line"}, &query.RadiusMeters, s.now, s.scopeWarning(), []string{ "candidate_lines_only_not_a_route_plan", "passability_surface_width_slope_vehicle_limits_and_closures_are_unknown", "source_updated_raw_has_no_confirmed_timezone", }), nil } // GetResponsibleUnits returns non-personal team responsibility recorded on covering grids. func (s *SpatialService) GetResponsibleUnits(ctx context.Context, point domain.Coordinate) (QueryResult[[]domain.ResponsibleUnit], error) { if err := validateCoordinate(point); err != nil { return QueryResult[[]domain.ResponsibleUnit]{}, err } queryCtx, cancel := context.WithTimeout(ctx, s.queryTimeout) defer cancel() items, err := s.repository.GetResponsibleUnits(queryCtx, point, s.scope) if err != nil { return QueryResult[[]domain.ResponsibleUnit]{}, classifyRepositoryError("get responsible units", err) } return result(items, []string{"fire_grid"}, nil, s.now, s.scopeWarning(), []string{ "source_does_not_store_live_team_positions", "source_does_not_store_confirmed_assembly_sites_or_readiness", }), nil } // FindNearbyRiskAreas returns nearby cemetery and forest-enterprise polygons. func (s *SpatialService) FindNearbyRiskAreas(ctx context.Context, point domain.Coordinate, radiusMeters float64, limit int) (QueryResult[[]domain.RiskArea], error) { query, err := s.nearbyQuery(point, radiusMeters, 3_000, 10_000, limit) if err != nil { return QueryResult[[]domain.RiskArea]{}, err } queryCtx, cancel := context.WithTimeout(ctx, s.queryTimeout) defer cancel() items, err := s.repository.FindNearbyRiskAreas(queryCtx, query) if err != nil { return QueryResult[[]domain.RiskArea]{}, classifyRepositoryError("find nearby risk areas", err) } return result(items, []string{ "cemetery_area", "forest_enterprise", }, &query.RadiusMeters, s.now, s.scopeWarning(), []string{ "risk_records_may_be_incomplete_or_stale", "proximity_does_not_establish_current_hazard_severity", }), nil } func (s *SpatialService) nearbyQuery(point domain.Coordinate, radiusMeters, defaultRadius, maximumRadius float64, limit int) (domain.NearbyQuery, error) { if err := validateCoordinate(point); err != nil { return domain.NearbyQuery{}, err } if radiusMeters == 0 { radiusMeters = defaultRadius } if radiusMeters < 100 || radiusMeters > maximumRadius || math.IsNaN(radiusMeters) || math.IsInf(radiusMeters, 0) { return domain.NearbyQuery{}, fmt.Errorf("%w: radius_meters must be between 100 and %.0f", ErrInvalidArgument, maximumRadius) } if limit == 0 { limit = 10 } if limit < 1 || limit > 20 { return domain.NearbyQuery{}, fmt.Errorf("%w: limit must be between 1 and 20", ErrInvalidArgument) } return domain.NearbyQuery{ Point: point, RadiusMeters: radiusMeters, Limit: limit, Scope: s.scope, }, nil } func validateCoordinate(point domain.Coordinate) error { if math.IsNaN(point.Longitude) || math.IsInf(point.Longitude, 0) || point.Longitude < -180 || point.Longitude > 180 { return fmt.Errorf("%w: longitude must be between -180 and 180", ErrInvalidArgument) } if math.IsNaN(point.Latitude) || math.IsInf(point.Latitude, 0) || point.Latitude < -90 || point.Latitude > 90 { return fmt.Errorf("%w: latitude must be between -90 and 90", ErrInvalidArgument) } return nil } func containsControlCharacter(value string) bool { for _, character := range value { if unicode.IsControl(character) { return true } } return false } func classifyRepositoryError(action string, err error) error { if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { return fmt.Errorf("%w: %s", ErrQueryTimeout, action) } return fmt.Errorf("%w: %s: %w", ErrDataSourceUnavailable, action, err) } func (s *SpatialService) scopeWarning() string { if s.scope.AllTowns { return allTownsScopeWarning } return townAllowlistScopeWarning } func result[T any](items []T, dataSources []string, radius *float64, now func() time.Time, scopeWarning string, warnings []string) QueryResult[[]T] { if items == nil { items = []T{} } status := statusOK if len(items) == 0 { status = statusNoResults } resultWarnings := []string{scopeWarning, invalidGeometryWarning} resultWarnings = append(resultWarnings, warnings...) return QueryResult[[]T]{ Status: status, Data: items, Metadata: ResultMetadata{ GeneratedAt: now().UTC().Format(time.RFC3339Nano), DataSources: slices.Clone(dataSources), SpatialReference: "EPSG:4326", ResultCount: len(items), SearchRadiusMeters: radius, }, Warnings: resultWarnings, } }