import Foundation
import NucleicProtocol
import Security
import os

/// Push failures are best-effort and were previously silent; log them so a mis-addressed push
/// (e.g. APNs `BadTopic` for a per-channel build, `BadDeviceToken` for an env mismatch) is
/// visible in the Mac's Console instead of vanishing. The relay logs the APNs `reason` itself.
private let pushLog = Logger(subsystem: "com.nucleic", category: "push")

/// The host's out-of-band push path (docs/CLOUD_INFRA.md §3): upload each paired phone's
/// APNS token to the relay (`POST /v1/push/register`) and ask it to wake a phone that isn't
/// connected when an approval arrives (`POST /v1/push/notify`). The push itself is
/// content-free — the relay holds the APNS key and sends a localized "approval pending"
/// tickle; the phone pulls the real approval over the encrypted channel on open.
///
/// **No shared secret ships with the app.** The host self-enrolls with the relay on first
/// use (`POST /v1/host/enroll`) and receives its own scoped credential — it can register
/// and wake only its own devices (the relay stores just the credential's hash). The
/// credential lives in this Mac's login Keychain. User-facing configuration is therefore a
/// single toggle; the URL/environment knobs exist only for development.
public struct PushRelayConfig: Sendable {
    public let baseURL: URL
    /// Which APNS environment the paired phones' builds use: "production" (TestFlight /
    /// App Store — the right default for a distributed build) or "sandbox" (Xcode dev
    /// installs; a dev-build-only override).
    public let apnsEnv: String

    /// The production relay every distributed build talks to.
    public static let defaultBaseURL = URL(string: "https://api.nucleic.blakeslee.xyz")!

    public init(baseURL: URL = PushRelayConfig.defaultBaseURL, apnsEnv: String = "production") {
        self.baseURL = baseURL
        self.apnsEnv = apnsEnv
    }

    /// Resolve the relay configuration. Enabled by the Settings toggle
    /// (`nucleic.relay.enabled`) or the `NUCLEIC_RELAY_ENABLED=1` env override; nil while
    /// off — the host then never touches the network for push. The URL and APNS
    /// environment take dev-only overrides (`NUCLEIC_RELAY_URL`, `NUCLEIC_APNS_ENV`),
    /// env vars first then the same-named defaults keys the dev Settings fields write.
    public static func resolve(
        environment: [String: String] = ProcessInfo.processInfo.environment,
        defaults: UserDefaults = .standard
    ) -> PushRelayConfig? {
        func value(_ key: String) -> String? {
            let v = environment[key] ?? defaults.string(forKey: key)
            return (v?.isEmpty == false) ? v : nil
        }
        let enabled = environment["NUCLEIC_RELAY_ENABLED"] == "1"
            || defaults.bool(forKey: "nucleic.relay.enabled")
        guard enabled else { return nil }
        let url = value("NUCLEIC_RELAY_URL").flatMap(URL.init(string:)) ?? defaultBaseURL
        return PushRelayConfig(baseURL: url, apnsEnv: value("NUCLEIC_APNS_ENV") ?? "production")
    }
}

/// The host's scoped relay credential (`hostId` + secret), minted by `/v1/host/enroll` and
/// presented as `Bearer <hostId>.<secret>`. Persisted in the login Keychain.
struct RelayHostCredential: Sendable {
    let hostID: String
    let secret: String

    var bearer: String { "\(hostID).\(secret)" }
}

/// Keychain persistence for the relay credential (same pattern as `HostIdentityStore`).
enum RelayCredentialStore {
    private static let account = "com.nucleic.host.relay-credential"

    private static var identity: [String: Any] {
        [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: account,
        ]
    }

    static func load() -> RelayHostCredential? {
        guard let data = KeychainOwnedAccess.copyData(identity),
              let joined = String(data: data, encoding: .utf8),
              let dot = joined.firstIndex(of: ".")
        else { return nil }
        return RelayHostCredential(
            hostID: String(joined[..<dot]), secret: String(joined[joined.index(after: dot)...]))
    }

    static func save(_ credential: RelayHostCredential) {
        var base = identity
        base[kSecValueData as String] = Data(credential.bearer.utf8)
        KeychainOwnedAccess.set(base)
    }
}

