import XCTest
import RideFormCore
@testable import RideForm

/// Synthetic delivery tests. They establish file accounting, never hardware accuracy.
final class EndToEndSessionTests: XCTestCase {
    #if targetEnvironment(simulator)
    func testCaptureStopRelaunchAnalysisAndIndependentExportReload() throws {
        let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
        defer { try? FileManager.default.removeItem(at: root) }
        let repository = SessionRepository(directory: root.appendingPathComponent("Sessions"))
        let capture = MotionCapture()
        let base = OrientationMath.quaternion(rotationVector: Vector3(x: 0.6, y: 0.4))
        let columns = [Vector3(x: 1), Vector3(y: 1), Vector3(z: 1)].map { OrientationMath.rotate(base, $0) }
        let matrix = RotationMatrix(m11: columns[0].x, m12: columns[1].x, m13: columns[2].x,
            m21: columns[0].y, m22: columns[1].y, m23: columns[2].y,
            m31: columns[0].z, m32: columns[1].z, m33: columns[2].z)
        let samples = (0..<41).map { index in
            MotionSample(sequence: UInt64(index), sensorTimestamp: 100.12345678901235 + Double(index) * 0.01,
                receiptUptime: 100.14345678901235 + Double(index) * 0.01,
                attitudeRadians: Vector3(x: 0.6, y: 0.4), attitudeQuaternion: base,
                attitudeRotationMatrix: matrix, rotationRate: Vector3(x: 0.001, y: -0.002, z: 0.003),
                gravity: OrientationMath.rotate(OrientationMath.inverse(base), Vector3(z: -1)),
                userAcceleration: Vector3(x: 0.12345678901234567),
                magneticField: .available(value: Vector3(x: 12.345678901234567), rawValidityCode: 3),
                headingRadians: .invalid(value: -1, reason: "arbitrary frame", rawValidityCode: -1))
        }
        let calibration = Calibration(neutralSamples: Array(samples.prefix(3)), leftGestureSamples: [samples[3]],
            forwardGestureSamples: [samples[4]], neutralQuaternion: base,
            configuration: CalibrationConfiguration(mountDescription: "synthetic oblique fixture"),
            actualDurationSeconds: 2.5, dispersionRadians: 0, verified: true)
        let manifest = SessionManifest(deviceModel: "synthetic simulator acceptance fixture", calibrationID: calibration.id,
            analysisConfiguration: MotionAnalyzer.defaultConfiguration)
        let bundle = try repository.begin(manifest: manifest, calibration: calibration)
        let acceleration = RawAccelerometerSample(sequence: 0, sensorTimestamp: 100.12745678901235, receiptUptime: 100.15, acceleration: Vector3(x: 0.9876543210987654))
        let gyro = RawGyroscopeSample(sequence: 0, sensorTimestamp: 100.13045678901235, receiptUptime: 100.16, rotationRate: Vector3(z: -0.12345678901234567))
        let events = [EventMarker(sequence: 0, label: "LEFT", hostUptime: 100.2, note: "quoted \"value\", preserved"),
            EventMarker(sequence: 1, kind: "cue", label: "CENTER", hostUptime: 100.3, scheduledUptime: 100.29, audioDispatchUptime: 100.3)]
        capture.startSimulatorFixture { repository.enqueue($0) }
        samples.forEach {
            capture.inspectConvention($0)
            capture.deliverSimulatorFixture(.motion($0))
        }
        capture.deliverSimulatorFixture(.accelerometer(acceleration))
        capture.deliverSimulatorFixture(.gyroscope(gyro))
        events.forEach { repository.enqueue($0) }
        let saved = expectation(description: "capture stop then real disk finalization barrier")
        capture.stop { summary in
            XCTAssertEqual(summary.acceptedCounts[.motion], samples.count)
            XCTAssertTrue(summary.rotationConvention.verified)
            repository.finalize(summary: summary) { result in
                if case .failure(let error) = result { XCTFail("\(error)") }
                saved.fulfill()
            }
        }
        capture.deliverSimulatorFixture(.motion(MotionSample(sensorTimestamp: 999)))
        wait(for: [saved], timeout: 10)
        XCTAssertNil(repository.writer)
        let relaunched = SessionRepository(directory: repository.directory)
        XCTAssertEqual(try relaunched.list(), [bundle])
        let session = try relaunched.load(bundle)
        XCTAssertEqual(session.motion.samples, samples)
        XCTAssertEqual(session.accelerometer.samples, [acceleration])
        XCTAssertEqual(session.gyroscope.samples, [gyro])
        XCTAssertEqual(session.events, events)
        XCTAssertEqual(session.calibration, calibration)
        XCTAssertEqual(session.manifest.analysisConfiguration, manifest.analysisConfiguration)
        for stream in session.manifest.streams {
            XCTAssertEqual(stream.acceptedCount, stream.writtenCount)
            XCTAssertEqual(stream.lossCount, 0)
            XCTAssertEqual(stream.statistics.count, stream.writtenCount)
        }
        let original = try masterBytes(bundle)
        let identity = try DerivedSidecar<AnalysisResult>.identity(session)
        let result = try MotionAnalyzer.analyzeAndSave(session: session, configuration: session.manifest.analysisConfiguration,
            masterURL: bundle, sidecarURL: root.appendingPathComponent("Derived/result.json"))
        XCTAssertEqual(result.inputSHA256, identity)
        let sidecar = try JSONDecoder().decode(DerivedSidecar<AnalysisResult>.self, from: Data(contentsOf: root.appendingPathComponent("Derived/result.json")))
        XCTAssertTrue(sidecar.isCurrent(for: session, configuration: result.configuration))
        let exporter = ShareExporter(temporaryDirectory: root)
        for _ in 0..<2 {
            let files = try exporter.prepare(bundle: bundle)
            let zip = try XCTUnwrap(files.first { $0.pathExtension == "zip" })
            let entries = try readStoredZIP(Data(contentsOf: zip))
            XCTAssertEqual(entries, original)
            let decoder = JSONDecoder()
            func rows<T: Decodable>(_ name: String, _: T.Type) throws -> [T] {
                try XCTUnwrap(entries[name]).split(separator: 10).map { try decoder.decode(T.self, from: Data($0)) }
            }
            XCTAssertEqual(try rows("motion.jsonl", MotionSample.self), samples)
            XCTAssertEqual(try rows("accelerometer.jsonl", RawAccelerometerSample.self), [acceleration])
            XCTAssertEqual(try rows("gyroscope.jsonl", RawGyroscopeSample.self), [gyro])
            XCTAssertEqual(try rows("events.jsonl", EventMarker.self), events)
            XCTAssertEqual(try decoder.decode(SessionManifest.self, from: XCTUnwrap(entries["manifest.json"])), session.manifest)
            XCTAssertEqual(try decoder.decode(Calibration.self, from: XCTUnwrap(entries["calibration.json"])), calibration)
            let motionCSV = try csvRows(Data(contentsOf: XCTUnwrap(files.first { $0.lastPathComponent == "motion.csv" })))
            XCTAssertEqual(motionCSV.count, samples.count + 1)
            let timestamp = try XCTUnwrap(motionCSV[0].firstIndex(of: "sensor_timestamp_seconds_since_boot"))
            let receipt = try XCTUnwrap(motionCSV[0].firstIndex(of: "receipt_host_uptime_seconds"))
            for (index, sample) in samples.enumerated() {
                XCTAssertEqual(Double(motionCSV[index + 1][timestamp]), sample.sensorTimestamp)
                XCTAssertEqual(Double(motionCSV[index + 1][receipt]), sample.receiptUptime)
            }
            let exportedManifest = try decoder.decode(SessionManifest.self, from: Data(contentsOf: XCTUnwrap(files.first { $0.lastPathComponent == "manifest.json" })))
            XCTAssertEqual(exportedManifest, session.manifest)
            XCTAssertEqual(try masterBytes(bundle), original)
            XCTAssertEqual(try DerivedSidecar<AnalysisResult>.identity(relaunched.load(bundle)), identity)
            XCTAssertEqual(try MotionAnalyzer.analyze(session: relaunched.load(bundle), configuration: result.configuration), result)
        }
    }
    #endif

