import Foundation
import IndieSync
import Testing
@testable import Notes

struct NoteDocumentTests {
    private func makeNote() -> Note {
        var context = CaptureContext.empty
        context.latitude = 37.8044
        context.longitude = -122.2712
        context.placeName = "Blue Bottle Coffee"
        context.locality = "Oakland"
        context.timeZoneID = "America/Los_Angeles"
        context.device = "iPhone"
        return Note.create(text: "Barista's name is Sam\nLikes cortados", tags: ["coffee", "people"], context: context)
    }

    @Test func codableRoundTrip() throws {
        let note = makeNote()
        let data = try DocumentCoder.encode(note)
        let decoded = try DocumentCoder.decode(Note.self, from: data)
        #expect(decoded.payload == note.payload)
        #expect(decoded.meta.id == note.meta.id)
        #expect(decoded.meta.schemaVersion == note.meta.schemaVersion)
        // ISO-8601 encoding truncates sub-second precision.
        #expect(abs(decoded.meta.createdAt.timeIntervalSince(note.meta.createdAt)) < 1)
        #expect(abs(decoded.meta.modifiedAt.timeIntervalSince(note.meta.modifiedAt)) < 1)
    }

    @Test func forwardGateQuarantinesNewerSchema() {
        var note = makeNote()
        note.meta.schemaVersion = Note.currentSchemaVersion + 1
        #expect(!note.isReadable)
        #expect(makeNote().isReadable)
    }

    @Test func relativePathIsUTCBucketed() {
        let note = makeNote()
        let path = note.relativePath
        #expect(path.hasSuffix("\(note.meta.id.stringValue).json"))
        #expect(path == TimeBucketedLayout.relativePath(for: note.meta.id))
    }

    @Test func titleIsFirstLine() {
        #expect(makeNote().title == "Barista's name is Sam")
    }

    // MARK: - Schema v2 (audio)

    /// A v1 file predates the `audio` key. It must decode unchanged — as a text
    /// note with `audio == nil` — and pass the forward gate (1 ≤ current).
    @Test func v1DocumentDecodesAsTextNoteWithNoAudio() throws {
        let id = ULID(millisecondsSinceEpoch: 1_700_000_000_000, randomHi: 1, randomLo: 2)
        let json = """
        {
          "meta": {
            "id": "\(id.stringValue)",
            "schemaVersion": 1,
            "createdAt": "2023-11-14T22:13:20Z",
            "modifiedAt": "2023-11-14T22:13:20Z"
          },
          "payload": {
            "text": "A v1 note",
            "source": "typed",
            "tags": ["legacy"],
            "context": { "device": "iPhone", "timeZoneID": "UTC" }
          }
        }
        """
        let note = try DocumentCoder.decode(Note.self, from: Data(json.utf8))
        #expect(note.meta.schemaVersion == 1)
        #expect(note.payload.audio == nil)
        #expect(note.payload.text == "A v1 note")
        #expect(note.isReadable)
    }

    /// A file stamped with a newer schema (and carrying a field this build
    /// doesn't model) must be quarantined by the gate — Codable silently drops
    /// the unknown key, so partial-decoding then rewriting would downgrade it.
    @Test func forwardGateQuarantinesNewerFixture() throws {
        let id = ULID(millisecondsSinceEpoch: 1_700_000_000_000, randomHi: 3, randomLo: 4)
        let json = """
        {
          "meta": {
            "id": "\(id.stringValue)",
            "schemaVersion": \(Note.currentSchemaVersion + 1),
            "createdAt": "2023-11-14T22:13:20Z",
            "modifiedAt": "2023-11-14T22:13:20Z"
          },
          "payload": {
            "text": "From the future",
            "source": "typed",
            "tags": [],
            "context": {},
            "audio": null,
            "unknownFutureField": { "whatever": true }
          }
        }
        """
        let note = try DocumentCoder.decode(Note.self, from: Data(json.utf8))
        #expect(note.meta.schemaVersion == Note.currentSchemaVersion + 1)
        #expect(!note.isReadable)
    }

