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. v3 moved transcripts out of
    /// `payload.text` into `AudioInfo.attempts`: text is now purely the user's
    /// own writing, and every transcription attempt is preserved. v1/v2 files
    /// decode unchanged (missing keys ⇒ nil/empty); `migrateIfNeeded` back-fills
    /// v2 voice notes by moving a completed transcript from `text` into
    /// `attempts` at read time. v4 added optional `payload.project` — purely
    /// additive, so v1–v3 files decode unchanged (missing key ⇒ nil) with no
    /// read-time transformation needed.
    static let currentSchemaVersion = 4

    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
    }

    /// One finished transcription of a voice note. Attempts accumulate — a
    /// re-transcription appends rather than overwrites, so no machine output is
    /// ever lost and the user can be shown alternatives.
    struct TranscriptionAttempt: Codable, Sendable, Equatable {
        var text: String
        /// BCP-47 locale the transcript was produced in.
        var localeID: String
        /// Character-weighted mean confidence 0…1, or nil when the engine
        /// emitted none (`DictationTranscriber` locales need not report it).
        /// Confidences are not comparable across locales/engines — display and
        /// search-weighting metadata, never a cross-attempt selector.
        var confidence: Double?
        var transcribedAt: Date
        /// Whether the user forced this language (re-transcribe menu) rather
        /// than auto-detection picking it.
        var languageWasForced: Bool
    }

    /// 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?
        /// Every finished transcription, oldest first. The **latest** attempt is
        /// the displayed one — a forced re-transcribe is a statement of intent,
        /// so recency wins, not confidence (which isn't cross-locale comparable).
        var attempts: [TranscriptionAttempt] = []

        /// The attempt currently shown for this note.
        var latestAttempt: TranscriptionAttempt? { attempts.last }

        private enum CodingKeys: String, CodingKey {
            case duration, transcriberDeviceID, transcriptionStatus,
                 transcriptionLocaleID, transcribedAt, attempts
        }

        init(
            duration: TimeInterval,
            transcriberDeviceID: String,
            transcriptionStatus: TranscriptionStatus,
            transcriptionLocaleID: String? = nil,
            transcribedAt: Date? = nil,
            attempts: [TranscriptionAttempt] = []
        ) {
            self.duration = duration
            self.transcriberDeviceID = transcriberDeviceID
            self.transcriptionStatus = transcriptionStatus
            self.transcriptionLocaleID = transcriptionLocaleID
            self.transcribedAt = transcribedAt
            self.attempts = attempts
        }

        init(from decoder: any Decoder) throws {
            let container = try decoder.container(keyedBy: CodingKeys.self)
            duration = try container.decode(TimeInterval.self, forKey: .duration)
            transcriberDeviceID = try container.decode(String.self, forKey: .transcriberDeviceID)
            transcriptionStatus = try container.decode(TranscriptionStatus.self, forKey: .transcriptionStatus)
            transcriptionLocaleID = try container.decodeIfPresent(String.self, forKey: .transcriptionLocaleID)
            transcribedAt = try container.decodeIfPresent(Date.self, forKey: .transcribedAt)
            // Absent on v2 files — decode to empty, back-filled by migration.
            attempts = try container.decodeIfPresent([TranscriptionAttempt].self, forKey: .attempts) ?? []
        }
    }

    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?
        /// The single project (or "category" — user's choice, see
        /// `ProjectsNaming`) this note is filed under. Nil means unfiled.
        /// Optional so a pre-v4 document (no `project` key) decodes unchanged.
        var project: String? = nil
    }

    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,
        project: String? = 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, project: project)
        )
    }

    /// 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
                )
            )
        )
    }

    /// What a reader should see as this note's body: the user's own text when
    /// they wrote any, else the latest transcript. Transcripts live in
    /// `audio.attempts`, never in `payload.text`.
    var displayText: String {
        payload.text.isEmpty ? (payload.audio?.latestAttempt?.text ?? "") : payload.text
    }

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