import XCTest
@testable import RideForm
import RideFormCore

final class MotionCaptureTests: XCTestCase {
    func testPhysicalCoreMotionTransposeRepresentationVerifiesWithoutChangingRawSample() {
        // iPhone sample from the 2026-10-01 device-only diagnostic. Core Motion's
        // matrix is the transpose of the quaternion's active rotation matrix.
        let sample = MotionSample(
            attitudeQuaternion: Quaternion(x: 0.4121965757639524, y: 0.07765702350217363, z: 7.42263574357314e-18, w: 0.9077793246462788),
            attitudeRotationMatrix: RotationMatrix(m11: 0.9879387617111206, m12: 0.06401991844177246, m13: -0.14099088311195374,
                m21: 0.06401991844177246, m22: 0.6601879596710205, m23: 0.7483670711517334,
                m31: 0.14099088311195374, m32: -0.7483670711517334, m33: 0.6481267213821411),
            gravity: Vector3(x: 0.14099088311195374, y: -0.7483670711517334, z: -0.6481267213821411))
        let capture = MotionCapture()
        for _ in 0..<10 { capture.inspectConvention(sample) }
        let checked = expectation(description: "physical convention")
        capture.snapshot { summary in
            XCTAssertTrue(summary.rotationConvention.verified, summary.conventionDiagnostics)
            XCTAssertEqual(summary.rotationConvention.capturedToCanonical, "identity")
            checked.fulfill()
        }
        wait(for: [checked], timeout: 5)
    }
    #if !targetEnvironment(simulator)
    /// Explicit device-only diagnostic; no simulator result stands in for this check.
    func testPhysicalSensorConventionEvidence() {
        let capture = MotionCapture()
        let sampled = expectation(description: "100 physical motion samples")
        var count = 0
        capture.start(mode: .fusedOnly) { record in
            if case .motion = record {
                count += 1
                if count == 100 { sampled.fulfill() }
            }
        }
        wait(for: [sampled], timeout: 10)
        let stopped = expectation(description: "physical diagnostic stopped")
        capture.stop { summary in
            let attachment = XCTAttachment(string: "verified=\(summary.rotationConvention.verified); \(summary.rotationConvention.capturedToCanonical); \(summary.conventionDiagnostics)")
            attachment.name = "physical-sensor-convention"; attachment.lifetime = .keepAlways
            self.add(attachment)
            XCTAssertGreaterThanOrEqual(summary.acceptedCounts[.motion, default: 0], 100)
            stopped.fulfill()
        }
        wait(for: [stopped], timeout: 5)
    }
    #endif
    func testClockValidationAndReset() {
        let clock = SessionClock()
        XCTAssertGreaterThan(clock.now(), 0)
        for index in 0..<10 { clock.validateMapping(sensorTimestamp: Double(index), receiptUptime: Double(index) + 0.02) }
        XCTAssertTrue(clock.mapping.validated)
        clock.validateMapping(sensorTimestamp: 10, receiptUptime: 9)
        XCTAssertFalse(clock.mapping.validated)
        clock.resetValidation()
        XCTAssertFalse(clock.mapping.validated)
    }

    func testGravitySelectsBothDirectionsAndAmbiguousNeutralRemainsUnverified() {
        func sample(inverted: Bool, transposed: Bool) -> MotionSample {
            // Noncommuting 0.6-radian X then 0.4-radian Y rotations.
            let sx = sin(0.3), cx = cos(0.3), sy = sin(0.2), cy = cos(0.2)
            let q = Quaternion(x: sx * cy, y: cx * sy, z: sx * sy, w: cx * cy)
            let x = q.x, y = q.y, z = q.z, w = q.w
            let m = RotationMatrix(m11: 1-2*(y*y+z*z), m12: 2*(x*y-z*w), m13: 2*(x*z+y*w),
                m21: 2*(x*y+z*w), m22: 1-2*(x*x+z*z), m23: 2*(y*z-x*w),
                m31: 2*(x*z-y*w), m32: 2*(y*z+x*w), m33: 1-2*(x*x+y*y))
            // Identity canonical uses matrix transpose to put reference gravity into device.
            let gravity = inverted ? Vector3(x: -m.m13, y: -m.m23, z: -m.m33) : Vector3(x: -m.m31, y: -m.m32, z: -m.m33)
            let matrix = transposed ? RotationMatrix(m11: m.m11, m12: m.m21, m13: m.m31,
                m21: m.m12, m22: m.m22, m23: m.m32, m31: m.m13, m32: m.m23, m33: m.m33) : m
            return MotionSample(attitudeQuaternion: q, attitudeRotationMatrix: matrix, gravity: gravity)
        }
        for inverted in [false, true] {
          for transposed in [false, true] {
            let capture = MotionCapture()
            capture.inspectConvention(MotionSample(gravity: Vector3(z: -1)))
            for _ in 0..<10 { capture.inspectConvention(sample(inverted: inverted, transposed: transposed)) }
            let snapshot = expectation(description: "convention")
            capture.snapshot { result in
                XCTAssertTrue(result.rotationConvention.verified)
                XCTAssertEqual(result.rotationConvention.capturedToCanonical, inverted ? "quaternion conjugate" : "identity")
                XCTAssertTrue(result.rotationConvention.capturedAttitude.contains(transposed ? "transpose" : "direct"))
                snapshot.fulfill()
            }
            wait(for: [snapshot], timeout: 5)
          }
        }
        let capture = MotionCapture()
        for _ in 0..<10 { capture.inspectConvention(MotionSample(gravity: Vector3(z: -1))) }
        let ambiguous = expectation(description: "ambiguous")
        capture.snapshot { result in
            XCTAssertFalse(result.rotationConvention.verified)
            ambiguous.fulfill()
        }
        wait(for: [ambiguous], timeout: 5)
    }