    private func masterBytes(_ bundle: URL) throws -> [String: Data] {
        try Dictionary(uniqueKeysWithValues: FileManager.default.contentsOfDirectory(at: bundle, includingPropertiesForKeys: nil).map { ($0.lastPathComponent, try Data(contentsOf: $0)) })
    }

    /// Independent ZIP local-header reader; deliberately does not use MasterArchive or SessionIO.
    private func readStoredZIP(_ data: Data) throws -> [String: Data] {
        func integer(_ offset: Int, _ width: Int) throws -> Int {
            guard offset >= 0, offset + width <= data.count else { throw ParseError.truncated }
            return (0..<width).reduce(0) { $0 | Int(data[offset + $1]) << (8 * $1) }
        }
        var offset = 0, entries: [String: Data] = [:]
        while try integer(offset, 4) == 0x04034b50 {
            XCTAssertEqual(try integer(offset + 8, 2), 0)
            let size = try integer(offset + 18, 4), length = try integer(offset + 26, 2), extra = try integer(offset + 28, 2)
            let start = offset + 30, body = start + length + extra
            guard body + size <= data.count else { throw ParseError.truncated }
            let path = String(decoding: data[start..<(start + length)], as: UTF8.self)
            let name = String(try XCTUnwrap(path.split(separator: "/").last))
            entries[name] = data.subdata(in: body..<(body + size))
            offset = body + size
        }
        XCTAssertEqual(try integer(offset, 4), 0x02014b50)
        return entries
    }

