//
//  SchemaSkipScanner.swift
//  Reference code copied from the indie-diag skill (2026-07-06 addition),
//  adapted for Workouts.
//
//  Answers a question `NSMetadataQuery` can't: of the documents that are fully
//  synced and locally present, how many is this build currently *skipping*
//  because their `schemaVersion` is newer than it understands (IndieSync's
//  `VersionedDocument.isReadable` forward gate)? A skipped document is a
//  healthy, expected state — the data is safe and fully synced, just written by
//  a newer app version on another device — and updating the app recovers it.
//
//  ADAPTATION FROM THE REFERENCE: the reference scanner takes a single
//  `currentSchemaVersion: Int`. Workouts has TWO document types with
//  INDEPENDENT version counters (`RoutineDocument.currentSchemaVersion` and
//  `WorkoutDocument.currentSchemaVersion`), so one global ceiling can't classify
//  both correctly — a Routine written at a version the workout counter has already
//  reached would be misjudged. The forward-gate ceiling therefore has to be
//  resolved per file (its location under `Documents/` tells us its type), so the
//  scanner takes a `CeilingResolver` closure. A single-Int convenience init is
//  kept so the file stays drop-in for single-document-type apps. Deliberately
//  never triggers a download: callers filter to locally-present documents first
//  (see `DiagnosticsCoordinator.generate()`).
//

import Foundation

/// Outcome of probing one document's schema version.
public enum SchemaProbeState: Sendable, Equatable {
    /// `schemaVersion` is at or below what this build understands.
    case readable(version: Int)
    /// `schemaVersion` is newer than this build understands — the app's
    /// `isReadable` forward gate skips this document. An app update
    /// recovers it; the data is intact.
    case skipped(version: Int)
    /// Decoded as JSON but no schema version field was found (e.g. a
    /// tombstone stub or other non-versioned file in the container).
    case noVersionField
    /// The file couldn't be read or parsed at all.
    case unreadable(description: String)
}

/// Probe result for one document.
public struct SchemaProbeResult: Sendable, Identifiable {
    public var id: URL { url }
    public let url: URL
    public let fileName: String
    public let state: SchemaProbeState
}

/// Point-in-time result of scanning locally-available documents.
///
/// Only documents that were actually probed appear here — evicted
/// documents are excluded upstream (never downloaded just to probe), so
/// `skippedCount` is a floor, not a guarantee, whenever
/// `DocumentSyncSnapshot.evictedCount` is nonzero.
public struct SchemaSkipSnapshot: Sendable {
    public let results: [SchemaProbeResult]
    public let currentSchemaVersion: Int
    public let generatedAt: Date

    public var probedCount: Int { results.count }
    public var skippedCount: Int {
        results.count { if case .skipped = $0.state { return true } else { return false } }
    }
    public var skipped: [SchemaProbeResult] {
        results.filter { if case .skipped = $0.state { return true } else { return false } }
    }
    /// Highest schema version seen among skipped documents — i.e. the
    /// newest app version that has written into this container. Useful in
    /// support copy ("another device is running a newer version").
    public var newestSkippedVersion: Int? {
        skipped.compactMap { if case .skipped(let v) = $0.state { return v } else { return nil } }.max()
    }
}

