//
// Copyright (c) Double Symmetry GmbH
// Commercial use requires a license. See https://rntp.dev/pricing
//

import XCTest

@testable import PlayerCore

// MARK: - Sleep Timer Integration Tests
//
// These tests exercise the complete sleep timer flow using the real production
// SleepTimerController with AudioPlayer + MockPlayerEngine, giving us observable
// side-effects without requiring the React Native runtime.
//
// The three bugs caught during manual testing are guarded by explicit test cases:
//  1. Timer not firing (RunLoop) — covered by tick-count / pause assertions.
//  2. Volume not restoring after fade — testVolumeRestoredAfterFadeCompletes.
//  3. Pause overridden during media-item transition — testMediaItemTimerPausesAfterTargetIndex.

// MARK: - MockItem helper

private struct TestItem: AudioItem {
    var sourceUrl: String
    var sourceType: SourceType = .stream
    var headers: [String: String]? = nil
    var isLive: Bool = false
    var title: String?
    var artist: String? = nil
    var albumTitle: String? = nil

    init(_ id: String) {
        self.sourceUrl = "https://example.com/\(id).mp3"
        self.title = id
    }

    #if canImport(UIKit)
    func getArtwork(_ handler: @escaping (UIImage?) -> Void) {
        handler(nil)
    }
    #endif
}

private func trackItem(_ id: String) -> TestItem { TestItem(id) }

// MARK: - SleepTimerIntegrationTests

final class SleepTimerIntegrationTests: XCTestCase {

    private var engine: MockPlayerEngine!
    private var player: AudioPlayer!
    private var controller: SleepTimerController!
    private var triggeredTypes: [String]!

    override func setUp() {
        super.setUp()
        engine = MockPlayerEngine()
        player = AudioPlayer(engine: engine)
        controller = SleepTimerController(player: player)
        triggeredTypes = []
        controller.onTriggered = { [weak self] type in
            self?.triggeredTypes.append(type)
        }
    }

    // MARK: - Time-Based: Basic Countdown

    func testTimerPausesAfterCountdownReachesZero() {
        controller.sleepAfterTime(seconds: 3, fadeOutSeconds: 0)

        controller.tick() // 2 remaining
        XCTAssertEqual(engine.pauseCallCount, 0, "should not pause before countdown ends")

        controller.tick() // 1 remaining
        XCTAssertEqual(engine.pauseCallCount, 0)

        controller.tick() // 0 remaining — fires
        XCTAssertEqual(engine.pauseCallCount, 1, "pause must be called when timer reaches 0")
        XCTAssertEqual(triggeredTypes, ["time"])
    }

    func testTimerStateIsNilAfterFiring() {
        controller.sleepAfterTime(seconds: 2, fadeOutSeconds: 0)
        controller.tick()
        controller.tick()

        XCTAssertNil(controller.getState(), "getState must return nil after the timer fires")
    }

    // MARK: - Time-Based: Zero-Second Immediate Pause

    func testTimerWithZeroSecondsPausesImmediately() {
        controller.sleepAfterTime(seconds: 0, fadeOutSeconds: 0)

        XCTAssertEqual(engine.pauseCallCount, 1, "0-second timer must pause immediately without any ticks")
        XCTAssertEqual(triggeredTypes, ["time"])
        XCTAssertNil(controller.getState())
    }

    // MARK: - Time-Based: Fade-Out

    func testFadeOutReducesVolumeEachTick() {
        player.volume = 1.0
        // 5 seconds total, 5-second fade — entire duration fades
        controller.sleepAfterTime(seconds: 5, fadeOutSeconds: 5)

        var volumes: [Float] = [player.volume] // 1.0 before any tick

        for _ in 0..<4 {
            controller.tick()
            volumes.append(player.volume)
        }

        // Verify each tick reduces volume
        for i in 1..<volumes.count {
            XCTAssertLessThan(volumes[i], volumes[i - 1],
                "volume at tick \(i) (\(volumes[i])) should be lower than tick \(i-1) (\(volumes[i-1]))")
        }
    }

