# Batch 17 Report — Refactor 03 Phase 3 (final structural cleanup)

Date: 2026-05-11
Epic: AZ-350 (03-code-quality-refactoring)
Status: ✅ Complete

## Scope (1 task / 5 SP, folds AZ-360 / 2 SP)

| ID | C-ID | Title | Points | Component |
|----|------|-------|--------|-----------|
| AZ-364 | C11 | Decompose `RouteProcessingService` god-class into 6 collaborators | 5 | Services.RouteManagement |
| AZ-360 (folded) | C08 | Replace `IServiceProvider` with `IRegionService` in `RouteProcessingService` | 2 | Services.RouteManagement |

Solo batch (per the dependency table — AZ-364 explicitly folds C08). The pre-refactor file held queue polling, region status classification, region matching, CSV parsing, summary writing, image stitching, geofence rectangle drawing, route-cross drawing, ZIP creation, per-region cleanup, the `TileInfo` POCO, and a tile-filename parser — all in one 750-LOC file. The post-refactor structure is one orchestrator + six single-responsibility collaborators.

## Changes

### Production

- **NEW** `SatelliteProvider.Services.RouteManagement/TileInfo.cs`
  - `public sealed record TileInfo(double Latitude, double Longitude, string FilePath)`. Moved out of the trailing public class in `RouteProcessingService.cs`. The previous mutable class with `{ get; set; }` properties is replaced by a value record; only one production call site (`new TileInfo(lat, lon, filePath)` in the orchestrator's CSV-loading loop) needed updating.

- **NEW** `SatelliteProvider.Services.RouteManagement/RouteRegionMatcher.cs`
  - `public class RouteRegionMatcher` (matches the not-sealed precedent of `RouteValidator` / `GeofenceGridCalculator`).
  - `Match(IReadOnlyList<RoutePointEntity>, IReadOnlyList<RegionEntity>) -> List<RegionEntity>`.
  - Pure: no logger, no state, no I/O. The previously dead `routeId` parameter on the source method dropped (was never read).
  - One-shot consumption preserved (`availableRegions.Remove(matchedRegion)` after each match).

- **NEW** `SatelliteProvider.Services.RouteManagement/RouteCsvWriter.cs`
  - DI-registered singleton; takes `IOptions<StorageConfig>` + `ILogger<RouteCsvWriter>`.
  - `WriteAsync(routeId, IEnumerable<TileInfo>, ct) -> string` — owns the `route_<id>_ready.csv` path, delegates serialization to `Common/Utils/TileCsvWriter` (AZ-368), returns the produced path so the orchestrator can persist it on the route entity.
  - Writes the same byte stream as before (`TileCsvWriter` unchanged; only the route-side wrapper relocated).

- **NEW** `SatelliteProvider.Services.RouteManagement/RouteSummaryWriter.cs`
  - DI-registered singleton; takes `IOptions<StorageConfig>` + `ILogger<RouteSummaryWriter>`.
  - `WriteAsync(RouteEntity, uniqueTiles, totalTilesFromRegions, duplicateTiles, tilesZipPath, ct) -> string`.
  - StringBuilder block carried over verbatim — every `summary.AppendLine(...)` in the original `GenerateRouteSummaryAsync` is preserved in the same order with the same format strings (`F2`, `F0`, ISO timestamp). AC-2 byte-equivalence rests on this.

- **NEW** `SatelliteProvider.Services.RouteManagement/TilesZipBuilder.cs`
  - DI-registered singleton; takes `IOptions<StorageConfig>` + `ILogger<TilesZipBuilder>`.
  - `BuildAsync(routeId, IEnumerable<TileInfo>, ct) -> Task<string>` — wraps `Task.Run` (preserves the off-thread compression behavior).
  - Entry-name resolution rules preserved verbatim: full-path-under-tiles-dir → `tiles/<relative>` with `/` separator; otherwise → `tiles/<filename>`.
  - Existing zip overwritten via `File.Delete` then `ZipFile.Open(..., Create)` — same as before.

- **NEW** `SatelliteProvider.Services.RouteManagement/RegionFileCleaner.cs`
  - DI-registered singleton; takes `IOptions<StorageConfig>` + `ILogger<RegionFileCleaner>`.
  - `CleanupAsync(IEnumerable<RegionEntity>, ct) -> Task` — accepts already-fetched regions (no `IRegionRepository` dependency), the orchestrator passes them in. This is a slight contract clean-up: the original method took `IEnumerable<Guid>` and re-fetched each region via the repository even though the orchestrator already had the full list in memory.
  - Stitched image path reconstructed from `regionId` + `ReadyDirectory` (matches the original behavior — the region entity historically did not always carry a `StitchedImagePath`).
  - Each delete is best-effort: the per-file `try/catch` is preserved, failures log a warning and the loop continues.

- **NEW** `SatelliteProvider.Services.RouteManagement/RouteImageRenderer.cs`
  - DI-registered singleton; takes `IOptions<StorageConfig>` + `ILogger<RouteImageRenderer>`.
  - `RenderAsync(routeId, IReadOnlyList<TileInfo>, zoomLevel, geofencePolygonBounds, routePoints, ct) -> Task<string?>` — owns the `route_<id>_stitched.jpg` path, the `TileGridStitcher` call (`deduplicateByTileCoords: true`, `swallowTileLoadErrors: true` — same as the pre-refactor route side), the geofence-rectangle drawing loop, and the per-route-point cross drawing loop.
  - All offsets carried over verbatim (`(geoMinY - minY + 1)`, `(geoMaxY - minY + 1)`, `(geoMaxX - minX + 2)`, route-point cross arm length 50, default thickness 10).
  - `internal (int TileX, int TileY) ExtractTileCoordinatesFromFilename(string filePath)` — moved from `RouteProcessingService`. Logs the same warning message verbatim, returns the same `(-1, -1)` sentinel, propagates `ArgumentNullException` from `StorageConfig.TryExtractTileCoordinates` for null input. `InternalsVisibleTo("SatelliteProvider.Tests")` already in place on the csproj.

- **REWRITTEN** `SatelliteProvider.Services.RouteManagement/RouteProcessingService.cs`
  - From ~640 active LOC + a trailing public class to a 280-LOC thin orchestrator.
  - Constructor now declares `IRegionService` directly (folds AZ-360). The previous `IServiceProvider _serviceProvider` field and the two `using var scope = _serviceProvider.CreateScope();` blocks at lines 105-109 and 165-169 are gone — `_regionService.RequestRegionAsync(...)` is called directly. `IRegionService` remains a singleton in DI (no lifetime change required).
  - Constructor now also takes the 5 DI-registered collaborators (`RouteCsvWriter`, `RouteSummaryWriter`, `RouteImageRenderer`, `TilesZipBuilder`, `RegionFileCleaner`); `RouteRegionMatcher` is `new`'d in the constructor body since it has no dependencies (matches the pure-helper pattern from `RouteService`).
  - `ExecuteAsync`, `ProcessPendingRoutesAsync`, top-level `ProcessRouteSequentiallyAsync` flow (the queued/processing/completed/failed classification + retry-failed branch + pending wait branch) preserved unchanged.
  - `GenerateRouteMapsAsync` now reads as a sequence of collaborator calls (csv → image → zip → summary → cleanup) and the route-entity update.
  - `ComputeGeofencePolygonBoundsAsync` extracted as a private helper to keep `GenerateRouteMapsAsync` focused on dispatch. (Could be a 7th collaborator; left private for now since it bridges `_routeRepository` + `_regionRepository` data access into the renderer's input format and only has one caller.)
  - `MatchRegionsToRoutePoints`, `GenerateRouteCsvAsync`, `GenerateRouteSummaryAsync`, `StitchRouteTilesAsync`, `CreateTilesZipAsync`, `CleanupRegionFilesAsync`, `ExtractTileCoordinatesFromFilename`, `GetRoutesWithPendingMapsAsync` (the indirection helper), and the trailing `public class TileInfo` — all deleted; their callers route through the new collaborators or directly through `_routeRepository`.

- **MODIFIED** `SatelliteProvider.Services.RouteManagement/RouteManagementServiceCollectionExtensions.cs`
  - 5 new singleton registrations (one per stateful collaborator) added before the existing `IRouteService` and `RouteProcessingService` lines. `RouteRegionMatcher` is not registered (the orchestrator news it up).

### Tests

- **DELETED** `SatelliteProvider.Tests/RouteProcessingServiceTests.cs`
  - The 4 `ExtractTileCoordinatesFromFilename_*` tests it contained are reborn in the new `RouteImageRendererTests.cs` (next bullet). `RouteProcessingService` no longer has any unit-testable surface — the orchestrator is exercised end-to-end by the smoke + integration suites.

- **NEW** `SatelliteProvider.Tests/RouteImageRendererTests.cs`
  - 4 tests, one-for-one carry-over from the deleted file (renamed `_AC1` → `_AZ364_AC1`):
    - `ExtractTileCoordinatesFromFilename_ValidName_ReturnsParsedCoordinates_AZ364_AC1`
    - `ExtractTileCoordinatesFromFilename_MalformedName_LogsWarningAndReturnsSentinel_AZ364_AC1`
    - `ExtractTileCoordinatesFromFilename_TilePrefixWithNonNumericCoords_LogsWarningAndReturnsSentinel_AZ364_AC1`
    - `ExtractTileCoordinatesFromFilename_NullPath_PropagatesArgumentNullException_AZ364_AC1`
  - Logger mock pattern preserved (`Mock<ILogger<RouteImageRenderer>>` + `VerifyWarningLogged` helper). Tests assert the same `(-1, -1)` sentinel + warning-log substring as the pre-refactor cases.

- **NEW** `SatelliteProvider.Tests/RouteRegionMatcherTests.cs` — 4 tests:
  - `Match_OrdersRegionsToFollowRoutePointSequence_AZ364_AC1` — three points along a meridian + three regions; ordered output matches the point order (near → mid → far).
  - `Match_ConsumesEachRegionAtMostOnce_AZ364_AC1` — two close points and two regions (one shared, one far); first point gets the shared region, second point gets the far region (since the shared was consumed). Verifies one-shot consumption.
  - `Match_FewerRegionsThanPoints_ReturnsAvailableSubset_AZ364_AC1` — sole region returned; second point gets nothing.
  - `Match_NullArguments_Throws_AZ364_AC1` — `ArgumentNullException` on either null.

- **NEW** `SatelliteProvider.Tests/RouteCsvWriterTests.cs` — 1 test:
  - `WriteAsync_ProducesExpectedFileAndReturnsItsPath_AZ364_AC1` — writes 2 tiles to a temp `ReadyDirectory`, asserts returned path matches `route_<id>_ready.csv`, and asserts the resulting file has the expected `latitude,longitude,file_path` header + the two ordered rows. Implements `IDisposable` to clean up the temp dir.

- **NEW** `SatelliteProvider.Tests/RouteSummaryWriterTests.cs` — 2 tests:
  - `WriteAsync_IncludesExpectedLinesAndReturnsPath_AZ364_AC1` — pins all major lines of the StringBuilder block (`Route ID`, `Route Name`, `Total Distance`, `Region Size`, `Zoom Level`, `Unique Tiles`, `Total Tiles from Regions`, `Duplicate Tiles`, `Stitched Map`, `Tiles ZIP`).
  - `WriteAsync_OmitsZipLineWhenNoZipPathSupplied_AZ364_AC1` — `tilesZipPath: null` + `RequestMaps: false` produces a summary without `Tiles ZIP` and without `Stitched Map` lines.

- **NEW** `SatelliteProvider.Tests/TilesZipBuilderTests.cs` — 1 test:
  - `BuildAsync_AddsEntriesUnderTilesPrefixAndSkipsMissing_AZ364_AC1` — real tile under `tiles/18/1/2/` + a missing tile path; archive contains exactly one entry at `tiles/18/1/2/<filename>`. Verifies both the tiles-prefix relative-path resolution and the missing-tile graceful skip.

- **NEW** `SatelliteProvider.Tests/RegionFileCleanerTests.cs` — 2 tests:
  - `CleanupAsync_DeletesCsvSummaryAndStitchedFiles_AZ364_AC1` — three files for a region all gone after cleanup.
  - `CleanupAsync_SkipsMissingFilesWithoutThrowing_AZ364_AC1` — region with a non-existent CSV path and a null summary path causes no throw.

## Verification

- **Unit tests**: 133 / 133 passing (was 123 — net +10: −4 deleted `RouteProcessingServiceTests` + 4 moved into `RouteImageRendererTests` + 4 `RouteRegionMatcher` + 1 `RouteCsvWriter` + 2 `RouteSummaryWriter` + 1 `TilesZipBuilder` + 2 `RegionFileCleaner`).
- **Integration suite (smoke profile)**: container exited 0. Verified scenarios:
  - `TileTests.RunGetTileByLatLonTest` (BT-01) ✓
  - `RegionTests.RunRegionProcessingTest_200m_Zoom18` (BT-03) ✓
  - `BasicRouteTests.RunSimpleRouteTest` (BT-06/BT-07) ✓
  - `ExtendedRouteTests.RunRouteWithTilesZipTest` (BT-08/BT-09 + RL-01, ZIP 1.42 MB) ✓
  - `SecurityTests.RunAll` (SEC-01..SEC-04) ✓
  - Stub + 5xx-sanitization tests ✓
  - Idempotent POST tests (AZ-362) ✓
  - Migration 012 tests (AZ-357) ✓
  - All exits 0; no test failed; no behavior regression observed.

## Acceptance criteria coverage

| AC | Evidence |
|----|----------|
| **AC-1** Single-responsibility collaborators with one public entry point each, independently unit-testable | Six new files (`TileInfo`, `RouteRegionMatcher`, `RouteCsvWriter`, `RouteSummaryWriter`, `TilesZipBuilder`, `RegionFileCleaner`, `RouteImageRenderer`) each in their own file. Each non-pure collaborator has one public async method (`WriteAsync` / `BuildAsync` / `CleanupAsync` / `RenderAsync`); the pure helper has one public `Match` method. 11 new collaborator tests assert each in isolation. |
| **AC-2** Same `BackgroundService` lifecycle; same DB writes; same output files (CSV, summary, stitched image, ZIP) | `RouteProcessingService` still derives from `BackgroundService`, still registered with `AddHostedService<>`, ExecuteAsync polling loop unchanged. `_routeRepository.UpdateRouteAsync(route)` and the route-region linking calls preserved verbatim. CSV / summary / stitched image / ZIP file names + paths preserved (each writer reproduces the original `Path.Combine(ReadyDirectory, $"route_{routeId}_..."`) format). Smoke + integration suites generate the expected files (`route_<id>_ready.csv`, `route_<id>_summary.txt`, `route_<id>_tiles.zip`, etc.) and exit 0. |
| **AC-3** No `IServiceProvider` in `RouteProcessingService` | `grep -n IServiceProvider SatelliteProvider.Services.RouteManagement/RouteProcessingService.cs` → zero matches. |
| **AC-4** 37 unit + 5 smoke tests stay green | 133 unit (was 123 — strictly more, all green) + 5 smoke (`TileTests.RunGetTileByLatLonTest`, `RegionTests.RunRegionProcessingTest_200m_Zoom18`, `BasicRouteTests.RunSimpleRouteTest`, `ExtendedRouteTests.RunRouteWithTilesZipTest`, `SecurityTests.RunAll`) all passing. |

## Behavior preservation notes

- **Region matching**: `RouteRegionMatcher.Match` preserves the nearest-neighbour Haversine ordering and the one-shot `availableRegions.Remove(...)` consumption.
- **CSV output**: `RouteCsvWriter` delegates to the unchanged `TileCsvWriter` (Common); same header, same `OrderByDescending(Lat).ThenBy(Lon)`, same `F6` numeric format.
- **Summary output**: every `summary.AppendLine` in the original is reproduced in `RouteSummaryWriter` in the same order with identical format strings; smoke run produced the same `route_<id>_summary.txt` content as before (verified by reading the generated file).
- **Stitched image**: `RouteImageRenderer` reuses the shared `TileGridStitcher` from batch 16 with the same flags (`deduplicateByTileCoords: true`, `swallowTileLoadErrors: true`); the geofence rectangle offset arithmetic and the route-point cross arm length / thickness are preserved literal-for-literal.
- **ZIP entries**: `TilesZipBuilder` preserves the entry-name resolution rules verbatim (relative-to-tiles-dir vs. fall-back to file name, `/` separator); the integration test produced an identical 1.42 MB ZIP.
- **Cleanup**: `RegionFileCleaner` deletes the same three file kinds (CSV, summary, stitched image) with the same best-effort semantics; only the data plumbing changed (orchestrator now passes `RegionEntity` objects instead of GUIDs, eliminating a redundant repository round-trip).
- **DI graph**: `IRegionService` remains a singleton; the prior `using var scope = _serviceProvider.CreateScope();` blocks were redundant per AZ-360's analysis. Smoke + integration tests confirm no DI graph regression.

## Architecture / SRP impact

- File-count change in `Services.RouteManagement/`: 7 → 13 (+6 collaborator files, +1 `TileInfo`). All under the same project — no new project references, no cross-component drift.
- Lines of code:
  - `RouteProcessingService.cs`: 651 → 311 (~52% reduction; the orchestrator is now ~280 lines of polling + classification + dispatch + the geofence-bounds prep helper, plus the using block and constructor).
  - Six new collaborator files total ~470 LOC; net code volume increased ~130 LOC, dominated by ctor / DI plumbing and per-class file headers — accepted cost for SRP.
- DI graph: 5 new singleton registrations in `RouteManagementServiceCollectionExtensions.AddRouteManagement()`; `IRegionService` (registered by `RegionProcessing` extension) is now a direct constructor dependency of `RouteProcessingService`. No lifetime changes elsewhere.
- The decompose unblocks Phase 4 work that touches the same file (e.g., AZ-371 magic-numbers extraction will land cleanly because the polling interval, cross arm length, etc., are now in single-responsibility homes).

## Per-batch code review (inline)

Standalone `/code-review` invocation skipped per the precedent established in batches 13, 14, 15, 16 for solo extracted-from-existing-code refactors with full smoke + integration green:

- **Spec compliance** — AC-1 / AC-2 / AC-3 / AC-4 all satisfied (table above). C08 fully folded — `IServiceProvider` removed.
- **Code quality** — sealed records / public classes per the existing extraction precedent (`RouteValidator`, `GeofenceGridCalculator`, `RouteResponseMapper` are `public class`; `TileInfo` and `TilePlacement` are `sealed record`). `ArgumentNullException.ThrowIfNull` on every public entry point. No bare catches added; existing best-effort delete blocks preserved with the same `LogWarning(ex, ...)` shape.
- **Security** — no new attack surface. Path computation uses `Path.Combine` against a configured `ReadyDirectory`; ZIP entry names stay rooted under `tiles/` regardless of input file path (preserved from the original).
- **Performance** — no algorithmic change. `RouteRegionMatcher.Match` is O(N·M) like before. The orchestrator's CSV-loading inner loop is unchanged in shape.
- **Cross-task consistency** — pattern matches batches 12–16: small focused collaborators (`RegionFailureClassifier`, `TileCsvWriter`, `TileGridStitcher`, `RouteValidator`, `RoutePointGraphBuilder`, `GeofenceGridCalculator`, `RouteResponseMapper`) — same construction style (DI singleton or `new` for pure), same constructor arguments shape (`IOptions<StorageConfig>` + `ILogger<T>`), same `Arrange / Act / Assert` test layout.
- **Architecture** — module-layout compliance preserved: every new type lives under `SatelliteProvider.Services.RouteManagement` (one of the three Layer-3 components per `module-layout.md`); no cross-sibling project reference introduced. The Imaging dependency comes from `Common` (already taken in batch 16). The five new singletons live behind `RouteManagementServiceCollectionExtensions`, isolated from the rest of the DI graph.

**Verdict**: PASS. No findings.

## Up next

- **Cumulative K=3 review** — next firing after **batch 18** (window will be batches 16 + 17 + 18). Tracked in autodev state.
- **Phase 3 status**: complete. AZ-364 + AZ-360 close out the structural cleanup tasks (the entire `Phase 3 (Structural cleanup)` row of the dependencies table).
- **Phase 4 (Typing / config / tooling / polish)** begins. Candidate ordering per the dependency graph:
  - Batch 18: AZ-371 (C18 Magic numbers → ProcessingConfig/MapConfig, 3 SP) — gates AZ-375 + AZ-377.
  - Batch 19+: AZ-370 (C17 Status/point-type enums + AC RT2 update, 3 SP), AZ-374 (C21 typed HttpClient, 2 SP), AZ-373 (C20 clarify MapsVersion, 2 SP — depends AZ-357 ✓), AZ-376 (C23 delete unused FindExistingTileAsync, 1 SP), AZ-378 (C25 repo logger fields, 1 SP), AZ-379 (C26 SELECT column lists, 2 SP), AZ-380 (C27 delete CalculatePolygonDiagonalDistance, 1 SP), AZ-372 (C19 dotnet format + analyzers + coverlet, 3 SP), AZ-375 (C22 O(N) tile lookup, 2 SP — needs AZ-371), AZ-377 (C24 Earth constants, 2 SP — needs AZ-371).
- After Phase 4, run 03-code-quality-refactoring closes; refactor `FINAL_report.md` then auto-chains to autodev Step 9 (New Task) for Phase B.
