import APIClient
import ClientTLS
import Foundation
import HostRegistry
import Observation
import WireProtocol

/// T-iOS-12 · Pairing state machine (plan §7 / §5.4).
///
/// Flow: scan / manual URL → **confirm gate** → two-step probe →
/// `Host{id,name}` into the `HostStore` + navigate signal.
///
/// Security invariants (plan §5 / task RED list):
/// - Scan payloads are UNTRUSTED external input: parsed exclusively through
///   `HostEndpoint` (single-point derivation — no hand-assembly), non-http(s)
///   rejected with copy, and **zero network happens before the user confirms**
///   (probe ① already GETs the target; probe ② spawns a PTY on it).
/// - The §5.4 warning tiers render ON the confirm page; the public-host tier
///   is BLOCKING — `confirmConnect` refuses to probe until the user has set
///   `hasAcknowledgedPublicRisk` explicitly.
/// - Every `PairingError` maps to inline copy + a recovery action
///   (`localNetworkDenied` → Settings deep-link; `originRejected` surfaces the
///   probe's hint VERBATIM; `atsBlocked` uses the §3.4 wording).
///
/// Documented decisions:
/// - **Manual entry reuses the same confirm state as scanning** (task left it
///   free): one code path, and the §5.4 warning tiers apply uniformly to
///   typed URLs too. Convenience: input without `://` gets an `http://`
///   prefix before the `HostEndpoint` parse (scan payloads get NO such help).
/// - **Host classification is (re)implemented here**: APIClient's
///   `PairingError.isPrivateOrLocalHost` is internal AND too coarse for the
///   tiers (it collapses loopback/Tailscale/RFC1918 into one bucket).
///   Duplication noted for the T-iOS-38 dedup pass.
/// - `.local` (mDNS) hosts over http are shown the plaintext-LAN notice: they
///   resolve to LAN addresses, so the §5.4 "ws:// on an untrusted LAN" row
///   applies to them the same way.
///
/// §3.4 contract ruling (2026-07-04): the injected probe returns the validated
/// `HostEndpoint`; `Host{id: UUID(), name:}` is constructed HERE (id/name are
/// not the probe's to know). Production wiring (T-iOS-15) passes
/// `runPairingProbe` with the real transports.
@MainActor
@Observable
final class PairingViewModel {
    /// The probe, injected as a closure so tests can both fake results AND
    /// assert non-invocation before the user confirms (task RED list).
    typealias Probe = @Sendable (HostEndpoint) async -> Result<HostEndpoint, PairingError>

    // MARK: - UI state model

    /// §5.4 warning tiers, decided from scheme + host class (see `warning(for:)`).
    enum SecurityWarning: Equatable, Sendable {
        /// https anywhere private-class, or ws→loopback: nothing to warn about.
        case none
        /// ws→100.64/10 or `*.ts.net`: WireGuard already encrypts — no
        /// plaintext warning, an optional positive badge instead.
        case tailscaleEncrypted
        /// ws→RFC1918 / link-local / `.local`: NON-blocking notice — keystrokes
        /// and output are sniffable on the same LAN; prefer `tailscale serve`.
        case plaintextLAN
        /// Public host (http AND https alike, §5.4 table): strongest BLOCKING
        /// warning — anyone who can reach the port gets a shell.
        case publicHostBlocking

        var isBlocking: Bool { self == .publicHostBlocking }
    }

    /// The parsed-but-not-yet-probed target shown on the confirm page.
    struct PendingHost: Equatable, Sendable {
        let endpoint: HostEndpoint
        let warning: SecurityWarning

        /// `scheme://host[:port]` via `HostEndpoint`'s single-point derivation
        /// (browser-Origin serialization) — NEVER hand-assembled.
        var displayAddress: String { endpoint.originHeader }
    }

    /// What the failure UI offers besides the message.
    enum RecoveryAction: Equatable, Sendable {
        case retry
        /// iOS Local Network permission was denied → deep-link to the app's
        /// Settings pane (its 本地网络 toggle lives there).
        case openLocalNetworkSettings
    }

    struct FailureDisplay: Equatable, Sendable {
        let message: String
        let action: RecoveryAction
    }

    enum Phase: Equatable {
        case idle
        case confirming(PendingHost)
        case probing(PendingHost)
        case failed(PendingHost, FailureDisplay)
        case paired(HostRegistry.Host)
    }

    // MARK: - Observable state