    func testFadeOutVolumeReachesZeroOnFinalTick() {
        player.volume = 1.0
        controller.sleepAfterTime(seconds: 3, fadeOutSeconds: 3)

        controller.tick() // 2 remaining — volume = 2/3
        controller.tick() // 1 remaining — volume = 1/3
        controller.tick() // 0 remaining — volume = 0, then pause, then restore

        // After the timer fires, cancelInternal(restoreVolume: true) restores volume
        XCTAssertEqual(engine.volume, 1.0, accuracy: 0.0001,
            "volume must be restored to pre-fade value after timer fires")
        XCTAssertEqual(engine.pauseCallCount, 1)
    }

    func testVolumeRestoredAfterFadeCompletes() {
        // This guards bug #2: volume not restored after fade completes.
        let initialVolume: Float = 0.8
        player.volume = initialVolume
        controller.sleepAfterTime(seconds: 4, fadeOutSeconds: 4)

        // Tick through all 4 seconds
        for _ in 0..<4 { controller.tick() }

        XCTAssertEqual(engine.volume, initialVolume, accuracy: 0.0001,
            "pre-fade volume must be restored after timer fires so next playback is not muted")
    }

    func testNonFullVolumeFadeUsesPreFadeVolumeAsCeiling() {
        player.volume = 0.6
        controller.sleepAfterTime(seconds: 4, fadeOutSeconds: 4)

        controller.tick() // 3 remaining, progress = 3/4 → expected volume = 0.6 * 0.75 = 0.45
        XCTAssertEqual(engine.volume, 0.6 * 0.75, accuracy: 0.0001,
            "fade must scale from pre-fade volume, not from 1.0")
    }

    // MARK: - Time-Based: Cancel Mid-Fade

    func testCancelMidFadeRestoresVolume() {
        let initialVolume: Float = 1.0
        player.volume = initialVolume
        controller.sleepAfterTime(seconds: 10, fadeOutSeconds: 10)

        controller.tick() // volume decreasing
        controller.tick() // volume decreasing
        let volumeDuringFade = player.volume
        XCTAssertLessThan(volumeDuringFade, initialVolume,
            "volume should have decreased before cancel")

        controller.cancel()

        XCTAssertEqual(engine.volume, initialVolume, accuracy: 0.0001,
            "cancelling mid-fade must restore pre-fade volume")
        XCTAssertNil(controller.getState())
        XCTAssertEqual(engine.pauseCallCount, 0, "cancel must not pause")
    }

    func testCancelWithNoFadeDoesNotChangePauseCount() {
        controller.sleepAfterTime(seconds: 5, fadeOutSeconds: 0)
        controller.tick()

        controller.cancel()

        XCTAssertEqual(engine.pauseCallCount, 0)
        XCTAssertNil(controller.getState())
    }

    // MARK: - Time-Based: FadeOutSeconds > Seconds Clamping

    func testFadeOutSecondsClampedToTimerDuration() {
        // fadeOutSeconds > seconds: production clamps to seconds
        controller.sleepAfterTime(seconds: 3, fadeOutSeconds: 10)

        XCTAssertEqual(controller.sleepTimerFadeOutSeconds, 3,
            "fadeOutSeconds must be clamped to timer duration when it exceeds it")
    }

    func testClampedFadeOutStillProducesMonotonicDecrease() {
        player.volume = 1.0
        // Requesting 10s fade on 3s timer → effectively 3s fade
        controller.sleepAfterTime(seconds: 3, fadeOutSeconds: 10)

        controller.tick() // 2 remaining
        let v1 = player.volume
        controller.tick() // 1 remaining
        let v2 = player.volume
        controller.tick() // 0 remaining — fires

        XCTAssertLessThan(v1, 1.0, "fade should have started on first tick")
        XCTAssertLessThan(v2, v1, "volume should keep decreasing")
        XCTAssertEqual(engine.pauseCallCount, 1, "timer must still fire after clamped fade")
    }

    // MARK: - Time-Based: getState State

