import Foundation
import IndieSync
import SwiftData
import Testing
@testable import Notes

/// The v3 transcript model: attempts accumulate on `AudioInfo`, the latest one
/// is displayed, `payload.text` stays purely user writing, and v2 files whose
/// transcript was baked into `text` migrate at read time.
@MainActor
struct TranscriptionAttemptTests {
    private func makeEngine() throws -> (engine: SyncEngine, container: ModelContainer) {
        let config = ModelConfiguration(isStoredInMemoryOnly: true, cloudKitDatabase: .none)
        let container = try ModelContainer(for: NoteEntity.self, configurations: config)
        return (SyncEngine(modelContainer: container), container)
    }

    private func attempt(_ text: String, locale: String = "en-US", confidence: Double? = 0.9) -> Note.TranscriptionAttempt {
        Note.TranscriptionAttempt(
            text: text,
            localeID: locale,
            confidence: confidence,
            transcribedAt: Date(timeIntervalSince1970: 1_700_000_100),
            languageWasForced: false
        )
    }

    // MARK: - Model

    @Test func latestAttemptWins() {
        var audio = Note.AudioInfo(duration: 5, transcriberDeviceID: "d", transcriptionStatus: .done)
        audio.attempts = [attempt("first"), attempt("второй", locale: "ru-RU")]
        #expect(audio.latestAttempt?.text == "второй")
    }

    @Test func displayTextPrefersUserTextThenTranscript() {
        var audio = Note.AudioInfo(duration: 5, transcriberDeviceID: "d", transcriptionStatus: .done)
        audio.attempts = [attempt("spoken words")]
        var note = Note.create(text: "", source: .transcribed, context: .empty, audio: audio)
        #expect(note.displayText == "spoken words")
        #expect(note.title == "spoken words")
        note.payload.text = "my own words"
        #expect(note.displayText == "my own words")
    }

    /// A v2 file (no `attempts` key) must decode with empty attempts, not fail.
    @Test func v2AudioInfoDecodesWithoutAttemptsKey() throws {
        let json = """
        {"duration": 5, "transcriberDeviceID": "d", "transcriptionStatus": "done", "transcriptionLocaleID": "en-US"}
        """
        let audio = try JSONDecoder().decode(Note.AudioInfo.self, from: Data(json.utf8))
        #expect(audio.attempts.isEmpty)
        #expect(audio.transcriptionStatus == .done)
    }

    @Test func attemptsSurviveCodableRoundTrip() throws {
        var audio = Note.AudioInfo(duration: 5, transcriberDeviceID: "d", transcriptionStatus: .done)
        audio.attempts = [attempt("hello", confidence: nil), attempt("привет", locale: "ru-RU")]
        let note = Note.create(text: "", source: .transcribed, context: .empty, audio: audio)
        let decoded = try DocumentCoder.decode(Note.self, from: DocumentCoder.encode(note))
        #expect(decoded.payload.audio?.attempts.count == 2)
        #expect(decoded.payload.audio?.latestAttempt?.localeID == "ru-RU")
        #expect(decoded.payload.audio?.attempts.first?.confidence == nil)
    }

    // MARK: - v2 → v3 migration

    @Test func migrationMovesCompletedTranscriptIntoAttempts() throws {
        let (engine, container) = try makeEngine()
        defer { withExtendedLifetime(container) {} }
        var note = Note.create(
            text: "the old transcript",
            source: .transcribed,
            context: .empty,
            audio: Note.AudioInfo(
                duration: 5,
                transcriberDeviceID: "d",
                transcriptionStatus: .done,
                transcriptionLocaleID: "en-US",
                transcribedAt: Date(timeIntervalSince1970: 1_700_000_100)
            )
        )
        note.meta.schemaVersion = 2

        let migrated = engine.migrateIfNeeded(note)
        #expect(migrated.meta.schemaVersion == Note.currentSchemaVersion)
        #expect(migrated.payload.text.isEmpty)
        #expect(migrated.payload.audio?.attempts.count == 1)
        #expect(migrated.payload.audio?.latestAttempt?.text == "the old transcript")
        #expect(migrated.payload.audio?.latestAttempt?.localeID == "en-US")
        #expect(migrated.payload.audio?.latestAttempt?.confidence == nil)
        #expect(migrated.displayText == "the old transcript")
    }