    private(set) var phase: Phase = .idle
    /// Inline rejection copy for invalid scan/manual input (idle-state error).
    private(set) var inputRejection: String?
    /// Editable name shown on the confirm page; defaults to the endpoint host
    /// and falls back to it when the user clears the field.
    var hostName = ""
    /// Explicit user acknowledgement for the blocking public-host warning.
    var hasAcknowledgedPublicRisk = false
    /// Set when confirm was attempted on a blocking warning WITHOUT the
    /// acknowledgement — the UI highlights the ack control.
    private(set) var needsPublicRiskAcknowledgement = false
    /// Navigate signal: set exactly once when pairing completes (T-iOS-15
    /// observes it to move on to the session list).
    private(set) var pairedHost: HostRegistry.Host?

    // MARK: - Dependencies (not observed)

    @ObservationIgnored private let store: any HostStore
    @ObservationIgnored private let probe: Probe
    /// C-iOS-3 · Whether a device client certificate is installed. Used to gate
    /// the probe for tunnel hosts (mTLS-only). Injected so tests control it;
    /// production reads the keychain. Defaulted so existing call sites compile.
    @ObservationIgnored private let isDeviceCertInstalled: @Sendable () -> Bool

    init(
        store: any HostStore,
        probe: @escaping Probe,
        isDeviceCertInstalled: @escaping @Sendable () -> Bool = {
            KeychainClientIdentityStore().hasInstalledIdentity()
        }
    ) {
        self.store = store
        self.probe = probe
        self.isDeviceCertInstalled = isDeviceCertInstalled
    }

    // MARK: - Input boundaries (untrusted, validated via HostEndpoint)

    /// QR scan result (`public/qr.ts` encodes `location.origin`). Strict: the
    /// payload must already be a full http(s) URL — no scheme inference for
    /// untrusted external input.
    func handleScannedCode(_ payload: String) {
        guard canAcceptNewTarget else { return }
        let trimmed = payload.trimmingCharacters(in: .whitespacesAndNewlines)
        guard let url = URL(string: trimmed), let endpoint = HostEndpoint(baseURL: url) else {
            inputRejection = PairingCopy.scanRejected
            return
        }
        enterConfirming(endpoint)
    }

    /// Manually typed URL. The user knows what they typed, but it still goes
    /// through the SAME confirm state (uniform warning tiers — documented
    /// decision). Convenience: no `://` → `http://` prefix before parsing.
    func submitManualURL(_ text: String) {
        guard canAcceptNewTarget else { return }
        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else {
            inputRejection = PairingCopy.manualRejected
            return
        }
        let candidate = trimmed.contains(Self.schemeSeparator)
            ? trimmed
            : Self.defaultManualScheme + trimmed
        guard let url = URL(string: candidate), let endpoint = HostEndpoint(baseURL: url) else {
            inputRejection = PairingCopy.manualRejected
            return
        }
        enterConfirming(endpoint)
    }

    /// Back out of confirm/failed to a clean entry state. Never probes.
    func cancel() {
        phase = .idle
        inputRejection = nil
        hasAcknowledgedPublicRisk = false
        needsPublicRiskAcknowledgement = false
    }

    // MARK: - Confirm → probe → store

    /// The ONLY way network starts. No-op unless confirming; a blocking
    /// warning without explicit acknowledgement refuses and flags the UI.
    func confirmConnect() async {
        guard case .confirming(let pending) = phase else { return }
        if pending.warning.isBlocking && !hasAcknowledgedPublicRisk {
            needsPublicRiskAcknowledgement = true
            return
        }
        await runProbe(for: pending)
    }

    /// Re-run the full probe against the same endpoint after a failure.
    func retry() async {
        guard case .failed(let pending, _) = phase else { return }
        await runProbe(for: pending)
    }

    private var canAcceptNewTarget: Bool {
        switch phase {
        case .idle, .confirming, .failed:
            return true
        case .probing, .paired:
            return false
        }
    }

    private func enterConfirming(_ endpoint: HostEndpoint) {
        inputRejection = nil
        hasAcknowledgedPublicRisk = false
        needsPublicRiskAcknowledgement = false
        hostName = endpoint.baseURL.host ?? ""
        phase = .confirming(PendingHost(
            endpoint: endpoint, warning: Self.warning(for: endpoint)
        ))
    }

    private func runProbe(for pending: PendingHost) async {
        needsPublicRiskAcknowledgement = false
        // C-iOS-3 · Tunnel hosts are mTLS-only: refuse to probe (which would
        // fail at the TLS handshake) until a device certificate is installed.
        // This is the single choke point both confirmConnect and retry funnel
        // through, so the gate can't be bypassed via retry().
        if Self.isTunnelHost(pending.endpoint), !isDeviceCertInstalled() {
            phase = .failed(pending, FailureDisplay(
                message: PairingCopy.deviceCertRequired, action: .retry
            ))
            return
        }
        phase = .probing(pending)
        switch await probe(pending.endpoint) {
        case .failure(let error):
            phase = .failed(pending, Self.display(for: error, endpoint: pending.endpoint))
        case .success(let endpoint):
            await storePairedHost(endpoint: endpoint, pending: pending)
        }
    }