    func testMatrixRepresentationCannotSwitchWithinCapture() {
        let q = Quaternion(x: sin(0.3), w: cos(0.3))
        func sample(transposed: Bool) -> MotionSample {
            let sign = transposed ? -1.0 : 1.0
            return MotionSample(attitudeQuaternion: q,
                attitudeRotationMatrix: RotationMatrix(m11: 1, m22: cos(0.6), m23: -sign*sin(0.6), m32: sign*sin(0.6), m33: cos(0.6)),
                gravity: Vector3(y: -sin(0.6), z: -cos(0.6)))
        }
        for first in [false, true] {
            let capture = MotionCapture()
            for _ in 0..<10 { capture.inspectConvention(sample(transposed: first)) }
            capture.inspectConvention(sample(transposed: !first))
            for _ in 0..<10 { capture.inspectConvention(sample(transposed: first)) }
            let checked = expectation(description: "inconsistent matrix relationship")
            capture.snapshot { summary in
                XCTAssertFalse(summary.rotationConvention.verified)
                XCTAssertTrue(summary.conventionDiagnostics.contains("inconsistent quaternion/matrix relationship"))
                checked.fulfill()
            }
            wait(for: [checked], timeout: 5)
        }
    }

    func testAmbiguousMismatchAndNonfiniteInputsInvalidateVerifiedConvention() {
        let q = Quaternion(x: sin(0.3), w: cos(0.3))
        let m = RotationMatrix(m11: 1, m22: cos(0.6), m23: -sin(0.6), m32: sin(0.6), m33: cos(0.6))
        let valid = MotionSample(attitudeQuaternion: q, attitudeRotationMatrix: m, gravity: Vector3(y: -sin(0.6), z: -cos(0.6)))
        let invalid = [
            MotionSample(attitudeRotationMatrix: RotationMatrix(m11: -1), gravity: Vector3(z: -1)),
            MotionSample(attitudeQuaternion: Quaternion(x: .nan), gravity: Vector3(z: -1)),
            MotionSample(attitudeRotationMatrix: RotationMatrix(m11: .infinity), gravity: Vector3(z: -1)),
            MotionSample(gravity: Vector3(x: .nan, z: -1)),
            MotionSample(rotationRate: Vector3(x: .infinity), gravity: Vector3(z: -1)),
            MotionSample(gravity: Vector3(z: -1), userAcceleration: Vector3(y: .nan)),
            MotionSample(attitudeRadians: Vector3(z: .nan), gravity: Vector3(z: -1))
        ]
        for sample in invalid {
            let capture = MotionCapture()
            for _ in 0..<10 { capture.inspectConvention(valid) }
            let verified = expectation(description: "verified before invalid input")
            capture.snapshot { XCTAssertTrue($0.rotationConvention.verified); verified.fulfill() }
            wait(for: [verified], timeout: 5)
            capture.inspectConvention(sample)
            let invalidated = expectation(description: "invalidated immediately")
            capture.snapshot {
                XCTAssertFalse($0.rotationConvention.verified)
                XCTAssertEqual($0.rotationConvention.capturedToCanonical, "unverified")
                XCTAssertFalse($0.conventionDiagnostics.contains("first failure=none"))
                XCTAssertTrue($0.conventionDiagnostics.contains("matrix mismatch=true"))
                invalidated.fulfill()
            }
            wait(for: [invalidated], timeout: 5)
            for _ in 0..<10 { capture.inspectConvention(valid) }
            let remainsInvalid = expectation(description: "invalid for remainder of capture")
            capture.snapshot { XCTAssertFalse($0.rotationConvention.verified); remainsInvalid.fulfill() }
            wait(for: [remainsInvalid], timeout: 5)
        }
    }