    @Test func migrationLeavesPendingAndTextNotesAlone() throws {
        let (engine, container) = try makeEngine()
        defer { withExtendedLifetime(container) {} }

        var pending = Note.create(
            text: "",
            source: .transcribed,
            context: .empty,
            audio: Note.AudioInfo(duration: 5, transcriberDeviceID: "d", transcriptionStatus: .pending)
        )
        pending.meta.schemaVersion = 2
        let migratedPending = engine.migrateIfNeeded(pending)
        #expect(migratedPending.payload.audio?.attempts.isEmpty == true)
        #expect(migratedPending.meta.schemaVersion == Note.currentSchemaVersion)

        var textNote = Note.create(text: "just typing", context: .empty)
        textNote.meta.schemaVersion = 1
        let migratedText = engine.migrateIfNeeded(textNote)
        #expect(migratedText.payload.text == "just typing")
        #expect(migratedText.meta.schemaVersion == Note.currentSchemaVersion)
    }

    // MARK: - Cache columns

    @Test func mapperCachesLatestAttempt() throws {
        let config = ModelConfiguration(isStoredInMemoryOnly: true, cloudKitDatabase: .none)
        let container = try ModelContainer(for: NoteEntity.self, configurations: config)
        defer { withExtendedLifetime(container) {} }
        let context = container.mainContext

        var audio = Note.AudioInfo(duration: 5, transcriberDeviceID: "d", transcriptionStatus: .done, transcriptionLocaleID: "ru-RU")
        audio.attempts = [attempt("first draft"), attempt("окончательный", locale: "ru-RU", confidence: 0.8)]
        let note = Note.create(text: "", source: .transcribed, context: .empty, audio: audio)
        NoteMapper.upsert(note, relativePath: note.relativePath, into: context)

        let entity = try #require(NoteMapper.fetch(id: note.meta.id.stringValue, in: context))
        #expect(entity.transcriptText == "окончательный")
        #expect(entity.transcriptConfidence == 0.8)
        #expect(entity.displayText == "окончательный")
        #expect(entity.title == "окончательный")
    }

    // MARK: - Search scoring

    @Test func searchRanksUserTextAboveTranscript() throws {
        let config = ModelConfiguration(isStoredInMemoryOnly: true, cloudKitDatabase: .none)
        let container = try ModelContainer(for: NoteEntity.self, configurations: config)
        defer { withExtendedLifetime(container) {} }
        let context = container.mainContext

        let typed = Note.create(text: "coffee order", context: .empty)
        var audio = Note.AudioInfo(duration: 5, transcriberDeviceID: "d", transcriptionStatus: .done)
        audio.attempts = [attempt("coffee with sam", confidence: 0.9)]
        let voice = Note.create(text: "", source: .transcribed, context: .empty, audio: audio)
        NoteMapper.upsert(typed, relativePath: typed.relativePath, into: context)
        NoteMapper.upsert(voice, relativePath: voice.relativePath, into: context)

        let typedEntity = try #require(NoteMapper.fetch(id: typed.meta.id.stringValue, in: context))
        let voiceEntity = try #require(NoteMapper.fetch(id: voice.meta.id.stringValue, in: context))

        let typedScore = try #require(typedEntity.searchScore(for: "coffee"))
        let voiceScore = try #require(voiceEntity.searchScore(for: "coffee"))
        #expect(typedScore > voiceScore)
        #expect(typedEntity.searchScore(for: "zzz") == nil)

        // Higher-confidence transcripts outrank shakier ones.
        var lowAudio = audio
        lowAudio.attempts = [attempt("coffee maybe", confidence: 0.2)]
        voiceEntity.audio = lowAudio
        let lowScore = try #require(voiceEntity.searchScore(for: "coffee"))
        #expect(voiceScore > lowScore)
    }
}
