import XCTest
@testable import RideFormCore

final class TransitionTests: XCTestCase {
    func testPartialLegsAtSessionAndGapStartRetainBoundaryUncertainty() {
        let trace = phaseTrace(knots: [(0,0),(1,20)])
        let completeRun = TransitionDetector.detect(trace: trace, configuration: MotionAnalyzer.defaultConfiguration, onsetThreshold: 5 * .pi/180)
        XCTAssertEqual(completeRun.count, 1)
        XCTAssertTrue(completeRun[0].qualityFlags.contains("onset_at_segment_boundary"))
        XCTAssertTrue(completeRun[0].qualityFlags.contains("truncated"))
        let pendingRun = TransitionDetector.detect(trace: Array(trace.prefix(3)), configuration: MotionAnalyzer.defaultConfiguration, onsetThreshold: 5 * .pi/180)
        XCTAssertEqual(pendingRun.count, 1)
        XCTAssertTrue(pendingRun[0].qualityFlags.contains("onset_at_segment_boundary"))
        XCTAssertTrue(pendingRun[0].qualityFlags.contains("onset_unconfirmed"))
        var gapTrace = trace
        for i in gapTrace.indices where i >= 25 { gapTrace[i].segmentID = 1 }
        let gapRuns = TransitionDetector.detect(trace: gapTrace, configuration: MotionAnalyzer.defaultConfiguration, onsetThreshold: 5 * .pi/180)
        XCTAssertEqual(gapRuns.count, 2)
        XCTAssertTrue(gapRuns.allSatisfy { $0.qualityFlags.contains("onset_at_segment_boundary") })
        XCTAssertNil(gapRuns[1].stableHoldTimestamp)
        XCTAssertNil(gapRuns[1].endDecelerationTimestamp)
    }
    func testIndependentPhaseFixtureAndReturnGrouping() throws {
        let trace = phaseTrace(knots: [(0,0),(1,0),(2,20),(3,20),(4,0),(5,0),(6,-20),(7,-20)])
        let transitions = TransitionDetector.detect(trace: trace, configuration: MotionAnalyzer.defaultConfiguration, onsetThreshold: 5 * .pi/180)
        XCTAssertEqual(transitions.count, 3)
        XCTAssertEqual(transitions.map(\.kind), ["outbound", "return", "outbound"])
        XCTAssertEqual(transitions.map(\.side), ["left", "left", "right"])
        for (t, onset) in zip(transitions, [1.0,3,5]) {
            XCTAssertEqual(t.onsetTimestamp, onset, accuracy: 0.020001)
            XCTAssertEqual(try XCTUnwrap(t.endDecelerationTimestamp), onset+1, accuracy: 0.020001)
            XCTAssertEqual(try XCTUnwrap(t.stableHoldTimestamp), onset+1.4, accuracy: 0.020001)
            XCTAssertEqual(try XCTUnwrap(t.durationSeconds), 1, accuracy: 0.020001)
            XCTAssertEqual(try XCTUnwrap(t.settlingTimeSeconds), 0.4, accuracy: 0.020001)
            XCTAssertEqual(t.rangeRadians, 20 * .pi/180, accuracy: 1e-9)
        }
        XCTAssertEqual(transitions[1].linkedOutboundID, transitions[0].id)
        XCTAssertEqual(transitions[1].groupID, transitions[0].groupID)
    }
    func testOvershootTwoCorrectionsExcludeReturn() throws {
        let trace = phaseTrace(knots: [(0,0),(1,0),(2,24),(2.2,18),(2.4,20),(3,20),(4,0),(5,0)])
        let ts = TransitionDetector.detect(trace: trace, configuration: MotionAnalyzer.defaultConfiguration, onsetThreshold: 5 * .pi/180)
        XCTAssertEqual(ts.count, 2)
        XCTAssertEqual(try XCTUnwrap(ts[0].overshootRadians), 4 * .pi/180, accuracy: 1e-8)
        XCTAssertEqual(ts[0].corrections.count, 2)
        XCTAssertEqual(ts[0].corrections[0].magnitudeRadians, 6 * .pi/180, accuracy: 1e-9)
        XCTAssertEqual(ts[0].corrections[1].magnitudeRadians, 2 * .pi/180, accuracy: 1e-9)
        XCTAssertEqual(ts[1].kind, "return")
        XCTAssertEqual(ts[1].corrections.count, 0)
    }
    func testNoiseDirectCrossNoHoldAndGap() {
        let noisy = phaseTrace(knots: [(0,0),(1,0.1),(2,0),(3,0.1)])
        XCTAssertTrue(TransitionDetector.detect(trace: noisy, configuration: MotionAnalyzer.defaultConfiguration, onsetThreshold: 5 * .pi/180).isEmpty)
        let cross = phaseTrace(knots: [(0,20),(1,20),(3,-20),(4,-20)])
        let direct = TransitionDetector.detect(trace: cross, configuration: MotionAnalyzer.defaultConfiguration, onsetThreshold: 5 * .pi/180)
        XCTAssertEqual(direct.first?.kind, "directCross")
        XCTAssertTrue(direct.first?.qualityFlags.contains("direct_crossing") ?? false)
        var truncated = phaseTrace(knots: [(0,0),(1,0),(2,20)])
        let noHold = TransitionDetector.detect(trace: truncated, configuration: MotionAnalyzer.defaultConfiguration, onsetThreshold: 5 * .pi/180)
        XCTAssertNil(noHold.first?.durationSeconds)
        XCTAssertNil(noHold.first?.overshootRadians)
        XCTAssertTrue(noHold.first?.qualityFlags.contains("truncated") ?? false)
        for i in truncated.indices where i >= 80 { truncated[i].segmentID = 1 }
        let gap = TransitionDetector.detect(trace: truncated, configuration: MotionAnalyzer.defaultConfiguration, onsetThreshold: 5 * .pi/180)
        XCTAssertTrue(gap.first?.qualityFlags.contains("gap_interrupted") ?? false)
        XCTAssertNil(gap.first?.stableHoldTimestamp)
    }
    func testAsymmetricSummaryUsesSampleVarianceAndMissingSides() throws {
        func transition(_ degrees: Double, _ side: String, _ id: Int) -> Transition {
            let trace = phaseTrace(knots: [(0,0),(1,0),(2,degrees),(3,degrees)])
            var t = TransitionDetector.detect(trace: trace, configuration: MotionAnalyzer.defaultConfiguration, onsetThreshold: 5 * .pi/180)[0]
            t.id = id; t.side = side
            return t
        }
        let summary = SessionSummary(transitions: [transition(10,"left",0), transition(20,"left",1), transition(-10,"right",2), transition(-10,"right",3)])
        let left = summary.groups[0].rangeRadians
        XCTAssertEqual(left.sampleCount, 2)
        XCTAssertEqual(try XCTUnwrap(left.mean), 15 * .pi/180, accuracy: 1e-9)
        XCTAssertEqual(try XCTUnwrap(left.variance), 50 * pow(.pi/180,2), accuracy: 1e-9)
        XCTAssertEqual(try XCTUnwrap(summary.rangeAsymmetry[0].normalizedDifference), 0.4, accuracy: 1e-9)
        XCTAssertNil(summary.rangeAsymmetry[1].normalizedDifference)
        XCTAssertNil(MetricDistribution(values: [1]).variance)
    }
}
/// Independent piecewise-linear fixture; analytic slope supplies the captured gyro.
func phaseTrace(knots: [(Double,Double)], step: Double = 0.02) -> [AnalysisPoint] {
    let count = Int((knots.last!.0-knots.first!.0)/step)
    return (0...count).map { i in
        let t = knots.first!.0+Double(i)*step
        var k = 0
        while k+1 < knots.count-1 && t >= knots[k+1].0-1e-10 { k += 1 }
        let a = knots[k], b = knots[k+1]
        let slope = (b.1-a.1)/(b.0-a.0)
        let angle = a.1+slope*(t-a.0)
        return AnalysisPoint(timestamp: t, sequence: UInt64(i), segmentID: 0, anglesRadians: Vector3(x: angle * .pi/180), angularVelocity: Vector3(x: -slope * .pi/180), angularAcceleration: Vector3(), angularJerk: Vector3(), userAcceleration: Vector3())
    }
}
