import XCTest
import RideFormCore
import UIKit
@testable import RideForm

@MainActor
final class RecordingLifecycleTests: XCTestCase {
    func testFinalFitSoftRetriesKeepCaptureRunningAndStopAfterTwoRetries() async throws {
        var configuration = CalibrationSolver.defaultConfiguration
        configuration.parameters["minimumGestureAxisSine"] = 1.1 // Reject even orthogonal fixture axes.
        let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
        let controller = RecordingController(repository: SessionRepository(directory: root), calibration: CalibrationController(configuration: configuration), synthetic: true)
        controller.beginCalibration()
        defer { controller.cancelCalibration() }
        for _ in 0..<400 {
            if controller.calibration.stage == .retry { break }
            try await Task.sleep(for: .milliseconds(50))
        }
        XCTAssertEqual(controller.calibration.stage, .retry)
        XCTAssertEqual(controller.calibration.automaticRetries, 2)
        XCTAssertEqual(controller.calibration.error, .collinearGestures)
        XCTAssertNil(controller.calibration.currentCalibration)
        let decoder = JSONDecoder(); decoder.dateDecodingStrategy = .iso8601
        let entries = try decoder.decode([CalibrationAttemptLog.Entry].self, from: Data(contentsOf: XCTUnwrap(controller.calibrationLogURL)))
        XCTAssertEqual(entries.filter { $0.event == "fit" }.count, 3)
        XCTAssertTrue(entries.contains { $0.stage == "returnToNeutral" && $0.retries == 2 })
    }
    func testCalibrationLogPersistsFailureReasonAndReopensWithoutBecomingSession() throws {
        let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
        let repository = SessionRepository(directory: root)
        let controller = RecordingController(repository: repository, synthetic: true)
        controller.beginCalibration()
        controller.calibration.fail(.unverifiedConvention)
        controller.cancelCalibration()
        let url = try XCTUnwrap(controller.calibrationLogURL)
        let decoder = JSONDecoder(); decoder.dateDecodingStrategy = .iso8601
        let entries = try decoder.decode([CalibrationAttemptLog.Entry].self, from: Data(contentsOf: url))
        XCTAssertTrue(entries.contains { $0.event == "started" })
        XCTAssertEqual(entries.last?.reason, "unverifiedConvention")
        XCTAssertEqual(entries.last?.event, "cancelled")
        XCTAssertTrue(try repository.list().isEmpty)
        let reopened = RecordingController(repository: repository, synthetic: true)
        XCTAssertEqual(reopened.calibrationLogURL, url)
        let newer = url.deletingLastPathComponent().appendingPathComponent("000-newer.json")
        try Data(contentsOf: url).write(to: newer)
        try FileManager.default.setAttributes([.modificationDate: Date(timeIntervalSince1970: 1)], ofItemAtPath: url.path)
        XCTAssertEqual(CalibrationAttemptLog(directory: root).url, newer, "Random filename order must not choose an older attempt")
    }
    func testHostTimerRepeatsGuidanceWhileSensorCallbacksContinue() async throws {
        var configuration = CalibrationSolver.defaultConfiguration
        configuration.parameters["minimumGestureRadians"] = 0.4 // Fixture leans only 0.3.
        let calibration = CalibrationController(configuration: configuration)
        var spoken: [String] = []
        let controller = RecordingController(calibration: calibration, synthetic: true, guidanceSpeech: { words, done in spoken.append(words); done(); return true })
        controller.beginCalibration()
        defer { controller.cancelCalibration() }
        for _ in 0..<300 {
            if spoken.filter({ $0.contains("Still waiting for the left lean") }).count >= 2 { break }
            try await Task.sleep(for: .milliseconds(50))
        }
        XCTAssertEqual(calibration.stage, .left)
        XCTAssertGreaterThanOrEqual(spoken.filter { $0.contains("Still waiting for the left lean") }.count, 2)
        XCTAssertGreaterThan(calibration.sampleCount, 500)
    }
    func testEveryActiveCalibrationStageReceivesWaitingAudioAndManualModeStaysQuiet() {
        var spoken: [String] = []
        let controller = RecordingController(synthetic: true, guidanceSpeech: { words, done in spoken.append(words); done(); return true })
        controller.beginCalibration()
        var visited = Set<String>()
        for tick in 0..<1800 {
            let stage = controller.calibration.stage
            if visited.insert(String(describing: stage)).inserted {
                let before = spoken.count
                controller.updateCalibrationFeedback(now: controller.capture.clock.now() + 5)
                XCTAssertEqual(spoken.count, before + 1, "Missing feedback for \(stage)")
                XCTAssertEqual(spoken.last, controller.calibration.waitingPrompt)
            }
            if stage == .awaitingConvention { break }
            if controller.calibration.waitingForVoice { controller.calibration.guidanceDidFinish() }
            let rotation = stage == .left ? Vector3(x: -0.3) : stage == .forward ? Vector3(y: 0.3) : Vector3()
            controller.calibration.ingest(MotionSample(sensorTimestamp: Double(tick)/100, attitudeQuaternion: OrientationMath.quaternion(rotationVector: rotation)))
        }
        XCTAssertEqual(visited.count, 7)
        controller.cancelCalibration()
        controller.spokenCalibration = false
        controller.beginCalibration()
        let before = spoken.count
        controller.updateCalibrationFeedback(now: controller.capture.clock.now() + 5)
        XCTAssertEqual(spoken.count, before)
        controller.cancelCalibration()
    }
    func testCalibrationRepeatsAudioWhileWaitingWithoutOverlappingSpeech() {
        var words: [String] = []
        var pending: (() -> Void)?
        let controller = RecordingController(synthetic: true, guidanceSpeech: { text, done in
            words.append(text); pending = done; return true
        })
        controller.beginCalibration()
        let now = controller.capture.clock.now()
        controller.updateCalibrationFeedback(now: now + 5)
        XCTAssertEqual(words.count, 1, "Never interrupt an active instruction with a reminder")
        pending?()
        controller.updateCalibrationFeedback(now: now + 10)
        pending?() // Finish the countdown's initial 3.
        controller.updateCalibrationFeedback(now: now + 15)
        XCTAssertTrue(words.last?.contains("countdown") == true)
        let count = words.count
        controller.cancelCalibration()
        pending?()
        controller.updateCalibrationFeedback(now: now + 20)
        XCTAssertEqual(words.count, count, "Cancellation must stop recurring feedback")
    }
    func testCancelledSpeechCannotResumeCalibrationAndManualRetryRestoresIdleTimer() {
        let previous = UIApplication.shared.isIdleTimerDisabled
        defer { UIApplication.shared.isIdleTimerDisabled = previous }
        UIApplication.shared.isIdleTimerDisabled = false
        var pending: (() -> Void)?
        let controller = RecordingController(synthetic: true, guidanceSpeech: { _, done in pending = done; return true })
        controller.beginCalibration()
        XCTAssertTrue(UIApplication.shared.isIdleTimerDisabled)
        controller.sceneInterrupted()
        pending?()
        XCTAssertEqual(controller.state, .uncalibrated)
        XCTAssertEqual(controller.calibration.stage, .idle)
        XCTAssertFalse(UIApplication.shared.isIdleTimerDisabled)
        controller.spokenCalibration = false
        controller.beginCalibration()
        for i in 0...250 { controller.calibration.ingest(MotionSample(sensorTimestamp: Double(i)/100, rotationRate: Vector3(x: 0.4))) }
        controller.advanceCalibration()
        XCTAssertEqual(controller.calibration.stage, .retry)
        XCTAssertFalse(UIApplication.shared.isIdleTimerDisabled)
        controller.beginCalibration(); controller.cancelCalibration()
        XCTAssertFalse(UIApplication.shared.isIdleTimerDisabled)
    }
    func testHandsFreeStartsRecordingOnlyAfterFinalAnnouncement() async throws {
        let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
        var readySpeech: (() -> Void)?
        let controller = RecordingController(repository: SessionRepository(directory: root), synthetic: true, guidanceSpeech: { words, done in
            if words.hasPrefix("Calibration ready") { readySpeech = done } else { done() }
            return true
        })
        controller.recordAfterCalibration = true
        controller.beginCalibration()
        for _ in 0..<300 {
            if readySpeech != nil { break }
            try await Task.sleep(for: .milliseconds(50))
        }
        XCTAssertEqual(controller.state, .ready, controller.status)
        XCTAssertNil(controller.repository.writer)
        controller.testCalibrationAudio()
        XCTAssertEqual(controller.audioTestStatus, "", "Audio check must not interrupt the ready announcement")
        try XCTUnwrap(readySpeech)()
        try await eventually { controller.state == .recording && controller.counts[.motion, default: 0] > 5 }
        readySpeech?()
        controller.stopRecording()
        try await eventually { controller.state == .saved }
        XCTAssertEqual(try controller.repository.list().count, 1)
    }
    func testStalledSpeechTimesOutDespiteSensorDeliveryAndRejectsLateCompletion() async throws {
        let previous = UIApplication.shared.isIdleTimerDisabled
        defer { UIApplication.shared.isIdleTimerDisabled = previous }
        UIApplication.shared.isIdleTimerDisabled = false
        var completions: [() -> Void] = []
        let controller = RecordingController(synthetic: true, guidanceSpeech: { _, done in completions.append(done); return true })
        controller.beginCalibration()
        let stale = try XCTUnwrap(completions.first)
        for _ in 0..<360 {
            if controller.calibration.stage == .retry { break }
            try await Task.sleep(for: .milliseconds(50))
        }
        XCTAssertEqual(controller.calibration.stage, .retry)
        XCTAssertFalse(UIApplication.shared.isIdleTimerDisabled)
        stale()
        XCTAssertEqual(controller.calibration.stage, .retry)
        controller.cancelCalibration()
    }
    func testUncalibratedCannotRecordAndDoubleCalibrationTapDoesNotResetStage() {
        let controller = RecordingController(synthetic: true)
        controller.startRecording()
        XCTAssertEqual(controller.state, .uncalibrated)
        controller.beginCalibration()
        controller.beginCalibration()
        XCTAssertEqual(controller.state, .calibrating)
        XCTAssertEqual(controller.calibration.stage, .preparingNeutral)
        controller.sceneInterrupted()
    }
    func testActualFixtureCalibrationMarkersDoubleTapsAndInterruptedSave() async throws {
        let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
        let repository = SessionRepository(directory: directory)
        let controller = RecordingController(repository: repository, synthetic: true)
        let previousIdle = UIApplication.shared.isIdleTimerDisabled
        try await calibrate(controller)
        controller.startRecording(); controller.startRecording()
        try await eventually { repository.writer != nil && controller.counts[.motion, default: 0] > 5 }
        XCTAssertTrue(UIApplication.shared.isIdleTimerDisabled)
        for label in ["LEFT", "CENTER", "RIGHT", "GOOD", "BAD"] { controller.marker(label) }
        controller.sceneInterrupted(); controller.stopRecording()
        try await eventually { controller.state == .saved }
        XCTAssertEqual(UIApplication.shared.isIdleTimerDisabled, previousIdle)
        XCTAssertEqual(try repository.list().count, 1)
        let url = try XCTUnwrap(controller.savedURL)
        let session = try repository.load(url)
        XCTAssertEqual(session.manifest.boundaries.status, "interrupted")
        XCTAssertEqual(session.events.map(\.label), ["LEFT", "CENTER", "RIGHT", "GOOD", "BAD"])
        XCTAssertEqual(session.events.map(\.sequence), [0, 1, 2, 3, 4])
        XCTAssertTrue(session.manifest.rotationConvention.verified)
        XCTAssertGreaterThan(session.motion.samples.count, 5)
        XCTAssertEqual(session.motion.samples.map(\.sequence), Array(0..<UInt64(session.motion.samples.count)))
        let calibration = try XCTUnwrap(session.calibration)
        for sample in session.motion.samples {
            let motion = try OrientationMath.riderMotion(sample: sample, calibration: calibration, convention: session.manifest.rotationConvention)
            XCTAssertLessThanOrEqual(OrientationMath.norm(OrientationMath.rotationVector(motion.relativeQuaternion)), 0.21, "Calibration reference must survive the recording boundary")
        }
        let end = try XCTUnwrap(session.manifest.boundaries.ended?.hostUptime)
        for event in session.events { XCTAssertGreaterThanOrEqual(event.hostUptime, session.manifest.boundaries.started.hostUptime); XCTAssertLessThanOrEqual(event.hostUptime, end) }
        XCTAssertTrue(zip(session.events, session.events.dropFirst()).allSatisfy { $0.hostUptime <= $1.hostUptime })
        controller.startRecording()
        XCTAssertEqual(controller.state, .saved, "Saved review must precede another recording")
        let bytes = try Data(contentsOf: url.appendingPathComponent("manifest.json"))
        controller.acknowledgeSaved()
        XCTAssertEqual(controller.state, .uncalibrated)
        try await calibrate(controller)
        controller.startRecording()
        try await eventually { repository.writer != nil && controller.counts[.motion, default: 0] > 5 }
        controller.stopRecording(); controller.stopRecording()
        try await eventually { controller.state == .saved }
        XCTAssertEqual(try SessionRepository(directory: directory).list().count, 2)
        XCTAssertEqual(try Data(contentsOf: url.appendingPathComponent("manifest.json")), bytes)
    }
    private func calibrate(_ controller: RecordingController) async throws {
        controller.spokenCalibration = false
        controller.beginCalibration()
        for _ in 0..<3 {
            try await eventually { controller.calibration.canAdvance }
            controller.advanceCalibration()
            XCTAssertNotEqual(controller.calibration.stage, .retry, controller.status)
        }
        try await eventually { controller.state == .ready || controller.calibration.stage == .retry }
        guard controller.state == .ready else {
            XCTFail("Calibration failed: \(controller.status)")
            throw NSError(domain: controller.status, code: 1)
        }
    }
    private func eventually(file: StaticString = #filePath, line: UInt = #line, _ predicate: () -> Bool) async throws {
        for _ in 0..<160 {
            if predicate() { return }
            try await Task.sleep(for: .milliseconds(50))
        }
        XCTFail("Timed out waiting for workflow state", file: file, line: line)
        throw NSError(domain: "Workflow timeout", code: 1)
    }
}