    @Test func audioNoteCodableRoundTrip() throws {
        var context = CaptureContext.empty
        context.device = "Watch"
        context.timeZoneID = "Europe/Berlin"
        let audio = Note.AudioInfo(
            duration: 12.5,
            transcriberDeviceID: "install-abc",
            transcriptionStatus: .done,
            transcriptionLocaleID: "ru_RU",
            transcribedAt: Date(timeIntervalSince1970: 1_700_000_100)
        )
        let note = Note.create(text: "привет", source: .transcribed, tags: ["voice"], context: context, audio: audio)
        let data = try DocumentCoder.encode(note)
        let decoded = try DocumentCoder.decode(Note.self, from: data)
        #expect(decoded.payload == note.payload)
        #expect(decoded.payload.audio?.transcriptionStatus == .done)
        #expect(decoded.payload.audio?.transcriptionLocaleID == "ru_RU")
    }

    @Test func audioPathDerivationSwapsExtension() {
        let id = ULID()
        let jsonPath = Note.relativePath(forID: id)
        #expect(jsonPath.hasSuffix(".json"))
        #expect(Note.audioRelativePath(forID: id) == jsonPath.replacingOccurrences(of: ".json", with: ".m4a"))
        #expect(Note.audioRelativePath(forID: id).hasSuffix("\(id.stringValue).m4a"))
    }

    /// The swap must operate on the note's actual path, so a record filed under
    /// a pre-fix local-time-zone month keeps its sidecar in the same bucket.
    @Test func audioPathPreservesLegacyBucket() {
        let id = ULID()
        let legacyJSONPath = "1999/12/\(id.stringValue).json"
        #expect(Note.audioRelativePath(forJSONPath: legacyJSONPath) == "1999/12/\(id.stringValue).m4a")
    }

    // MARK: - Schema v4 (project)

    @Test func projectCodableRoundTrip() throws {
        var context = CaptureContext.empty
        context.device = "iPhone"
        let note = Note.create(text: "Ship the release notes", context: context, project: "Contextful v0.3")
        let data = try DocumentCoder.encode(note)
        let decoded = try DocumentCoder.decode(Note.self, from: data)
        #expect(decoded.payload == note.payload)
        #expect(decoded.payload.project == "Contextful v0.3")
    }

    /// A v3 file predates the `project` key. It must decode unchanged — as an
    /// unfiled note with `project == nil` — and pass the forward gate (3 ≤ current).
    @Test func v3DocumentDecodesWithNoProject() throws {
        let id = ULID(millisecondsSinceEpoch: 1_700_000_000_000, randomHi: 7, randomLo: 8)
        let json = """
        {
          "meta": {
            "id": "\(id.stringValue)",
            "schemaVersion": 3,
            "createdAt": "2023-11-14T22:13:20Z",
            "modifiedAt": "2023-11-14T22:13:20Z"
          },
          "payload": {
            "text": "A v3 note",
            "source": "typed",
            "tags": [],
            "context": { "device": "iPhone", "timeZoneID": "UTC" }
          }
        }
        """
        let note = try DocumentCoder.decode(Note.self, from: Data(json.utf8))
        #expect(note.meta.schemaVersion == 3)
        #expect(note.payload.project == nil)
        #expect(note.isReadable)
    }

    @Test func createIngestedDerivesCreatedAtFromULID() {
        let id = ULID(millisecondsSinceEpoch: 1_700_000_000_000, randomHi: 5, randomLo: 6)
        var context = CaptureContext.empty
        context.device = "Watch"
        let note = Note.createIngested(id: id, duration: 8, transcriberDeviceID: "install-x", context: context)
        #expect(note.meta.id == id)
        #expect(note.meta.createdAt == id.timestamp)
        #expect(note.meta.modifiedAt == id.timestamp)
        #expect(note.payload.text.isEmpty)
        #expect(note.payload.source == .transcribed)
        #expect(note.payload.audio?.transcriptionStatus == .pending)
        #expect(note.payload.audio?.transcriberDeviceID == "install-x")
        #expect(note.relativePath == Note.relativePath(forID: id))
    }
}
