import XCTest
@testable import RideFormCore

final class AnalysisTests: XCTestCase {
    func testSidecarRejectsSymlinkParentsWithoutChangingMaster() throws {
        let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
        defer { try? FileManager.default.removeItem(at: root) }
        let master = root.appendingPathComponent("master")
        try FileManager.default.createDirectory(at: master, withIntermediateDirectories: true)
        let sentinel = master.appendingPathComponent("sentinel.json")
        let bytes = Data("immutable master".utf8)
        try bytes.write(to: sentinel)
        let alias = root.appendingPathComponent("alias")
        try FileManager.default.createSymbolicLink(atPath: alias.path, withDestinationPath: "master")
        let chain = root.appendingPathComponent("chain")
        try FileManager.default.createSymbolicLink(atPath: chain.path, withDestinationPath: "alias")
        let absoluteAlias = root.appendingPathComponent("absolute-alias")
        try FileManager.default.createSymbolicLink(atPath: absoluteAlias.path, withDestinationPath: master.path)
        let paths = [alias.appendingPathComponent("inside.json"), alias.appendingPathComponent("absent/deeper/inside.json"), chain.appendingPathComponent("inside.json"), absoluteAlias.appendingPathComponent("absent/inside.json"), alias.appendingPathComponent("sentinel.json"), master, master.appendingPathComponent("nested/../inside.json")]
        for path in paths {
            XCTAssertThrowsError(try MotionAnalyzer.analyzeAndSave(session: analysisSession([]), masterURL: master, sidecarURL: path)) { error in
                XCTAssertEqual(error as? AnalysisError, .sidecarInsideMaster)
            }
        }
        XCTAssertThrowsError(try MotionAnalyzer.analyzeAndSave(session: analysisSession([]), masterURL: alias, sidecarURL: master.appendingPathComponent("inside.json")))
        XCTAssertEqual(try Data(contentsOf: sentinel), bytes)
        XCTAssertEqual(try FileManager.default.contentsOfDirectory(atPath: master.path), ["sentinel.json"])
        // An alias outside the master may still be used, including new descendant directories.
        let outside = root.appendingPathComponent("outside")
        try FileManager.default.createDirectory(at: outside, withIntermediateDirectories: true)
        let outsideAlias = root.appendingPathComponent("outside-alias")
        try FileManager.default.createSymbolicLink(atPath: outsideAlias.path, withDestinationPath: "outside")
        _ = try MotionAnalyzer.analyzeAndSave(session: analysisSession([]), masterURL: master, sidecarURL: outsideAlias.appendingPathComponent("new/result.json"))
        XCTAssertTrue(FileManager.default.fileExists(atPath: outside.appendingPathComponent("new/result.json").path))
        let loop = root.appendingPathComponent("loop")
        try FileManager.default.createSymbolicLink(atPath: loop.path, withDestinationPath: "loop")
        XCTAssertThrowsError(try MotionAnalyzer.analyzeAndSave(session: analysisSession([]), masterURL: master, sidecarURL: loop.appendingPathComponent("inside.json"))) { error in
            XCTAssertEqual(error as? AnalysisError, .invalidSidecarPath)
        }
    }
    func testIrregularQuadraticGyroDerivativesAndReproducibility() throws {
        let times = [0.0, 0.07, 0.18, 0.27, 0.39, 0.51, 0.63]
        let samples = times.enumerated().map { i, t in MotionSample(sequence: UInt64(i), sensorTimestamp: t, rotationRate: Vector3(x: -3*t*t)) }
        let session = analysisSession(samples)
        let identity = try DerivedSidecar<AnalysisResult>.identity(session)
        var config = MotionAnalyzer.defaultConfiguration
        config.filterParameters["maximumGapSeconds"] = 0.5
        let result = try MotionAnalyzer.analyze(session: session, configuration: config)
        for p in result.rawTrace {
            XCTAssertEqual(p.angularVelocity.x, -3*p.timestamp*p.timestamp, accuracy: 1e-10)
            XCTAssertEqual(try XCTUnwrap(p.angularAcceleration).x, -6*p.timestamp, accuracy: 1e-8)
            XCTAssertEqual(try XCTUnwrap(p.angularJerk).x, -6, accuracy: 1e-7)
        }
        XCTAssertEqual(result, try MotionAnalyzer.analyze(session: session, configuration: config))
        XCTAssertEqual(identity, try DerivedSidecar<AnalysisResult>.identity(session))
        XCTAssertEqual(result.rawTrace.count, samples.count)
    }
    func testGapNeverBridgesDerivative() throws {
        let samples = [0.0, 0.01, 0.02, 1, 1.01].enumerated().map { MotionSample(sequence: UInt64($0.offset), sensorTimestamp: $0.element, rotationRate: Vector3(x: $0.element)) }
        let result = try MotionAnalyzer.analyze(session: analysisSession(samples))
        XCTAssertNotEqual(result.rawTrace[2].segmentID, result.rawTrace[3].segmentID)
        XCTAssertNil(result.rawTrace[3].angularJerk)
        XCTAssertTrue(result.qualityFlags.contains("sampling_gap"))
    }
    func testFilterPreservesAcquisitionAndExposesWindowFailures() throws {
        let times = (0...100).map { Double($0)*0.01 }
        let samples = times.enumerated().map { i,t in MotionSample(sequence: UInt64(i), sensorTimestamp: t, rotationRate: Vector3(x: t*t+0.01*(i % 2 == 0 ? 1 : -1))) }
        let session = analysisSession(samples)
        var configuration = MotionAnalyzer.defaultConfiguration
        configuration.filterMethod = "localPolynomial"
        let filtered = try MotionAnalyzer.analyze(session: session, configuration: configuration)
        let unfiltered = try MotionAnalyzer.analyze(session: session)
        XCTAssertEqual(filtered.rawTrace, unfiltered.rawTrace)
        XCTAssertTrue(filtered.configuredTrace.allSatisfy(\.filterApplied))
        XCTAssertLessThan(abs(filtered.configuredTrace[50].angularVelocity.x-0.25), abs(filtered.rawTrace[50].angularVelocity.x-0.25))
        configuration.derivativeWindowSeconds = 0.001
        let short = try MotionAnalyzer.analyze(session: session, configuration: configuration)
        XCTAssertTrue(short.qualityFlags.contains("filter_window_insufficient"))
        XCTAssertNil(short.configuredTrace[50].angularJerk)
    }
    func testFixedFrameVectorTransformAndSidecarIsolation() throws {
        let sample = MotionSample(sensorTimestamp: 0, attitudeQuaternion: OrientationMath.quaternion(rotationVector: Vector3(z: .pi/2)), rotationRate: Vector3(x: 2), userAcceleration: Vector3(x: 3))
        let session = analysisSession([sample])
        let result = try MotionAnalyzer.analyze(session: session)
        XCTAssertEqual(result.rawTrace[0].angularVelocity.y, 2, accuracy: 1e-12)
        XCTAssertEqual(result.rawTrace[0].userAcceleration.y, 3, accuracy: 1e-12)
        let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
        defer { try? FileManager.default.removeItem(at: root) }
        let master = root.appendingPathComponent("master")
        let sidecar = root.appendingPathComponent("derived/result.json")
        try FileManager.default.createDirectory(at: master, withIntermediateDirectories: true)
        let masterData = try SessionCodec.encode(session)
        let masterFile = master.appendingPathComponent("saved-session.json")
        try masterData.write(to: masterFile)
        let saved = try MotionAnalyzer.analyzeAndSave(session: session, masterURL: master, sidecarURL: sidecar)
        let payload = try JSONDecoder().decode(DerivedSidecar<AnalysisResult>.self, from: Data(contentsOf: sidecar))
        XCTAssertEqual(saved, payload.payload)
        XCTAssertTrue(payload.isCurrent(for: session, configuration: saved.configuration))
        XCTAssertEqual(try Data(contentsOf: masterFile), masterData)
        XCTAssertEqual(try FileManager.default.contentsOfDirectory(atPath: master.path), ["saved-session.json"])
        XCTAssertThrowsError(try MotionAnalyzer.analyzeAndSave(session: session, masterURL: master, sidecarURL: master.appendingPathComponent("analysis.json")))
        var changed = session; changed.motion.samples[0].rotationRate.x = 3
        XCTAssertFalse(payload.isCurrent(for: changed, configuration: saved.configuration))
    }
    func testRejectsUnorderedTimeAndUnknownFilter() {
        let samples = [MotionSample(sensorTimestamp: 1), MotionSample(sensorTimestamp: 1)]
        XCTAssertThrowsError(try MotionAnalyzer.analyze(session: analysisSession(samples)))
        var config = MotionAnalyzer.defaultConfiguration; config.filterMethod = "unknown"
        XCTAssertThrowsError(try MotionAnalyzer.analyze(session: analysisSession([]), configuration: config))
    }
    func testNoiseThresholdMarkersAndWatchParity() throws {
        var phone = analysisSession([MotionSample(sensorTimestamp: 0), MotionSample(sensorTimestamp: 0.01), MotionSample(sensorTimestamp: 0.02)])
        phone.calibration!.neutralSamples = [MotionSample(rotationRate: Vector3(x: 0.15)), MotionSample(rotationRate: Vector3(x: 0.16)), MotionSample(rotationRate: Vector3(x: 0.17))]
        phone.events = [EventMarker(sequence: 1, hostUptime: 0.01)]
        let first = try MotionAnalyzer.analyze(session: phone)
        XCTAssertEqual(first.onsetThresholdRadiansPerSecond, 0.22, accuracy: 1e-9)
        XCTAssertEqual(first.configuration.transitionThresholds["onsetSpeed"]!, 0.22, accuracy: 1e-9)
        XCTAssertEqual(first.events, phone.events)
        XCTAssertEqual(first, try MotionAnalyzer.analyze(session: phone, configuration: first.configuration))
        var watch = phone; watch.manifest.platform = .appleWatch
        let second = try MotionAnalyzer.analyze(session: watch)
        XCTAssertEqual(first.rawTrace, second.rawTrace)
        XCTAssertEqual(first.transitions, second.transitions)
        XCTAssertNotEqual(first.inputSHA256, second.inputSHA256)
    }
}
func analysisSession(_ samples: [MotionSample]) -> MotionSession {
    MotionSession(manifest: SessionManifest(rotationConvention: RotationConvention(capturedToCanonical: "identity", verified: true)), motion: MotionStream(samples: samples), calibration: Calibration(verified: true))
}