    /// §3.4 ruling: `Host{id,name}` is constructed here, from the PROBED
    /// endpoint. A store failure is surfaced explicitly (never swallowed);
    /// retry re-runs the whole confirm flow.
    private func storePairedHost(endpoint: HostEndpoint, pending: PendingHost) async {
        let trimmedName = hostName.trimmingCharacters(in: .whitespacesAndNewlines)
        let fallbackName = endpoint.baseURL.host ?? endpoint.originHeader
        let host = HostRegistry.Host(
            id: UUID(),
            name: trimmedName.isEmpty ? fallbackName : trimmedName,
            endpoint: endpoint
        )
        do {
            _ = try await store.upsert(host)
        } catch {
            phase = .failed(pending, FailureDisplay(
                message: PairingCopy.storeFailed, action: .retry
            ))
            return
        }
        pairedHost = host
        phase = .paired(host)
    }

    // MARK: - PairingError → copy + action (task RED list, one case each)

    /// C-iOS-3 · Host-aware display. nginx rejects an invalid/absent/revoked
    /// client cert at the TLS layer; URLSession surfaces that as
    /// secureConnectionFailed / connection-reset → `PairingError.classify` maps
    /// it to `.tlsFailure` ("server cert invalid"), which is the WRONG diagnosis
    /// for an mTLS tunnel host. Re-map that one case to the device-cert copy;
    /// everything else falls through to the per-case mapping.
    static func display(for error: PairingError, endpoint: HostEndpoint) -> FailureDisplay {
        if case .tlsFailure = error, isTunnelHost(endpoint) {
            return FailureDisplay(message: PairingCopy.clientCertRejected, action: .retry)
        }
        return display(for: error)
    }

    static func display(for error: PairingError) -> FailureDisplay {
        switch error {
        case .localNetworkDenied:
            return FailureDisplay(
                message: PairingCopy.localNetworkDenied, action: .openLocalNetworkSettings
            )
        case .hostUnreachable(let underlying):
            return FailureDisplay(
                message: PairingCopy.hostUnreachable(underlying), action: .retry
            )
        case .httpOkButNotWebTerminal:
            return FailureDisplay(message: PairingCopy.notWebTerminal, action: .retry)
        case .originRejected(let hint):
            // The probe already derived the complete actionable copy from
            // endpoint.originHeader — surface it VERBATIM, never re-derive.
            return FailureDisplay(message: hint, action: .retry)
        case .atsBlocked(let host):
            return FailureDisplay(message: PairingCopy.atsBlocked(host: host), action: .retry)
        case .tlsFailure:
            return FailureDisplay(message: PairingCopy.tlsFailure, action: .retry)
        case .timeout:
            return FailureDisplay(message: PairingCopy.timeout, action: .retry)
        }
    }

    // MARK: - §5.4 warning tiers

    /// Decide the confirm-page warning from scheme + host class. Public hosts
    /// block regardless of scheme (§5.4 table: https is included in the
    /// public-host confirm warning); otherwise https clears every notice.
    static func warning(for endpoint: HostEndpoint) -> SecurityWarning {
        // C-iOS-3 · A *.terminal.yaojia.wang tunnel host is gated by the device
        // client certificate (mTLS), so the "anyone who can reach the port gets
        // a shell" blocking warning is FALSE here and would only deter the
        // intended flow. The real gate is the cert-install check in runProbe.
        // Genuinely public NON-tunnel hosts still hit .publicHostBlocking below.
        if isTunnelHost(endpoint) {
            return .none
        }
        let hostClass = classifyHost(endpoint.baseURL.host ?? "")
        if hostClass == .publicHost {
            return .publicHostBlocking
        }
        if endpoint.baseURL.scheme?.lowercased() == Self.httpsScheme {
            return .none
        }
        switch hostClass {
        case .loopback:
            return .none
        case .tailscale:
            return .tailscaleEncrypted
        case .privateLAN:
            return .plaintextLAN
        case .publicHost:
            return .publicHostBlocking // unreachable; keeps the switch total
        }
    }

    /// Address classes relevant to §5.4. NOTE: near-duplicate of APIClient's
    /// internal `isPrivateOrLocalHost` (finer-grained here) — T-iOS-38 dedup.
    enum HostClass: Equatable, Sendable {
        case loopback
        case tailscale
        case privateLAN
        case publicHost
    }