    private func csvRows(_ data: Data) throws -> [[String]] {
        var rows: [[String]] = [], row: [String] = [], field = "", quoted = false
        let characters = Array(String(decoding: data, as: UTF8.self))
        var index = 0
        while index < characters.count {
            let character = characters[index]
            if character == "\"" {
                if quoted && index + 1 < characters.count && characters[index + 1] == "\"" { field.append("\""); index += 1 }
                else { quoted.toggle() }
            } else if character == "," && !quoted { row.append(field); field = "" }
            else if character == "\n" && !quoted { row.append(field); rows.append(row); row = []; field = "" }
            else { field.append(character) }
            index += 1
        }
        guard !quoted, row.isEmpty, field.isEmpty else { throw ParseError.truncated }
        return rows
    }
    private enum ParseError: Error { case truncated }
}

#if targetEnvironment(simulator)
@MainActor
extension EndToEndSessionTests {
    func testControllerFinalizationFailureRetainsWriterAndRetrySavesExactRows() async throws {
        try await exerciseDiskFailure(checkpoint: false)
    }

    func testControllerCheckpointFailureStopsAndRetainsStickyWarningAfterRetry() async throws {
        try await exerciseDiskFailure(checkpoint: true)
    }

    func testControllerLaterConventionInvalidationInterruptsAndRequiresRecalibration() async throws {
        let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
        defer { try? FileManager.default.removeItem(at: root) }
        let repository = SessionRepository(directory: root)
        let controller = RecordingController(repository: repository, synthetic: true)
        try await calibrate(controller)
        controller.startRecording()
        try await eventually { controller.state == .recording && controller.counts[.motion, default: 0] > 5 }
        controller.capture.inspectConvention(MotionSample(attitudeQuaternion: Quaternion(x: .nan)))
        try await eventually { controller.state == .saved }
        let session = try repository.load(XCTUnwrap(controller.savedURL))
        XCTAssertEqual(session.manifest.boundaries.status, "interrupted")
        XCTAssertFalse(session.manifest.rotationConvention.verified)
        XCTAssertTrue(session.manifest.diagnostics.contains { $0.code == "rotation_convention_invalidated" })
        XCTAssertTrue(controller.status.contains("Recalibration required"))
        XCTAssertFalse(session.motion.samples.isEmpty)
        controller.acknowledgeSaved()
        controller.startRecording()
        XCTAssertEqual(controller.state, .uncalibrated)
        XCTAssertNil(repository.writer)
    }

