import Foundation
import HostRegistry
import Observation
import OSLog
import WireProtocol

/// T-iOS-22 · Deep-link routing.
///
/// Two external entry points share ONE validation surface:
/// - `webterminal://open?host=<uuid>&join=<uuid>` (scheme registered in
///   project.yml `CFBundleURLTypes`; web 分享 QR 互通的 `?join=` 解析是
///   T-iOS-35 的增量), and
/// - the WEBTERM_GATE push payload `{sessionId}` (T-iOS-21 reuses
///   `route(from:)` — same UUID rules, no second parser).
///
/// 安全注 (plan T-iOS-22): a deep link is EXTERNAL input. Every field is
/// whitelist-validated (scheme / action / query keys / UUID v4 via the frozen
/// `Validation.isValidSessionId` — never re-regexed); ANY invalid part →
/// `.ignore` + counter (never crash, never partially apply). The host id
/// resolves ONLY through a `HostStore` lookup (`DeepLinkHandler`) — no URL or
/// request is ever built from link contents.
enum DeepLinkRouter {
    /// Parse outcome. `.openSession` carries RAW validated ids — host
    /// EXISTENCE is resolved later against the store (`DeepLinkHandler`).
    enum Route: Equatable, Sendable {
        /// `webterminal://open` with both ids syntactically valid.
        case openSession(hostId: UUID, sessionId: UUID)
        /// Push payload with a valid `sessionId` (no host id in the payload —
        /// T-iOS-21's notification handler owns the resolution strategy).
        case gateSession(sessionId: UUID)
        /// Anything else. Callers count + log; they never partially apply.
        case ignore
    }

    /// Whitelisted URL shape (`webterminal://open`). Scheme and URL host are
    /// case-insensitive per RFC 3986 → compared lowercased.
    private enum LinkShape {
        static let scheme = "webterminal"
        static let action = "open"
        static let emptyPaths: Set<String> = ["", "/"]
    }

    /// Whitelisted query keys (exact match; unknown keys are ignored).
    private enum QueryKey {
        static let host = "host"
        static let join = "join"
    }

    /// Root key of the APNs payload (server contract, T-iOS-20 final shape).
    private enum PushKey {
        static let sessionId = "sessionId"
    }

    // MARK: - URL entry (onOpenURL)

    static func route(url: URL) -> Route {
        guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
              components.scheme?.lowercased() == LinkShape.scheme,
              components.host?.lowercased() == LinkShape.action,
              LinkShape.emptyPaths.contains(components.path),
              let hostId = uniqueValidatedId(in: components, key: QueryKey.host),
              let sessionId = uniqueValidatedId(in: components, key: QueryKey.join)
        else { return .ignore }
        return .openSession(hostId: hostId, sessionId: sessionId)
    }

    // MARK: - Push entry (WEBTERM_GATE payload, reused by T-iOS-21)

    static func route(from payload: [AnyHashable: Any]) -> Route {
        guard let raw = payload[PushKey.sessionId] as? String,
              let sessionId = validatedId(raw)
        else { return .ignore }
        return .gateSession(sessionId: sessionId)
    }

    // MARK: - Field validation

    /// Exactly ONE occurrence of `key`, and its value is a v4 UUID.
    /// Duplicates are ambiguous input → nil (never partially apply).
    private static func uniqueValidatedId(in components: URLComponents, key: String) -> UUID? {
        let matches = (components.queryItems ?? []).filter { $0.name == key }
        guard matches.count == 1, let raw = matches.first?.value else { return nil }
        return validatedId(raw)
    }

    /// Frozen-contract v4 check (`Validation.isValidSessionId`, plan §3.1)
    /// FIRST — `UUID(uuidString:)` alone would admit non-v4 ids the server's
    /// SESSION_ID_RE rejects.
    private static func validatedId(_ raw: String) -> UUID? {
        guard Validation.isValidSessionId(raw) else { return nil }
        return UUID(uuidString: raw)
    }
}

/// User-facing deep-link copy (plan §4: 显式、可操作的话术).
enum DeepLinkCopy {
    static let hintTitle = "无法打开链接"
    static let unknownHostHint = "链接指向的主机尚未配对，请先扫码完成配对。"
    static let hostLoadFailed = "读取已配对主机失败，无法打开链接，请重进 App 后再试。"
    static let hintConfirm = "好"
}

/// Applies parsed routes to the app: host-store lookup (the ONLY host-id
/// resolution point), the cold-launch stash, the unknown-host → pairing hint,
/// and the invalid-link counter. Pure routing stays in `DeepLinkRouter`; this
/// class owns the stateful edges so `AppCoordinator`'s diff stays minimal.
@MainActor
@Observable
final class DeepLinkHandler {
    /// Coordinator-provided effects (closures — the handler never reaches
    /// into navigation state itself).
    struct Actions {
        /// Open `sessionId` on a RESOLVED, store-known host.
        let openSession: @MainActor (HostRegistry.Host, UUID) -> Void
        /// Unknown host id → surface the pairing flow.
        let showPairing: @MainActor () -> Void
    }

