import APIClient
import Foundation
import HostRegistry
import TestSupport
import Testing
import WireProtocol
@testable import WebTerm

/// T-iOS-12 · PairingViewModel (plan §7 / §5.4). Probe LOGIC is T-iOS-8's
/// domain — these tests cover the state mapping around it:
/// scan/manual input → confirm gate (ZERO network before the user says go) →
/// probe → Host into the store + navigate signal, error taxonomy → copy +
/// action, and the §5.4 warning tiers.
///
/// Determinism: the probe is injected as a closure; scripted results come from
/// an actor-backed `ProbeScript` (also the non-invocation counter). The
/// end-to-end test runs the REAL `runPairingProbe` over `FakeHTTPTransport` +
/// `FakeTransport` — zero real network, zero real waits.
@MainActor
@Suite("PairingViewModel")
struct PairingViewModelTests {
    // MARK: - Probe script (scripted results + invocation recording)

    private actor ProbeScript {
        private(set) var calls: [HostEndpoint] = []
        private var results: [Result<HostEndpoint, PairingError>]

        /// Empty `results` = always succeed with the probed endpoint.
        init(results: [Result<HostEndpoint, PairingError>] = []) {
            self.results = results
        }

        func invoke(_ endpoint: HostEndpoint) -> Result<HostEndpoint, PairingError> {
            calls = calls + [endpoint]
            guard let next = results.first else { return .success(endpoint) }
            results = Array(results.dropFirst())
            return next
        }
    }

    private func makeViewModel(
        store: any HostStore = InMemoryHostStore(),
        script: ProbeScript
    ) -> PairingViewModel {
        PairingViewModel(store: store, probe: { await script.invoke($0) })
    }

    private struct StoreFailure: Error {}

    private actor ThrowingHostStore: HostStore {
        func loadAll() async throws -> [HostRegistry.Host] { [] }
        func upsert(_ host: HostRegistry.Host) async throws -> [HostRegistry.Host] {
            throw StoreFailure()
        }
        func remove(id: UUID) async throws -> [HostRegistry.Host] { [] }
    }

    // MARK: - Scan → confirm gate → REAL two-step probe → store + navigate