    private func exerciseDiskFailure(checkpoint: Bool) async throws {
        let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
        defer { try? FileManager.default.removeItem(at: root) }
        let repository = SessionRepository(directory: root)
        let controller = RecordingController(repository: repository, synthetic: true)
        try await calibrate(controller)
        controller.startRecording()
        try await eventually { controller.state == .recording && controller.counts[.motion, default: 0] > 5 }
        let bundle = try XCTUnwrap(repository.list().first)
        let manifestURL = bundle.appendingPathComponent("manifest.json")
        let manifestBytes = try Data(contentsOf: manifestURL)
        try FileManager.default.removeItem(at: manifestURL)
        try FileManager.default.createDirectory(at: manifestURL, withIntermediateDirectories: false)
        if !checkpoint { controller.stopRecording() }
        try await eventually { controller.state == .saveError }
        let retainedWriter = try XCTUnwrap(repository.writer)
        controller.startRecording()
        XCTAssertEqual(controller.state, .saveError)
        XCTAssertTrue(repository.writer === retainedWriter)
        try FileManager.default.removeItem(at: manifestURL)
        try manifestBytes.write(to: manifestURL)
        controller.retrySave()
        try await eventually { controller.state == .saved }
        XCTAssertNil(repository.writer)
        let session = try SessionRepository(directory: root).load(XCTUnwrap(controller.savedURL))
        XCTAssertGreaterThan(session.motion.samples.count, 5)
        XCTAssertEqual(session.motion.samples.map(\.sequence), Array(0..<UInt64(session.motion.samples.count)))
        for stream in session.manifest.streams {
            XCTAssertEqual(stream.acceptedCount, stream.writtenCount)
            XCTAssertEqual(stream.lossCount, 0)
        }
        if checkpoint {
            XCTAssertEqual(session.manifest.boundaries.status, "interrupted")
            XCTAssertTrue(session.manifest.diagnostics.contains { $0.code == "checkpoint_failed" })
            XCTAssertTrue(controller.status.contains("Saved with warning: Checkpoint failed"))
        } else {
            XCTAssertEqual(session.manifest.boundaries.status, "completed")
        }
    }

    private func calibrate(_ controller: RecordingController) async throws {
        controller.spokenCalibration = false
        controller.beginCalibration()
        for _ in 0..<3 {
            try await eventually { controller.calibration.canAdvance }
            controller.advanceCalibration()
        }
        try await eventually { controller.state == .ready }
    }

    private func eventually(_ predicate: () -> Bool) async throws {
        for _ in 0..<200 {
            if predicate() { return }
            try await Task.sleep(for: .milliseconds(50))
        }
        XCTFail("Timed out waiting for persisted workflow state")
        throw NSError(domain: "U8 workflow timeout", code: 1)
    }
}
#else
extension EndToEndSessionTests {
    /// Explicit opt-in physical desk smoke. No mounting, motion labels, or garage acceptance.
    func testOptInPhysicalForegroundSamplingArtifacts() throws {
        guard ProcessInfo.processInfo.environment["RIDEFORM_PHYSICAL_DESK_SMOKE"] == "1" else {
            throw XCTSkip("Requires explicitly opted-in connected-device foreground desk smoke")
        }
        let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
        let repository = SessionRepository(directory: documents.appendingPathComponent("DeskSmoke-" + UUID().uuidString))
        let capture = MotionCapture()
        guard capture.isDeviceMotionAvailable else { throw XCTSkip("Fused motion unavailable on connected device") }
        for mode in [CaptureMode.fusedOnly, .combined] {
            for trial in 1...3 {
                var manifest = SessionManifest(deviceModel: "connected iPhone; inspect actual model independently",
                    osVersion: ProcessInfo.processInfo.operatingSystemVersionString,
                    analysisConfiguration: MotionAnalyzer.defaultConfiguration)
                manifest.diagnostics.append(DiagnosticRecord(code: "desk_smoke_protocol",
                    message: "Foreground desk smoke; mode=\(mode); trial=\(trial); 30s requested; no mounted hardware or labeled movement acceptance"))
                let bundle = try repository.begin(manifest: manifest)
                capture.start(mode: mode, onRecord: { repository.enqueue($0) }, onDiagnostic: { repository.enqueue($0) })
                let saved = expectation(description: "physical desk capture drained and saved")
                DispatchQueue.main.asyncAfter(deadline: .now() + 30) {
                    capture.stop { summary in
                        repository.finalize(summary: summary) { result in
                            if case .failure(let error) = result { XCTFail("\(error)") }
                            saved.fulfill()
                        }
                    }
                }
                wait(for: [saved], timeout: 45)
                let session = try repository.load(bundle)
                XCTAssertFalse(session.motion.samples.isEmpty)
                XCTAssertEqual(session.manifest.boundaries.status, "completed")
                for stream in session.manifest.streams {
                    XCTAssertEqual(stream.acceptedCount, stream.writtenCount)
                    XCTAssertEqual(stream.lossCount, 0)
                }
                let exported = try ShareExporter(temporaryDirectory: repository.directory).prepare(bundle: bundle)
                let attachment = XCTAttachment(string: "Desk smoke master: \(bundle.path)\nExports: \(exported.map(\.path).joined(separator: "\n"))")
                attachment.lifetime = .keepAlways
                add(attachment)
            }
        }
    }
}
#endif