/// Best-effort relay HTTP client. Failures are swallowed (the phone still works fully on
/// LAN without push); wakes are throttled per device so an approval storm can't hammer
/// APNS — one tickle is enough to bring the user in.
public actor PushRelayClient {
    private let config: PushRelayConfig
    private let session: URLSession
    private var credential: RelayHostCredential?
    private var lastWake: [String: Date] = [:]
    /// Per-device throttle for the Live Activity "adopt" wake — separate from `lastWake` so an
    /// adopt nudge and an approval tickle don't throttle each other.
    private var lastAdoptWake: [String: Date] = [:]
    /// Tokens already uploaded this run (re-uploading on every reconnect is redundant).
    private var registered: Set<String> = []
    /// Enrollment backoff so a down relay isn't hammered on every approval.
    private var lastEnrollAttempt: Date?

    private static let wakeInterval: TimeInterval = 30
    private static let enrollRetryInterval: TimeInterval = 60

    public init(config: PushRelayConfig, session: URLSession = .shared) {
        self.config = config
        self.session = session
        self.credential = RelayCredentialStore.load()
    }

    /// Upload a phone's APNS token after its sync Hello, tagged with its APNs `topic` (the phone's
    /// own bundle id) so the relay addresses wakes/glances to the right per-channel app. De-duped
    /// per (device, token, topic) — a topic change (e.g. a channel switch) re-registers.
    public func registerToken(deviceID: String, token: String, topic: String? = nil) async {
        let key = "\(deviceID):\(token):\(topic ?? "")"
        guard !registered.contains(key) else { return }
        var body = ["deviceId": deviceID, "token": token, "env": config.apnsEnv]
        if let topic, !topic.isEmpty { body["topic"] = topic }
        let ok = await authedPost("/v1/push/register", body: body)
        if ok {
            registered.insert(key)
        } else {
            pushLog.warning("relay token register failed for device \(deviceID, privacy: .public)")
        }
    }

    /// Ask the relay to wake a phone (content-free APNS tickle). Throttled per device. `question`
    /// tags the wake so the relay's alert reads "waiting for your answer" when the block is an agent
    /// question (`AskUserQuestion`) rather than a tool approval — the only detail the content-free
    /// tickle carries is this block *category*, not the question itself.
    public func wake(deviceID: String, question: Bool = false) async {
        let now = Date()
        if let last = lastWake[deviceID], now.timeIntervalSince(last) < Self.wakeInterval { return }
        lastWake[deviceID] = now
        var body = ["deviceId": deviceID]
        if question { body["kind"] = "question" }
        _ = await authedPost("/v1/push/notify", body: body)
    }

    /// Ask the relay to silently wake a phone so it comes online and registers a push-started Live
    /// Activity's update token (iOS 17.2+, UX_IOS §5.3). A content-available background push — it
    /// carries no content, just enough to grant the app a moment of runtime to harvest the token the
    /// host needs to keep the glance fresh (and to end it cleanly). Throttled per device so repeated
    /// broadcasts while the phone stays offline don't hammer APNs; best-effort like `wake`.
    public func wakeForLiveActivityAdopt(deviceID: String) async {
        let now = Date()
        if let last = lastAdoptWake[deviceID], now.timeIntervalSince(last) < Self.wakeInterval { return }
        lastAdoptWake[deviceID] = now
        _ = await authedPost("/v1/push/liveActivityAdopt", body: ["deviceId": deviceID])
    }

    /// Ask the relay to recall a phone's approval tickle — the counterpart to `wake`, sent once an
    /// approval is resolved (on any device) and nothing else is pending, so a backgrounded phone
    /// still showing "A session is waiting for your approval" clears it. Unthrottled: it only fires
    /// on the edge where pending approvals reach zero, so it's naturally single-shot. Also drops the
    /// wake throttle for the device so the *next* approval can nudge immediately rather than waiting
    /// out the 30-second window.
    public func clear(deviceID: String) async {
        lastWake[deviceID] = nil
        _ = await authedPost("/v1/push/clear", body: ["deviceId": deviceID])
    }

    /// Push a Live Activity content-state update to a phone's activity (UX_IOS §5.3). Unlike
    /// `wake`, this carries the aggregate glance so the lock screen stays fresh while the phone's
    /// socket is suspended — the relay forwards it to APNS as a `liveactivity` push. The `token`
    /// is the activity's per-activity APNS update token (distinct from the device token); the
    /// content-state is therefore visible to the relay/APNS, unlike the content-free wake tickle.
    /// Best-effort; the caller dedupes so an unchanged glance isn't re-sent.
    /// Which alert an alerting Live Activity update carries so the glance itself sounds/vibrates in
    /// place of a separate push/banner: `approval` (a decision is waited on — its Allow/Deny live on
    /// the glance) or `input` (the session wants the user's next prompt). Raw values match the relay's
    /// `alert` field and the phone's loc-keys.
    public enum LiveActivityAlert: String, Sendable {
        case approval, input
    }

    public func updateLiveActivity(
        deviceID: String, token: String, topic: String? = nil, snapshot: LiveActivitySnapshot,
        alert: LiveActivityAlert? = nil
    ) async {
        await sendLiveActivity(
            deviceID: deviceID, token: token, topic: topic, event: "update", snapshot: snapshot,
            alert: alert)
    }

    /// Ask the relay to **start** a phone's Live Activity over APNs (iOS 17.2+ push-to-start) — used
    /// when work begins while the app is closed, so the glance appears without the user opening the
    /// app first (UX_IOS §5.3). Carries the activity's static attributes (the host name) and the
    /// initial glance; the relay turns these into an `event:"start"` push. `token` is the
    /// device-scoped push-to-start token, distinct from a per-activity update token.
    public func startLiveActivity(
        deviceID: String, token: String, topic: String? = nil, hostName: String,
        snapshot: LiveActivitySnapshot
    ) async {
        await sendLiveActivity(
            deviceID: deviceID, token: token, topic: topic, event: "start", snapshot: snapshot,
            attributes: LiveActivityAttributes(hostName: hostName))
    }

    /// Tell the relay to end a phone's Live Activity (all idle / dismissed). Normally called with a
    /// nil `done`, which dismisses immediately — the "Done" glance is now *held* via ordinary updates
    /// until the user opens the app (UX_IOS §5.3, `SyncHost.performLiveActivityPush`), not lingered on
    /// the end push. A non-nil `done` still rides along as the end push's content-state and lingers the
    /// dismissal a few seconds (legacy path, currently unused).
    public func endLiveActivity(
        deviceID: String, token: String, topic: String? = nil, done: LiveActivitySnapshot? = nil
    ) async {
        await sendLiveActivity(
            deviceID: deviceID, token: token, topic: topic, event: "end", snapshot: done)
    }

    /// The static `ActivityAttributes` a push-to-start push carries — the phone's
    /// `NucleicSessionAttributes` (host name only). Set for an `event:"start"` push; nil otherwise.
    struct LiveActivityAttributes: Encodable {
        let hostName: String
    }

    private struct LiveActivityPushBody: Encodable {
        let deviceId: String
        let token: String
        /// The phone's APNs topic (its bundle id). Omitted when unknown → the relay uses its
        /// configured fallback topic. A synthesized `Encodable` omits a nil optional.
        let topic: String?
        let env: String
        let event: String
        let contentState: LiveActivitySnapshot?
        /// Only present on an `event:"start"` push (push-to-start): the Activity's static attributes
        /// and the type name they must decode into on the phone. A synthesized `Encodable` omits both
        /// when nil, so update/end bodies stay byte-for-byte as before.
        let attributes: LiveActivityAttributes?
        let attributesType: String?
        /// Set on an `update` that should sound/vibrate through the glance (a session newly needs the
        /// user while the phone was away): "approval" | "input". The relay then attaches the alert
        /// that replaces the separate wake tickle / banner. A synthesized `Encodable` omits it when
        /// nil, so silent updates (and every start/end) stay byte-for-byte as before.
        let alert: String?
    }

    /// The phone's `ActivityAttributes` conformer name — the `attributes-type` a push-to-start push
    /// must name so ActivityKit knows which Activity to create. Must match the Swift type exactly.
    private static let attributesTypeName = "NucleicSessionAttributes"

    private func sendLiveActivity(
        deviceID: String, token: String, topic: String?, event: String,
        snapshot: LiveActivitySnapshot?, attributes: LiveActivityAttributes? = nil,
        alert: LiveActivityAlert? = nil
    ) async {
        let body = LiveActivityPushBody(
            deviceId: deviceID, token: token,
            topic: (topic?.isEmpty == false) ? topic : nil, env: config.apnsEnv,
            event: event, contentState: snapshot,
            attributes: attributes,
            attributesType: attributes == nil ? nil : Self.attributesTypeName,
            alert: alert?.rawValue)
        guard let data = try? JSONEncoder().encode(body) else { return }
        let ok = await authedPostData("/v1/push/liveActivity", body: data)
        if !ok {
            pushLog.warning(
                "relay live-activity \(event, privacy: .public) failed for device \(deviceID, privacy: .public) topic \(topic ?? "-", privacy: .public)")
        }
    }

    // MARK: - Enrollment + transport

    /// POST with the host credential, enrolling first if we have none. A 401 means the
    /// credential lapsed server-side (KV TTL after a long dark spell): re-enroll once and
    /// retry — token records still carry the old owner, but every phone Hello re-registers,
    /// so ownership heals on the next connect.
    private func authedPost(_ path: String, body: [String: String]) async -> Bool {
        guard let credential = await ensureEnrolled() else { return false }
        let status = await post(path, body: body, bearer: credential.bearer)
        if status == 401 {
            self.credential = nil
            guard let fresh = await enroll() else { return false }
            return await post(path, body: body, bearer: fresh.bearer) == 200
        }
        return status == 200
    }

    private func ensureEnrolled() async -> RelayHostCredential? {
        if let credential { return credential }
        return await enroll()
    }

    /// `authedPost` for a pre-encoded JSON body (the Live Activity push carries a nested
    /// content-state object, not the flat `[String: String]` the wake/register paths use).
    private func authedPostData(_ path: String, body: Data) async -> Bool {
        guard let credential = await ensureEnrolled() else { return false }
        let status = await postData(path, body: body, bearer: credential.bearer)
        if status == 401 {
            self.credential = nil
            guard let fresh = await enroll() else { return false }
            return await postData(path, body: body, bearer: fresh.bearer) == 200
        }
        return status == 200
    }

    private func postData(_ path: String, body: Data, bearer: String) async -> Int {
        var request = URLRequest(url: config.baseURL.appendingPathComponent(path))
        request.httpMethod = "POST"
        request.timeoutInterval = 10
        request.setValue("Bearer \(bearer)", forHTTPHeaderField: "Authorization")
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = body
        guard let (_, response) = try? await session.data(for: request),
              let http = response as? HTTPURLResponse else { return -1 }
        return http.statusCode
    }

    private func enroll() async -> RelayHostCredential? {
        let now = Date()
        if let last = lastEnrollAttempt, now.timeIntervalSince(last) < Self.enrollRetryInterval {
            return nil
        }
        lastEnrollAttempt = now
        var request = URLRequest(url: config.baseURL.appendingPathComponent("/v1/host/enroll"))
        request.httpMethod = "POST"
        request.timeoutInterval = 10
        guard let (data, response) = try? await session.data(for: request),
              (response as? HTTPURLResponse)?.statusCode == 200,
              let json = try? JSONSerialization.jsonObject(with: data) as? [String: String],
              let hostID = json["hostId"], let secret = json["hostSecret"]
        else { return nil }
        let fresh = RelayHostCredential(hostID: hostID, secret: secret)
        RelayCredentialStore.save(fresh)
        credential = fresh
        registered.removeAll()  // a new identity owns nothing yet; re-upload on next Hello
        return fresh
    }

    private func post(_ path: String, body: [String: String], bearer: String) async -> Int {
        var request = URLRequest(url: config.baseURL.appendingPathComponent(path))
        request.httpMethod = "POST"
        request.timeoutInterval = 10
        request.setValue("Bearer \(bearer)", forHTTPHeaderField: "Authorization")
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = try? JSONSerialization.data(withJSONObject: body)
        guard let (_, response) = try? await session.data(for: request),
              let http = response as? HTTPURLResponse else { return -1 }
        return http.statusCode
    }
}