    @Test("scan shows the HostEndpoint-parsed address, no network until confirm; then probe → Host into store + navigate signal")
    func scanConfirmGateThenRealProbePairsHost() async throws {
        // Arrange: REAL runPairingProbe over fakes — the strongest proof that
        // (a) nothing touches the wire before the user confirms and (b) the
        // production closure wiring is exercised end to end.
        let http = FakeHTTPTransport()
        let ws = FakeTransport()
        let store = InMemoryHostStore()
        let viewModel = PairingViewModel(
            store: store,
            probe: { await runPairingProbe(endpoint: $0, http: http, ws: ws) }
        )
        let base = "http://192.168.1.5:3000"
        let probeSessionId = "1b671a64-40d5-491e-99b0-da01ff1f3341"
        // Script the full happy path UP FRONT — if the VM probed before
        // confirm, recordedRequests would already be non-empty below.
        await http.queueSuccess(
            url: try #require(URL(string: "\(base)/live-sessions")),
            body: Data("[]".utf8)
        )
        await ws.emit(frame: #"{"type":"attached","sessionId":"\#(probeSessionId)"}"#)
        await http.queueSuccess(
            method: "DELETE",
            url: try #require(URL(string: "\(base)/live-sessions/\(probeSessionId)")),
            status: 204
        )

        // Act: scan the web UI QR payload (public/qr.ts encodes location.origin).
        viewModel.handleScannedCode(base)

        // Assert: confirm state shows the single-point-derived address and the
        // scanned host has seen ZERO network traffic (probe ① would GET, probe
        // ② would spawn a PTY on the target — untrusted scan input, plan §5).
        guard case .confirming(let pending) = viewModel.phase else {
            Issue.record("expected .confirming, got \(viewModel.phase)")
            return
        }
        #expect(pending.displayAddress == base)
        #expect(pending.endpoint.originHeader == base)
        #expect(pending.warning == .plaintextLAN)
        #expect(await http.recordedRequests.isEmpty)
        #expect(await ws.connectAttempts.isEmpty)
        #expect(viewModel.pairedHost == nil)

        // Act: name the host, then confirm — ONLY now may the probe run.
        viewModel.hostName = "书房 Mac"
        await viewModel.confirmConnect()

        // Assert: paired + navigate signal; Host{id,name} constructed by the
        // VM (§3.4 contract ruling) and upserted into the store.
        guard case .paired(let host) = viewModel.phase else {
            Issue.record("expected .paired, got \(viewModel.phase)")
            return
        }
        #expect(host.name == "书房 Mac")
        #expect(host.endpoint == pending.endpoint)
        #expect(viewModel.pairedHost == host)
        #expect(try await store.loadAll() == [host])

        // Assert: exactly the two probe HTTP calls, in order, Origin stamped
        // iff guarded (§3.4 铁律) — and one WS attach round-trip, closed.
        let requests = await http.recordedRequests
        #expect(requests.map(\.httpMethod) == ["GET", "DELETE"])
        #expect(requests[0].value(forHTTPHeaderField: "Origin") == nil)
        #expect(requests[1].value(forHTTPHeaderField: "Origin") == base)
        #expect(await ws.connectAttempts.count == 1)
        #expect(await ws.closeCallCount == 1)

        // Assert: a stray scan cannot preempt a finished pairing.
        viewModel.handleScannedCode("http://10.0.0.9:3000")
        #expect(viewModel.phase == .paired(host))
    }

    // MARK: - Input boundary: scan payloads are untrusted external input

    @Test("non-http(s) scan payloads are rejected with copy and zero probe calls", arguments: [
        "ftp://192.168.1.5:3000",
        "ws://192.168.1.5:3000",
        "javascript:alert(1)",
        "WIFI:S:mynet;T:WPA;P:hunter2;;",
        "",
    ])
    func scanRejectsNonHTTPPayloads(payload: String) async throws {
        // Arrange
        let script = ProbeScript()
        let viewModel = makeViewModel(script: script)

        // Act
        viewModel.handleScannedCode(payload)

        // Assert: stays idle, inline rejection copy, probe never invoked.
        #expect(viewModel.phase == .idle)
        #expect(viewModel.inputRejection == PairingCopy.scanRejected)
        #expect(await script.calls.isEmpty)
    }

    // MARK: - Manual entry (documented decision: reuses the confirm state)

    @Test("manual entry reuses the confirm state; a bare host:port gets the http:// convenience prefix", arguments: [
        ("http://192.168.1.5:3000", "http://192.168.1.5:3000"),
        ("192.168.1.5:3000", "http://192.168.1.5:3000"),
        ("https://mac.tail1234.ts.net", "https://mac.tail1234.ts.net"),
    ])
    func manualEntryEntersConfirmState(input: String, expectedOrigin: String) async throws {
        // Arrange
        let script = ProbeScript()
        let viewModel = makeViewModel(script: script)

        // Act
        viewModel.submitManualURL(input)

        // Assert: SAME confirm state as the scan path (uniform §5.4 warning
        // surface — documented T-iOS-12 decision), still zero probe calls.
        guard case .confirming(let pending) = viewModel.phase else {
            Issue.record("expected .confirming for \(input), got \(viewModel.phase)")
            return
        }
        #expect(pending.endpoint.originHeader == expectedOrigin)
        #expect(await script.calls.isEmpty)
    }

    @Test("unparseable manual input is rejected with copy", arguments: [
        "", "   ", "://nope", "http://",
    ])
    func manualEntryRejectsUnparseableInput(input: String) async throws {
        // Arrange
        let script = ProbeScript()
        let viewModel = makeViewModel(script: script)

        // Act
        viewModel.submitManualURL(input)

        // Assert
        #expect(viewModel.phase == .idle)
        #expect(viewModel.inputRejection == PairingCopy.manualRejected)
        #expect(await script.calls.isEmpty)
    }

    // MARK: - §5.4 warning tiers (shown on the confirm page)

    @Test("warning tiers follow the §5.4 table", arguments: [
        // public host → strongest BLOCKING warning, http AND https alike
        ("http://203.0.113.7:3000", PairingViewModel.SecurityWarning.publicHostBlocking),
        ("https://example.com", PairingViewModel.SecurityWarning.publicHostBlocking),
        // ws:// to RFC1918 / link-local / .local → non-blocking plaintext notice
        ("http://192.168.1.5:3000", PairingViewModel.SecurityWarning.plaintextLAN),
        ("http://10.1.2.3:3000", PairingViewModel.SecurityWarning.plaintextLAN),
        ("http://172.20.10.2:3000", PairingViewModel.SecurityWarning.plaintextLAN),
        ("http://169.254.10.2:3000", PairingViewModel.SecurityWarning.plaintextLAN),
        ("http://mymac.local:3000", PairingViewModel.SecurityWarning.plaintextLAN),
        // Tailscale (100.64/10 CGNAT or MagicDNS *.ts.net) → no plaintext
        // warning (WireGuard already encrypts); positive badge instead
        ("http://100.101.102.103:3000", PairingViewModel.SecurityWarning.tailscaleEncrypted),
        ("http://mac.tail1234.ts.net:3000", PairingViewModel.SecurityWarning.tailscaleEncrypted),
        // loopback → none; https to a private-class host → none
        ("http://127.0.0.1:3000", PairingViewModel.SecurityWarning.none),
        ("http://localhost:3000", PairingViewModel.SecurityWarning.none),
        ("https://192.168.1.5:3000", PairingViewModel.SecurityWarning.none),
        ("https://mac.tail1234.ts.net", PairingViewModel.SecurityWarning.none),
    ])
    func warningTiersFollowTable(url: String, expected: PairingViewModel.SecurityWarning) throws {
        // Arrange
        let baseURL = try #require(URL(string: url))
        let endpoint = try #require(HostEndpoint(baseURL: baseURL))

        // Act & Assert
        #expect(PairingViewModel.warning(for: endpoint) == expected)
    }

    @Test("public-host blocking warning requires explicit acknowledgement before any probe")
    func blockingWarningGatesTheProbe() async throws {
        // Arrange
        let script = ProbeScript()
        let viewModel = makeViewModel(script: script)
        viewModel.handleScannedCode("http://203.0.113.7:3000")
        guard case .confirming(let pending) = viewModel.phase else {
            Issue.record("expected .confirming, got \(viewModel.phase)")
            return
        }
        #expect(pending.warning == .publicHostBlocking)

        // Act: confirm WITHOUT acknowledging the risk.
        await viewModel.confirmConnect()

        // Assert: no probe, still confirming, the UI is told to demand the ack.
        #expect(await script.calls.isEmpty)
        #expect(viewModel.phase == .confirming(pending))
        #expect(viewModel.needsPublicRiskAcknowledgement)

        // Act: explicit acknowledgement, then confirm again.
        viewModel.hasAcknowledgedPublicRisk = true
        await viewModel.confirmConnect()

        // Assert: probe ran exactly once and pairing completed.
        #expect(await script.calls.count == 1)
        guard case .paired = viewModel.phase else {
            Issue.record("expected .paired, got \(viewModel.phase)")
            return
        }
    }

    // MARK: - PairingError taxonomy → inline copy + recovery action

    @Test("every PairingError maps to actionable copy and the right recovery action", arguments: [
        (PairingError.localNetworkDenied,
         PairingViewModel.RecoveryAction.openLocalNetworkSettings,
         ["本地网络", "设置"]),
        (PairingError.hostUnreachable(underlying: "Connection refused"),
         PairingViewModel.RecoveryAction.retry,
         ["Connection refused"]),
        (PairingError.httpOkButNotWebTerminal,
         PairingViewModel.RecoveryAction.retry,
         ["端口"]),
        (PairingError.originRejected(hint: "在主机加 ALLOWED_ORIGINS=http://192.168.1.5:3000"),
         PairingViewModel.RecoveryAction.retry,
         ["ALLOWED_ORIGINS=http://192.168.1.5:3000"]),
        (PairingError.atsBlocked(host: "198.18.0.1"),
         PairingViewModel.RecoveryAction.retry,
         ["ATS", "198.18.0.1", "tailscale serve", "例外"]),
        (PairingError.tlsFailure,
         PairingViewModel.RecoveryAction.retry,
         ["TLS"]),
        (PairingError.timeout,
         PairingViewModel.RecoveryAction.retry,
         ["超时"]),
    ])
    func pairingErrorMapsToCopyAndAction(
        error: PairingError,
        expectedAction: PairingViewModel.RecoveryAction,
        requiredFragments: [String]
    ) async throws {
        // Arrange: private-class host so no blocking-warning gate interferes.
        let script = ProbeScript(results: [.failure(error)])
        let viewModel = makeViewModel(script: script)
        viewModel.handleScannedCode("http://192.168.1.5:3000")

        // Act
        await viewModel.confirmConnect()

        // Assert
        guard case .failed(_, let failure) = viewModel.phase else {
            Issue.record("expected .failed for \(error), got \(viewModel.phase)")
            return
        }
        #expect(failure.action == expectedAction)
        #expect(!failure.message.isEmpty)
        for fragment in requiredFragments {
            #expect(failure.message.contains(fragment),
                    "copy for \(error) must contain \(fragment)")
        }
    }