    /// Alert copy for RootView (unknown host / store failure). nil = no alert.
    private(set) var hintMessage: String?
    /// Invalid deep links dropped so far (plan: `.ignore` + log counter).
    private(set) var ignoredCount = 0

    @ObservationIgnored private let loadHosts: @Sendable () async throws -> [HostRegistry.Host]
    @ObservationIgnored private let actions: Actions
    /// Cold-launch gate: `handle(url:)` before `markReady()` stashes instead
    /// of applying (the host list / root route may not exist yet).
    @ObservationIgnored private var isReady = false
    /// Single-slot stash — a newer link before readiness replaces the older
    /// one (two half-applied navigations would be worse than dropping one).
    @ObservationIgnored private var pendingRoute: DeepLinkRouter.Route?
    @ObservationIgnored private let logger = Logger(
        subsystem: DeepLinkLog.subsystem, category: DeepLinkLog.category
    )

    init(
        loadHosts: @escaping @Sendable () async throws -> [HostRegistry.Host],
        actions: Actions
    ) {
        self.loadHosts = loadHosts
        self.actions = actions
    }

    // MARK: - Entry points

    /// `.onOpenURL` lands here (via `AppCoordinator.handleDeepLink`). Invalid
    /// links are counted and dropped — they are NEVER stashed.
    func handle(url: URL) async {
        let route = DeepLinkRouter.route(url: url)
        guard route != .ignore else {
            recordIgnored()
            return
        }
        guard isReady else {
            pendingRoute = route
            return
        }
        await apply(route)
    }

    /// Cold-start bootstrap finished (root route decided) → flush the stash.
    /// Idempotent: the slot is cleared before applying, so a second call
    /// never replays.
    func markReady() async {
        isReady = true
        guard let route = pendingRoute else { return }
        pendingRoute = nil
        await apply(route)
    }

    func clearHint() {
        hintMessage = nil
    }

    // MARK: - Apply (host id resolves ONLY through the store)

    private func apply(_ route: DeepLinkRouter.Route) async {
        // `.gateSession` never reaches here in P1: it only exists for the
        // push path, whose handling (host resolution incl.) is T-iOS-21.
        guard case let .openSession(hostId, sessionId) = route else { return }
        do {
            let hosts = try await loadHosts()
            guard let host = hosts.first(where: { $0.id == hostId }) else {
                hintMessage = DeepLinkCopy.unknownHostHint
                actions.showPairing()
                return
            }
            actions.openSession(host, sessionId)
        } catch {
            // Explicit failure copy — a broken store must never look like a
            // silently dead link (plan §4 error-handling rule).
            hintMessage = DeepLinkCopy.hostLoadFailed
            logger.error("deep link host-store read failed: \(error)")
        }
    }

    private func recordIgnored() {
        ignoredCount += 1
        // URL contents are untrusted external input — log the counter only,
        // never echo the link (log-injection / privacy hygiene).
        logger.notice("ignored invalid deep link (total: \(self.ignoredCount))")
    }
}

private enum DeepLinkLog {
    static let subsystem = "com.yaojia.webterm"
    static let category = "deep-link"
}

// MARK: - AppCoordinator wiring (kept here so the coordinator diff stays tiny)

extension AppCoordinator {
    /// Factory for the coordinator's lazy `deepLink` property.
    func makeDeepLinkHandler() -> DeepLinkHandler {
        DeepLinkHandler(
            loadHosts: { [environment] in try await environment.hostStore.loadAll() },
            actions: DeepLinkHandler.Actions(
                openSession: { [weak self] host, sessionId in
                    self?.openDeepLinkedSession(host: host, sessionId: sessionId)
                },
                showPairing: { [weak self] in
                    self?.showPairingForDeepLink()
                }
            )
        )
    }

    /// RootView's `.onOpenURL` entry (sync SwiftUI context → async apply).
    /// Fires for BOTH warm links and cold launches — on cold start the
    /// handler stashes until `bootstrap()` calls `markReady()`.
    func handleDeepLink(url: URL) {
        Task { await deepLink.handle(url: url) }
    }

    /// Deep links may target a session while another terminal is open:
    /// detach the current one first (`open` is a no-op otherwise — its
    /// one-foreground-session guard would swallow the link).
    private func openDeepLinkedSession(host: HostRegistry.Host, sessionId: UUID) {
        if terminalController != nil {
            closeTerminal()
        }
        open(SessionListViewModel.OpenRequest(id: UUID(), host: host, sessionId: sessionId))
    }

    /// Unknown host id → pairing. On the first-run pairing route the pairing
    /// screen is already frontmost — only the list route needs the sheet.
    private func showPairingForDeepLink() {
        guard route == .sessions else { return }
        presentAddHost()
    }
}