    #if targetEnvironment(simulator)
    func testSensorCancellationCannotCancelQueuedControlOperations() {
        let capture = MotionCapture()
        capture.simulateSensorCancellationOnFixtureStop = true
        let entered = expectation(description: "callback entered")
        let release = DispatchSemaphore(value: 0)
        let stopped = expectation(description: "first stop")
        let snapshot = expectation(description: "snapshot after queued restart")
        let stoppedAgain = expectation(description: "second stop")
        capture.startSimulatorFixture { _ in entered.fulfill(); _ = release.wait(timeout: .now() + 5) }
        capture.deliverSimulatorFixture(.motion(MotionSample()))
        wait(for: [entered], timeout: 5)
        capture.stop { _ in stopped.fulfill() }
        capture.startSimulatorFixture { _ in }
        capture.snapshot { XCTAssertEqual($0.acceptedCounts[.motion, default: 0], 0); snapshot.fulfill() }
        capture.stop { _ in stoppedAgain.fulfill() }
        release.signal()
        wait(for: [stopped, snapshot, stoppedAgain], timeout: 5)
    }

    func testLatePriorGenerationCallbackCannotEnterRestartedSession() {
        let capture = MotionCapture()
        let ready = expectation(description: "old callback captured")
        var oldCallback: ((CapturedRecord) -> Void)?
        capture.startSimulatorFixture { _ in XCTFail("old session should receive no records") }
        capture.makeSimulatorFixtureCallback { oldCallback = $0; ready.fulfill() }
        wait(for: [ready], timeout: 5)
        let stopped = expectation(description: "old session stopped")
        capture.stop { _ in stopped.fulfill() }
        wait(for: [stopped], timeout: 5)
        let nextReady = expectation(description: "new session started")
        var received: [Double] = []
        capture.startSimulatorFixture { if case .motion(let sample) = $0 { received.append(sample.sensorTimestamp) } }
        capture.snapshot { _ in nextReady.fulfill() }
        wait(for: [nextReady], timeout: 5)
        oldCallback?(.motion(MotionSample(sensorTimestamp: 99)))
        capture.deliverSimulatorFixture(.motion(MotionSample(sensorTimestamp: 1)))
        let nextStopped = expectation(description: "new session stopped")
        capture.stop {
            XCTAssertEqual($0.acceptedCounts[.motion], 1)
            XCTAssertEqual(received, [1])
            nextStopped.fulfill()
        }
        wait(for: [nextStopped], timeout: 5)
    }


    func testIndependentStreamsAndStopBoundaryPersistAcceptedRows() throws {
        let capture = MotionCapture()
        let stopped = expectation(description: "stop barrier")
        var motion: [MotionSample] = []
        var accelerometer: [RawAccelerometerSample] = []
        var gyroscope: [RawGyroscopeSample] = []
        let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
        capture.startSimulatorFixture { record in
            XCTAssertFalse(Thread.isMainThread)
            switch record {
            case .motion(let sample): motion.append(sample)
            case .accelerometer(let sample): accelerometer.append(sample)
            case .gyroscope(let sample): gyroscope.append(sample)
            }
        }
        capture.deliverSimulatorFixture(.motion(MotionSample(sensorTimestamp: 1)))
        capture.deliverSimulatorFixture(.accelerometer(RawAccelerometerSample(sensorTimestamp: 1.003)))
        capture.deliverSimulatorFixture(.gyroscope(RawGyroscopeSample(sensorTimestamp: 1.007)))
        capture.deliverSimulatorFixture(.motion(MotionSample(sensorTimestamp: 1.01)))
        capture.stop { summary in
            XCTAssertEqual(summary.acceptedCounts[.motion], 2)
            XCTAssertEqual(summary.acceptedCounts[.accelerometer], 1)
            XCTAssertEqual(summary.acceptedCounts[.gyroscope], 1)
            let session = MotionSession(motion: MotionStream(samples: motion), accelerometer: MotionStream(samples: accelerometer), gyroscope: MotionStream(samples: gyroscope))
            do { try SessionCodec.encode(session).write(to: url) } catch { XCTFail("\(error)") }
            stopped.fulfill()
        }
        capture.deliverSimulatorFixture(.motion(MotionSample(sensorTimestamp: 1.02)))
        wait(for: [stopped], timeout: 5)
        let reloaded = try SessionCodec.decode(Data(contentsOf: url))
        XCTAssertEqual(reloaded.motion.samples.map(\.sensorTimestamp), [1, 1.01])
        XCTAssertEqual(reloaded.accelerometer.samples.map(\.sensorTimestamp), [1.003])
        XCTAssertEqual(reloaded.gyroscope.samples.map(\.sensorTimestamp), [1.007])
        XCTAssertEqual(reloaded.motion.samples.map(\.sequence), [0, 1])
    }
    #endif
}