    @Test("originRejected surfaces the probe's hint VERBATIM as the whole message")
    func originRejectedHintIsVerbatim() async throws {
        // Arrange: the hint the probe derives from endpoint.originHeader is
        // already the complete actionable copy — never rewrap or re-derive it.
        let hint = "服务器拒绝了这个来源。请在主机上设置 ALLOWED_ORIGINS=http://192.168.1.5:3000"
            + "（与 App 连接的 URL 完全一致）后重启 web-terminal，再重试配对。"
        let script = ProbeScript(results: [.failure(.originRejected(hint: hint))])
        let viewModel = makeViewModel(script: script)
        viewModel.handleScannedCode("http://192.168.1.5:3000")

        // Act
        await viewModel.confirmConnect()

        // Assert
        guard case .failed(_, let failure) = viewModel.phase else {
            Issue.record("expected .failed, got \(viewModel.phase)")
            return
        }
        #expect(failure.message == hint)
    }

    // MARK: - Retry / cancel

    @Test("retry re-runs the probe against the same endpoint and can succeed")
    func retryRerunsProbeAfterFailure() async throws {
        // Arrange: first probe times out, second succeeds.
        let script = ProbeScript(results: [.failure(.timeout)])
        let store = InMemoryHostStore()
        let viewModel = makeViewModel(store: store, script: script)
        viewModel.handleScannedCode("http://192.168.1.5:3000")
        await viewModel.confirmConnect()
        guard case .failed = viewModel.phase else {
            Issue.record("expected .failed, got \(viewModel.phase)")
            return
        }

        // Act
        await viewModel.retry()

        // Assert: two probe calls, same endpoint, pairing completed.
        let calls = await script.calls
        #expect(calls.count == 2)
        #expect(calls.first == calls.last)
        guard case .paired(let host) = viewModel.phase else {
            Issue.record("expected .paired, got \(viewModel.phase)")
            return
        }
        #expect(try await store.loadAll() == [host])
    }