    static func classifyHost(_ rawHost: String) -> HostClass {
        let host = rawHost.lowercased()
            .trimmingCharacters(in: CharacterSet(charactersIn: "[]")) // IPv6 brackets
        if host == Self.localhostName {
            return .loopback
        }
        if host.hasSuffix(Self.tailscaleMagicDNSSuffix) {
            return .tailscale
        }
        if host.hasSuffix(Self.mdnsSuffix) {
            return .privateLAN
        }
        if let octets = ipv4Octets(host) {
            return classifyIPv4(octets)
        }
        if host.contains(":") {
            return classifyIPv6(host)
        }
        return .publicHost
    }

    private static func classifyIPv4(_ octets: [Int]) -> HostClass {
        switch (octets[0], octets[1]) {
        case (127, _):
            return .loopback
        case (10, _), (192, 168), (169, 254):
            return .privateLAN // RFC1918 10/8, 192.168/16 · link-local 169.254/16
        case (172, 16...31):
            return .privateLAN // RFC1918 172.16/12
        case (100, 64...127):
            return .tailscale // CGNAT 100.64/10
        default:
            return .publicHost
        }
    }

    private static func classifyIPv6(_ host: String) -> HostClass {
        if host == Self.ipv6Loopback {
            return .loopback
        }
        let isLinkLocal = host.hasPrefix(Self.ipv6LinkLocalPrefix)
        let isULA = host.hasPrefix("fc") || host.hasPrefix("fd") // fc00::/7
        return (isLinkLocal || isULA) ? .privateLAN : .publicHost
    }

    private static func ipv4Octets(_ host: String) -> [Int]? {
        let parts = host.split(separator: ".", omittingEmptySubsequences: false)
        guard parts.count == Self.ipv4OctetCount else { return nil }
        let octets = parts.compactMap { Int($0) }
        guard octets.count == Self.ipv4OctetCount,
              octets.allSatisfy({ Self.ipv4OctetRange.contains($0) })
        else { return nil }
        return octets
    }

    /// C-iOS-3 · A native mTLS reverse-tunnel host (`<name>.terminal.yaojia.wang`).
    /// These reach a loopback base app through the VPS and are protected ONLY by
    /// the device client certificate — hence the softened warning + the
    /// cert-install gate + the client-cert-rejected re-classification.
    static func isTunnelHost(_ endpoint: HostEndpoint) -> Bool {
        (endpoint.baseURL.host ?? "").lowercased().hasSuffix(tunnelZoneSuffix)
    }

    // MARK: - Named constants (no magic values, plan §4)

    private static let tunnelZoneSuffix = ".terminal.yaojia.wang"
    private static let schemeSeparator = "://"
    private static let defaultManualScheme = "http://"
    private static let httpsScheme = "https"
    private static let localhostName = "localhost"
    private static let tailscaleMagicDNSSuffix = ".ts.net"
    private static let mdnsSuffix = ".local"
    private static let ipv6Loopback = "::1"
    private static let ipv6LinkLocalPrefix = "fe80"
    private static let ipv4OctetCount = 4
    private static let ipv4OctetRange = 0...255
}

/// User-facing pairing copy (plan §3.4 taxonomy → actionable wording; §5.2
/// Local-Network guidance including the iOS 18 restart caveat).
enum PairingCopy {
    static let scanRejected =
        "二维码不是 http(s) 地址，无法配对。请扫描 web 终端工具栏「Connect a device」弹出的二维码。"
    static let manualRejected =
        "无法解析这个地址。请输入完整 URL，例如 http://192.168.1.5:3000"
    static let storeFailed =
        "主机已通过验证，但保存到本机失败，请重试。"
    static let localNetworkDenied =
        "无法访问本地网络——「本地网络」权限可能被拒绝。请到 设置 → 隐私与安全性 → 本地网络 打开 WebTerm 的开关"
        + "（iOS 18 存在需要重启手机才生效的已知问题）。"
    static let notWebTerminal =
        "对方在响应 HTTP，但不是 web-terminal——端口对吗？"
    static let tlsFailure =
        "TLS 连接失败：证书无效或不受信任。"
    static let timeout =
        "连接超时。请确认主机在线、与手机在同一网络后重试。"
    /// C-iOS-3 · Tunnel host reached without a device certificate installed.
    static let deviceCertRequired =
        "请先安装本设备证书：到 设置 →「设备证书」导入 .p12 后，再连接该隧道主机。"
    /// C-iOS-3 · nginx rejected the presented client certificate (invalid /
    /// revoked). Surfaced in place of the mis-classified "server cert invalid".
    static let clientCertRejected =
        "本设备证书无效或已吊销，请重新导入。"

    static func hostUnreachable(_ underlying: String) -> String {
        "无法连接主机：\(underlying)"
    }

    /// §3.4 wording for the ATS cleartext block.
    static func atsBlocked(host: String) -> String {
        "明文 HTTP 被 ATS 拦截——\(host) 所在 IP 段不在 App 例外列表内，"
        + "请改用 https / tailscale serve，或反馈该网段。"
    }
}
