import Foundation
import IndieSync

/// The context snapshot captured at the moment a note is taken — the raw
/// material the relevance ranking later matches against "where am I, what
/// time is it" at recall time. Every field is optional: capture is
/// best-effort and a note taken with location off is still a note.
struct CaptureContext: Codable, Sendable, Equatable {
    var latitude: Double?
    var longitude: Double?
    var horizontalAccuracy: Double?

    /// Point-of-interest or venue name from reverse geocoding ("Blue Bottle
    /// Coffee"). Stored as text so recall works even when geocoding is
    /// unavailable later.
    var placeName: String?
    var thoroughfare: String?
    var locality: String?
    var administrativeArea: String?
    var country: String?

    /// Time zone the note was taken in; local hour / weekday are derived
    /// from `meta.createdAt` under this zone rather than stored.
    var timeZoneID: String?

    /// "iPhone" / "Mac" — which device captured the note.
    var device: String?

    static let empty = CaptureContext()

    var hasCoordinate: Bool { latitude != nil && longitude != nil }

    /// Short human summary for rows and the composer ("Blue Bottle Coffee · Oakland").
    var summary: String? {
        let parts = [placeName, locality].compactMap { $0 }
        return parts.isEmpty ? nil : parts.joined(separator: " · ")
    }
}

/// One note = one JSON file in the iCloud container, filed under
/// `Records/YYYY/MM/<ULID>.json` (UTC-bucketed by the ULID's timestamp).
struct Note: SyncDocument, VersionedDocument, Equatable {
    /// v2 added `Payload.audio` for voice notes. Because it is optional, a v1
    /// file decodes unchanged (missing key ⇒ nil) and `migrateIfNeeded` only
    /// bumps the stamped version — no field back-fill is needed.
    static let currentSchemaVersion = 2

    struct Meta: Codable, Sendable, Equatable {
        var id: ULID
        var schemaVersion: Int
        var createdAt: Date
        var modifiedAt: Date
    }

    /// How the note's text came to be. `transcribed` marks a voice note whose
    /// text is (or will be) produced by on-device transcription.
    enum Source: String, Codable, Sendable {
        case typed
        case transcribed
    }

    /// Where a voice note is in its transcription lifecycle.
    enum TranscriptionStatus: String, Codable, Sendable {
        case pending
        case done
        case failed
    }

    /// The audio-note sidecar metadata. Present only on voice notes; the audio
    /// bytes themselves live in a `.m4a` next to the note JSON (path derived,
    /// never stored — see `audioRelativePath`).
    struct AudioInfo: Codable, Sendable, Equatable {
        var duration: TimeInterval
        /// Per-install UUID of the device that owns pending transcription. Only
        /// the device whose `InstallIdentity.installID` matches this transcribes;
        /// every other device renders status only. Re-transcribe reassigns it.
        var transcriberDeviceID: String
        var transcriptionStatus: TranscriptionStatus
        /// BCP-47 locale the transcript was produced in; nil while pending.
        var transcriptionLocaleID: String?
        var transcribedAt: Date?
    }

    struct Payload: Codable, Sendable, Equatable {
        var text: String
        var source: Source
        var tags: [String]
        var context: CaptureContext
        /// Voice-note sidecar metadata, nil for a plain typed note. Optional so
        /// a v1 document (no `audio` key) decodes as a text note unchanged.
        var audio: AudioInfo?
    }

    var meta: Meta
    var payload: Payload

    var id: ULID { meta.id }
    var schemaVersion: Int { meta.schemaVersion }

    var relativePath: String { Self.relativePath(forID: meta.id) }

    static func relativePath(forID id: ULID) -> String {
        TimeBucketedLayout.relativePath(for: id)
    }

    /// The `.m4a` sidecar path for a note, derived by swapping `.json` → `.m4a`
    /// on the note's **actual** JSON path. The audio path is never stored — it
    /// is always this swap — so it cannot drift from the note it belongs to, and
    /// deleting a note whose file sits under a legacy (pre-fix) month bucket
    /// still targets the sidecar that shares that bucket.
    static func audioRelativePath(forJSONPath jsonPath: String) -> String {
        guard jsonPath.hasSuffix(".json") else { return jsonPath + ".m4a" }
        return String(jsonPath.dropLast(".json".count)) + ".m4a"
    }

    /// The `.m4a` sidecar path recomputed from a note id, sharing the note's
    /// UTC-bucketed layout. Safe for load/warm paths because audio exists only
    /// on v2+ records, which always file under the current UTC bucket — the
    /// legacy local-month concern applies solely to v1 text notes (no audio).
    static func audioRelativePath(forID id: ULID) -> String {
        audioRelativePath(forJSONPath: relativePath(forID: id))
    }

    static func create(
        text: String,
        source: Source = .typed,
        tags: [String] = [],
        context: CaptureContext,
        audio: AudioInfo? = nil
    ) -> Note {
        let now = Date()
        return Note(
            meta: Meta(id: ULID(), schemaVersion: currentSchemaVersion, createdAt: now, modifiedAt: now),
            payload: Payload(text: text, source: source, tags: tags, context: context, audio: audio)
        )
    }

    /// Factory for a note ingested from another device's recording (the watch,
    /// which has no iCloud of its own). The audio was captured elsewhere; this
    /// device only stores and transcribes it. `createdAt` is the ULID's embedded
    /// timestamp — the record-start instant on the capturing device — so the note
    /// buckets under the month it was *recorded*, not the (possibly much later)
    /// month it was ingested. Text is empty until transcription lands.
    static func createIngested(
        id: ULID,
        duration: TimeInterval,
        transcriberDeviceID: String,
        tags: [String] = [],
        context: CaptureContext
    ) -> Note {
        let recordedAt = id.timestamp
        return Note(
            meta: Meta(id: id, schemaVersion: currentSchemaVersion, createdAt: recordedAt, modifiedAt: recordedAt),
            payload: Payload(
                text: "",
                source: .transcribed,
                tags: tags,
                context: context,
                audio: AudioInfo(
                    duration: duration,
                    transcriberDeviceID: transcriberDeviceID,
                    transcriptionStatus: .pending,
                    transcriptionLocaleID: nil,
                    transcribedAt: nil
                )
            )
        )
    }

    /// First line of the text, used as the display title.
    var title: String {
        payload.text
            .split(separator: "\n", omittingEmptySubsequences: true)
            .first.map(String.init) ?? ""
    }
}