    @Test("cancel returns to idle without ever probing")
    func cancelReturnsToIdleWithoutProbe() async throws {
        // Arrange
        let script = ProbeScript()
        let viewModel = makeViewModel(script: script)
        viewModel.handleScannedCode("http://192.168.1.5:3000")

        // Act
        viewModel.cancel()

        // Assert
        #expect(viewModel.phase == .idle)
        #expect(viewModel.inputRejection == nil)
        #expect(await script.calls.isEmpty)
    }

    // MARK: - Store failure is explicit, never silent

    @Test("a store failure after a successful probe surfaces an explicit retryable error")
    func storeFailureSurfacesExplicitError() async throws {
        // Arrange
        let script = ProbeScript()
        let viewModel = makeViewModel(store: ThrowingHostStore(), script: script)
        viewModel.handleScannedCode("http://192.168.1.5:3000")

        // Act
        await viewModel.confirmConnect()

        // Assert: failed with the dedicated copy; no navigate signal.
        guard case .failed(_, let failure) = viewModel.phase else {
            Issue.record("expected .failed, got \(viewModel.phase)")
            return
        }
        #expect(failure.message == PairingCopy.storeFailed)
        #expect(failure.action == .retry)
        #expect(viewModel.pairedHost == nil)
    }

    // MARK: - Host naming

    @Test("host name defaults to the endpoint host and user names are trimmed")
    func hostNameDefaultsAndTrims() async throws {
        // Arrange & Act: untouched name → default = endpoint host.
        let script = ProbeScript()
        let storeA = InMemoryHostStore()
        let viewModelA = makeViewModel(store: storeA, script: script)
        viewModelA.handleScannedCode("http://192.168.1.5:3000")
        #expect(viewModelA.hostName == "192.168.1.5")
        await viewModelA.confirmConnect()

        // Assert
        guard case .paired(let defaultNamed) = viewModelA.phase else {
            Issue.record("expected .paired, got \(viewModelA.phase)")
            return
        }
        #expect(defaultNamed.name == "192.168.1.5")

        // Arrange & Act: user-typed name is trimmed before storing.
        let storeB = InMemoryHostStore()
        let viewModelB = makeViewModel(store: storeB, script: script)
        viewModelB.handleScannedCode("http://192.168.1.5:3000")
        viewModelB.hostName = "  书房 Mac  "
        await viewModelB.confirmConnect()

        // Assert
        guard case .paired(let userNamed) = viewModelB.phase else {
            Issue.record("expected .paired, got \(viewModelB.phase)")
            return
        }
        #expect(userNamed.name == "书房 Mac")
    }
}