    func testGetStateReturnsTimeState() {
        controller.sleepAfterTime(seconds: 30, fadeOutSeconds: 10)

        let state = controller.getState()
        XCTAssertNotNil(state)
        XCTAssertEqual(state?["type"] as? String, "time")
        XCTAssertEqual(state?["remainingSeconds"] as? Double, 30)
        XCTAssertEqual(state?["fadeOutSeconds"] as? Double, 10)
    }

    func testGetStateDecreasesAfterTick() {
        controller.sleepAfterTime(seconds: 5, fadeOutSeconds: 0)
        controller.tick()

        let state = controller.getState()
        XCTAssertEqual(state?["remainingSeconds"] as? Double, 4)
    }

    func testGetStateReturnsNilAfterCancel() {
        controller.sleepAfterTime(seconds: 5, fadeOutSeconds: 0)
        controller.cancel()

        XCTAssertNil(controller.getState())
    }

    // MARK: - MediaItem-Based: Forward Transition

    func testMediaItemTimerPausesAfterTargetIndex() {
        // This guards bug #3: pause being overridden during media-item transitions.
        // Target is index 1; previous is 1; when we transition to index 2 it should fire.
        player.add(items: [trackItem("a"), trackItem("b"), trackItem("c")])
        controller.sleepAfterMediaItemAtIndex(index: 1)

        // Simulate the player being at index 1 (set previousIndex via handleItemTransition)
        // The controller captures currentIndex on init — engine starts at index 0 after add.
        // We need to bring previousIndex to 1 first.
        _ = controller.handleItemTransition(to: 1) // 0→1: arrives at target, no fire
        let fired = controller.handleItemTransition(to: 2) // 1→2: departs target, fires

        XCTAssertTrue(fired, "sleep timer should fire when transitioning past target index")
        XCTAssertEqual(triggeredTypes, ["mediaItem"])
        XCTAssertNil(controller.getState(), "timer must be cleared after firing")
    }

    func testMediaItemTimerDoesNotPauseOnSameIndex() {
        player.add(items: [trackItem("a"), trackItem("b")])
        controller.sleepAfterMediaItemAtIndex(index: 1)

        // Transition from current (0) → 1 (arriving at target, not leaving it)
        let fired = controller.handleItemTransition(to: 1)

        XCTAssertFalse(fired, "arriving at target index must not fire the timer")
        XCTAssertEqual(triggeredTypes, [])
    }

    func testMediaItemTimerDoesNotPauseOnBackwardSkip() {
        // The production code fires whenever previousIndex == targetIndex && newIndex != targetIndex.
        // A skip-backward from target DOES fire — so this test verifies the current documented
        // behaviour: the timer fires on any forward OR backward departure from the target index.
        player.add(items: [trackItem("a"), trackItem("b"), trackItem("c")])
        controller.sleepAfterMediaItemAtIndex(index: 2)

        // Bring previousIndex to 2
        _ = controller.handleItemTransition(to: 1) // 0→1
        _ = controller.handleItemTransition(to: 2) // 1→2: arrives at target

        // Transition to a LOWER index (skip backward from 2 to 1)
        let fired = controller.handleItemTransition(to: 1)
        XCTAssertTrue(fired, "departing the target index (even backward) fires the timer per production logic")
        XCTAssertEqual(triggeredTypes, ["mediaItem"])
    }

    func testMediaItemTimerDoesNotPauseBeforeReachingTarget() {
        player.add(items: [trackItem("a"), trackItem("b"), trackItem("c"), trackItem("d")])
        // Target is 2; controller captures currentIndex = 0 on sleepAfterMediaItemAtIndex
        controller.sleepAfterMediaItemAtIndex(index: 2)

        // Transition 0 → 1 (pre-target)
        let firedAt1 = controller.handleItemTransition(to: 1)
        XCTAssertFalse(firedAt1, "must not fire when previous (0) ≠ target (2)")
        XCTAssertEqual(triggeredTypes, [])

        // Transition 1 → 2 (arriving at target)
        let firedAt2 = controller.handleItemTransition(to: 2)
        XCTAssertFalse(firedAt2, "must not fire when arriving at target")
        XCTAssertEqual(triggeredTypes, [])

        // Transition 2 → 3 (leaving target — fires)
        let firedAt3 = controller.handleItemTransition(to: 3)
        XCTAssertTrue(firedAt3, "must fire when leaving target index")
        XCTAssertEqual(triggeredTypes, ["mediaItem"])
    }