/// Probes documents for their `schemaVersion` and counts the ones this
/// build would skip. A plain `actor` so the coordinated file reads block off
/// the caller's thread and a wedged iCloud account can't freeze the UI.
public actor SchemaSkipScanner {
    /// Pulls the schema version out of a document's raw bytes. Return nil
    /// if the data has no version field.
    public typealias VersionExtractor = @Sendable (Data) -> Int?

    /// Resolves the newest schema version THIS build can read for the document at
    /// `url` — its forward-gate ceiling. Workouts resolves this from the file's
    /// location under `Documents/` (Splits/ vs Workouts/); see the file header.
    public typealias CeilingResolver = @Sendable (URL) -> Int

    private let ceilingForURL: CeilingResolver
    private let extractVersion: VersionExtractor
    /// Representative "newest schema this build understands" (the max ceiling),
    /// kept only for `SchemaSkipSnapshot.currentSchemaVersion`'s informational use.
    private let representativeCeiling: Int

    /// Per-URL ceiling — the form Workouts uses (two document types, two counters).
    /// - Parameters:
    ///   - ceilingForURL: newest readable version for the document at a given URL.
    ///   - representativeCeiling: a single number for the snapshot's informational
    ///     field — pass the max of the app's document-type ceilings.
    ///   - extractVersion: override when documents don't match the default layouts.
    public init(
        ceilingForURL: @escaping CeilingResolver,
        representativeCeiling: Int,
        extractVersion: VersionExtractor? = nil
    ) {
        self.ceilingForURL = ceilingForURL
        self.representativeCeiling = representativeCeiling
        self.extractVersion = extractVersion ?? Self.defaultExtractor
    }

    /// Single global ceiling — the reference package's original signature, kept so
    /// this file stays drop-in for single-document-type apps.
    public init(currentSchemaVersion: Int, extractVersion: VersionExtractor? = nil) {
        self.init(
            ceilingForURL: { _ in currentSchemaVersion },
            representativeCeiling: currentSchemaVersion,
            extractVersion: extractVersion
        )
    }

    /// Probe the given document URLs. Pass only locally-present documents
    /// (filter a `DocumentSyncSnapshot` down to states that imply a full
    /// local copy) — this scanner reads whatever it's handed, and reading
    /// an evicted document would trigger a full download.
    public func scan(urls: [URL]) -> SchemaSkipSnapshot {
        SchemaSkipSnapshot(
            results: urls.map(probe),
            currentSchemaVersion: representativeCeiling,
            generatedAt: Date()
        )
    }

    private func probe(_ url: URL) -> SchemaProbeResult {
        let fileName = url.lastPathComponent

        // Coordinated read, same as the sync engine's document reads — a
        // plain Data(contentsOf:) can race an in-flight iCloud update.
        var coordinationError: NSError?
        var data: Data?
        var readErrorDescription: String?
        NSFileCoordinator(filePresenter: nil).coordinate(
            readingItemAt: url, options: .withoutChanges, error: &coordinationError
        ) { actualURL in
            do { data = try Data(contentsOf: actualURL) }
            catch { readErrorDescription = error.localizedDescription }
        }

        guard let data else {
            let description = coordinationError?.localizedDescription
                ?? readErrorDescription
                ?? "Unknown read error"
            return SchemaProbeResult(url: url, fileName: fileName, state: .unreadable(description: description))
        }
        guard let version = extractVersion(data) else {
            return SchemaProbeResult(url: url, fileName: fileName, state: .noVersionField)
        }
        let ceiling = ceilingForURL(url)
        let state: SchemaProbeState = version > ceiling
            ? .skipped(version: version)
            : .readable(version: version)
        return SchemaProbeResult(url: url, fileName: fileName, state: state)
    }

    /// Covers both canonical portfolio layouts: top-level `schemaVersion`
    /// (IndieSync's `VersionedDocument`) and `meta.schemaVersion`
    /// (time-series-data-model's Meta+Payload). Decodes only the version
    /// field — never the full document, so a future schema can't trip it.
    private static let defaultExtractor: VersionExtractor = { data in
        struct TopLevel: Decodable { let schemaVersion: Int }
        struct MetaWrapped: Decodable {
            struct Meta: Decodable { let schemaVersion: Int }
            let meta: Meta
        }
        let decoder = JSONDecoder()
        if let doc = try? decoder.decode(TopLevel.self, from: data) { return doc.schemaVersion }
        if let doc = try? decoder.decode(MetaWrapped.self, from: data) { return doc.meta.schemaVersion }
        return nil
    }
}
