import XCTest
import SwiftUI
@testable import RideFormWatch
import RideFormCore

@MainActor
final class WatchRecordingTests: XCTestCase {
    func testGrantedRuntimeKeepsBackgroundCaptureUntilGrantEnds() async throws {
        let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
        let controller = RecordingController(repository: SessionRepository(directory: root), synthetic: true)
        let runtime = WatchCaptureRuntime()
        runtime.controller = controller
        runtime.start()
        XCTAssertTrue(runtime.isRunning)
        controller.startDiagnostics(mode: .fusedOnly)
        for _ in 0..<100 {
            if controller.state == .recording { break }
            try await Task.sleep(for: .milliseconds(50))
        }
        controller.watchSceneChanged(.background, backgroundRuntime: runtime.isRunning)
        try await Task.sleep(for: .milliseconds(300))
        XCTAssertEqual(controller.state, .recording)
        runtime.handleRuntimeInvalidation("Test grant ended")
        XCTAssertFalse(runtime.isRunning)
        for _ in 0..<100 {
            if controller.state == .saved { break }
            try await Task.sleep(for: .milliseconds(50))
        }
        XCTAssertEqual(controller.state, .saved)
        XCTAssertEqual(try controller.repository.load(XCTUnwrap(controller.savedURL)).manifest.boundaries.status, "interrupted")
    }
    func testTerminalCalibrationRetryReleasesRuntimeAndCanRestart() {
        let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
        let controller = RecordingController(repository: SessionRepository(directory: root), synthetic: true)
        let runtime = WatchCaptureRuntime()
        runtime.controller = controller
        controller.beginCalibration()
        runtime.start()
        controller.calibration.fail(.invalidInput, message: "Test failure", allowRecovery: false)
        runtime.updateCalibration()
        XCTAssertFalse(runtime.isRunning)
        XCTAssertEqual(controller.state, .calibrating)
        XCTAssertEqual(controller.calibration.stage, .retry)
        controller.beginCalibration()
        runtime.updateCalibration()
        XCTAssertTrue(runtime.isRunning)
        XCTAssertEqual(controller.calibration.stage, .preparingNeutral)
        controller.sceneInterrupted()
        runtime.stop()
    }
    func testInterruptDuringDiagnosticPreparationCannotStartLateRecording() async throws {
        let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
        let controller = RecordingController(repository: SessionRepository(directory: root), synthetic: true)
        controller.startDiagnostics(mode: .fusedOnly)
        controller.sceneInterrupted()
        try await Task.sleep(for: .milliseconds(500))
        XCTAssertEqual(controller.state, .uncalibrated)
        XCTAssertNil(controller.repository.writer)
        XCTAssertTrue(try controller.repository.list().isEmpty)
    }
    func testAudioCheckUsesSpeechAndPreservesEarlierLog() throws {
        let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
        var phrases: [String] = []
        let controller = RecordingController(repository: SessionRepository(directory: root), synthetic: true,
            guidanceSpeech: { text, done in phrases.append(text); done(); return true })
        controller.testCalibrationAudio()
        let first = try XCTUnwrap(controller.calibrationLogURL)
        let bytes = try Data(contentsOf: first)
        controller.testCalibrationAudio()
        XCTAssertNotEqual(first, controller.calibrationLogURL)
        XCTAssertEqual(try Data(contentsOf: first), bytes)
        XCTAssertEqual(phrases.count, 2)
        XCTAssertTrue(controller.audioTestStatus.contains("If you heard nothing"))
        XCTAssertEqual(controller.state, .uncalibrated)
    }
    #if !targetEnvironment(simulator)
    func testPhysicalSpeechOutputEvidence() async throws {
        let speaker = CalibrationSpeaker()
        var messages: [String] = []
        speaker.diagnostic = { messages.append($0) }
        var finished = false
        XCTAssertTrue(speaker.say("RideForm audio test. You should hear this instruction.") { finished = true })
        for _ in 0..<200 {
            if finished { break }
            try await Task.sleep(for: .milliseconds(100))
        }
        let attachment = XCTAttachment(string: messages.joined(separator: "\n"))
        attachment.name = "physical-watch-audio"; attachment.lifetime = .keepAlways; add(attachment)
        XCTAssertTrue(finished, "Speech did not finish: \(messages)")
        XCTAssertTrue(messages.contains { $0.hasPrefix("started:") })
        // Completion is playback evidence, never proof of human audibility.
    }
    #endif
    func testWatchCapturePersistsPortableSessionAndInterruption() async throws {
        let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
        let repository = SessionRepository(directory: root)
        let controller = RecordingController(repository: repository, synthetic: true)
        controller.recordAfterCalibration = true
        controller.beginCalibration()
        for _ in 0..<300 {
            if controller.state == .recording && controller.counts[.motion, default: 0] > 10 { break }
            try await Task.sleep(for: .milliseconds(50))
        }
        XCTAssertEqual(controller.state, .recording)
        controller.marker("LEFT")
        let beforeDimming = controller.counts[.motion, default: 0]
        controller.watchSceneChanged(.inactive)
        try await Task.sleep(for: .milliseconds(500))
        XCTAssertEqual(controller.state, .recording, "Dimming must not end a frontmost Watch recording")
        XCTAssertGreaterThan(controller.counts[.motion, default: 0], beforeDimming)
        controller.watchSceneChanged(.active)
        XCTAssertEqual(controller.state, .recording)
        controller.watchSceneChanged(.background)
        for _ in 0..<100 {
            if controller.state == .saved { break }
            try await Task.sleep(for: .milliseconds(50))
        }
        XCTAssertEqual(controller.state, .saved)
        let session = try repository.load(XCTUnwrap(controller.savedURL))
        XCTAssertEqual(session.manifest.platform, .appleWatch)
        XCTAssertEqual(session.manifest.boundaries.status, "interrupted")
        XCTAssertTrue(session.calibration?.verified == true)
        XCTAssertGreaterThan(session.motion.samples.count, 10)
        XCTAssertEqual(session.events.first?.label, "LEFT")
        XCTAssertEqual(session.events.filter { $0.kind == "lifecycle" }.map(\.label), ["INACTIVE", "ACTIVE", "BACKGROUND"])
        let inactive = try XCTUnwrap(session.events.first { $0.label == "INACTIVE" })
        let reactivated = try XCTUnwrap(session.events.first { $0.label == "ACTIVE" })
        XCTAssertTrue(session.motion.samples.contains { $0.receiptUptime > inactive.hostUptime && $0.receiptUptime < reactivated.hostUptime })
        XCTAssertNoThrow(try MotionAnalyzer.analyze(session: session))
    }
}