    // MARK: - MediaItem-Based: Cancel

    func testCancelMediaItemTimerPreventsSubsequentPause() {
        player.add(items: [trackItem("a"), trackItem("b")])
        controller.sleepAfterMediaItemAtIndex(index: 0)
        controller.cancel()

        let fired = controller.handleItemTransition(to: 1)

        XCTAssertFalse(fired, "cancelled timer must not fire on transition")
        XCTAssertEqual(triggeredTypes, [])
    }

    // MARK: - MediaItem-Based: getState State

    func testGetStateReturnsMediaItemState() {
        controller.sleepAfterMediaItemAtIndex(index: 3)

        let state = controller.getState()
        XCTAssertNotNil(state)
        XCTAssertEqual(state?["type"] as? String, "mediaItem")
        XCTAssertEqual(state?["index"] as? Int, 3)
    }

    func testGetStateReturnsNilAfterMediaItemFires() {
        player.add(items: [trackItem("a"), trackItem("b")])
        controller.sleepAfterMediaItemAtIndex(index: 0)
        // previousIndex = 0 (currentIndex at setup), transition to 1 fires
        _ = controller.handleItemTransition(to: 1)

        XCTAssertNil(controller.getState())
    }

    // MARK: - Interaction: Last-One-Wins

    func testSettingTimeTimerCancelsMediaItemTimer() {
        controller.sleepAfterMediaItemAtIndex(index: 1)
        XCTAssertEqual(controller.getState()?["type"] as? String, "mediaItem")

        controller.sleepAfterTime(seconds: 5, fadeOutSeconds: 0)

        XCTAssertEqual(controller.getState()?["type"] as? String, "time",
            "setting a time timer must replace the active mediaItem timer")
    }

    func testSettingMediaItemTimerCancelsTimeTimer() {
        controller.sleepAfterTime(seconds: 10, fadeOutSeconds: 0)
        XCTAssertEqual(controller.getState()?["type"] as? String, "time")

        controller.sleepAfterMediaItemAtIndex(index: 2)

        XCTAssertEqual(controller.getState()?["type"] as? String, "mediaItem",
            "setting a mediaItem timer must replace the active time timer")
    }

    func testReplacingTimeTimerWithTimeTimerRestoresVolume() {
        // First timer with fade is replaced mid-fade; volume must be restored.
        player.volume = 1.0
        controller.sleepAfterTime(seconds: 5, fadeOutSeconds: 5)
        controller.tick() // volume drops
        controller.tick() // volume drops more
        let volumeDuringFade = player.volume
        XCTAssertLessThan(volumeDuringFade, 1.0)

        // Replace with a new timer
        controller.sleepAfterTime(seconds: 10, fadeOutSeconds: 0)

        XCTAssertEqual(engine.volume, 1.0, accuracy: 0.0001,
            "replacing a fading time timer must restore volume before starting the new one")
    }

    func testReplacingMediaItemTimerWithTimeTimerRestoresVolume() {
        player.volume = 1.0
        controller.sleepAfterMediaItemAtIndex(index: 1)

        // Replace with a time timer
        controller.sleepAfterTime(seconds: 5, fadeOutSeconds: 0)

        // No volume was changed by mediaItem timer, but cancellation should be clean
        XCTAssertEqual(engine.volume, 1.0, accuracy: 0.0001)
        XCTAssertEqual(controller.getState()?["type"] as? String, "time")
    }

    // MARK: - State Invariants

    func testTimerTypeIsNilInitially() {
        XCTAssertNil(controller.getState())
    }

    func testMultipleTicksBeyondZeroDoNotCallPauseMoreThanOnce() {
        controller.sleepAfterTime(seconds: 1, fadeOutSeconds: 0)
        controller.tick() // fires at 0

        // Timer is cancelled after firing; further ticks are no-ops
        controller.tick()
        controller.tick()

        XCTAssertEqual(engine.pauseCallCount, 1, "pause must be called exactly once")
    }
}
