// AUTO-GENERATED from gstack/ios-qa/templates/StateServer.swift.template
// Regenerate with: /ios-sync
//
// StateServer — HTTP server embedded in the iOS app under test. Loopback-only.
// All tailnet ingress is the responsibility of the Mac-side daemon.
//
// Threat model: this surface is reachable from the local Mac via the CoreDevice
// IPv6 tunnel. It MUST refuse any caller without a current bearer token. The
// boot token is rotated within ~5 seconds of daemon spawn so anything scraping
// os_log past that window sees a dead credential.

import Foundation
import Network
import os.log

#if DEBUG

public typealias JSONDict = [String: Any]

@MainActor
public final class StateServer {
    // MARK: Public surface

    public static let shared = StateServer()

    // MARK: Configuration

    private let logger = Logger(subsystem: "gstack.ios-qa", category: "StateServer")
    private let port: UInt16
    private let bootTokenPath: String

    // Two listeners for dual-stack loopback. The fork's single-listener IPv6-only
    // binding was caught in eng + outside-voice review as incomplete.
    private var ipv6Listener: NWListener?
    private var ipv4Listener: NWListener?

    // Auth state. The boot token is what we wrote to os_log on first launch.
    // It exists ONLY long enough for the daemon to call /auth/rotate.
    private var bootToken: String
    private var rotatedToken: String?  // set after first /auth/rotate
    private var bootTokenValid: Bool = true

    // MARK: Session lock (per-device, sliding window on mutations only)

    private struct Session {
        let id: String
        var lastMutationAt: Date
    }
    private var activeSession: Session?
    private let sessionTtlSeconds: TimeInterval = 300  // 5 min orphan timeout

    // MARK: Accessor registry (populated by codegen)

    public typealias ReadHandler = () -> Any?
    public typealias WriteHandler = (Any) -> Bool
    public typealias TypeName = String

    private var readHandlers: [String: ReadHandler] = [:]
    private var writeHandlers: [String: WriteHandler] = [:]
    private var typeNames: [String: TypeName] = [:]

    // Atomic-restore hook. Codegen wires this to the canonical AppState struct.
    // Restore replaces the entire struct in one assignment so SwiftUI's Combine
    // pipeline observes exactly one change notification — true observable
    // atomicity. @MainActor alone doesn't guarantee that.
    public typealias AtomicRestoreFn = (JSONDict) -> RestoreResult
    public enum RestoreResult {
        case ok
        case missingKey(String)
        case typeMismatch(String)
        case schemaMismatch(expected: String, got: String)
    }
    private var atomicRestore: AtomicRestoreFn?

    // Snapshot schema hash — written by codegen, stable across builds with
    // identical accessor signatures.
    private var accessorHash: String = "uninitialized"
    private var appBuildId: String = "uninitialized"

    // Agent identity for the DebugOverlay attribution chip. Display-only,
    // never used for auth.
    public private(set) var lastAgentIdentity: String = "Claude Code (local)"

    // MARK: Lifecycle

    private init(port: UInt16 = 9999) {
        self.port = port
        self.bootToken = UUID().uuidString
        self.bootTokenPath = NSTemporaryDirectory() + "gstack-ios-qa.token"
    }

    public func start() {
        // 1. Persist boot token to a 0600 file (best-effort fallback for the
        //    daemon if os_log scrape misses).
        try? bootToken.write(toFile: bootTokenPath, atomically: true, encoding: .utf8)
        try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: bootTokenPath)

        // 2. Log the boot token EXACTLY ONCE so the daemon can scrape it.
        //    The daemon will rotate immediately; this log line is dead within
        //    seconds.
        logger.notice("gstack-ios-qa-bootstrap token=\(self.bootToken, privacy: .public) port=\(self.port, privacy: .public) build=\(self.appBuildId, privacy: .public)")

        // 3. Bind both IPv6 and IPv4 loopback. CoreDevice tunnel uses IPv6;
        //    local tooling may use IPv4. Never bind 0.0.0.0 or ::.
        startListener(family: .ipv6)
        startListener(family: .ipv4)
    }

    public func register(buildId: String, accessorHash: String, atomicRestore: @escaping AtomicRestoreFn) {
        self.appBuildId = buildId
        self.accessorHash = accessorHash
        self.atomicRestore = atomicRestore
    }

    public func registerAccessor(key: String, type: String, read: @escaping ReadHandler, write: @escaping WriteHandler) {
        readHandlers[key] = read
        writeHandlers[key] = write
        typeNames[key] = type
    }

    // MARK: Listener setup

    private enum AddressFamily {
        case ipv4
        case ipv6

        var host: NWEndpoint.Host {
            switch self {
            case .ipv4: return NWEndpoint.Host("127.0.0.1")
            case .ipv6: return NWEndpoint.Host("::1")
            }
        }
    }

    private func startListener(family: AddressFamily) {
        do {
            // Binding strategy: accept connections from the device's loopback
            // AND from the CoreDevice tunnel (the USB-mounted tunnel the Mac
            // daemon uses to reach this app — appears as a non-loopback
            // utun-style interface on the device with the peer's source
            // address in the fd*/fc* ULA range). We can't use
            // params.acceptLocalOnly — Network.framework's definition of
            // "local" is strictly loopback and silently drops CoreDevice
            // tunnel peers. Instead we accept on the wildcard interface and
            // do a per-connection peer-address check below: loopback OR
            // RFC 4193 ULA (fc00::/7) → accept, everything else → cancel.
            let params = NWParameters.tcp
            params.allowLocalEndpointReuse = true

            let listener = try NWListener(using: params, on: NWEndpoint.Port(rawValue: port)!)
            listener.stateUpdateHandler = { [weak self] state in
                Task { @MainActor in
                    if case .ready = state {
                        self?.logger.notice("StateServer listening on \(String(describing: family))")
                    } else if case .failed(let err) = state {
                        self?.logger.error("StateServer listener failed: \(err.localizedDescription, privacy: .public)")
                    }
                }
            }
            listener.newConnectionHandler = { [weak self] connection in
                Task { @MainActor in
                    // Defense-in-depth: even with .loopback interface gate, double-check
                    // the peer is loopback. Reject otherwise.
                    if let self, self.isLoopbackPeer(connection) {
                        self.handle(connection)
                    } else {
                        connection.cancel()
                    }
                }
            }
            listener.start(queue: .global(qos: .userInitiated))

            switch family {
            case .ipv6: ipv6Listener = listener
            case .ipv4: ipv4Listener = listener
            }
        } catch {
            logger.error("Listener bind failed (\(String(describing: family))): \(error.localizedDescription, privacy: .public)")
        }
    }

    private func isLoopbackPeer(_ connection: NWConnection) -> Bool {
        switch connection.endpoint {
        case .hostPort(let host, _):
            switch host {
            case .ipv4(let addr):
                return addr == .loopback
            case .ipv6(let addr):
                // Loopback (::1) — local same-device traffic
                if addr.isLoopback { return true }
                // CoreDevice ULA range (fd00::/8 unique-local addresses) —
                // the USB tunnel that the Mac daemon uses to reach this app.
                // Apple's CoreDevice tunnel uses fd-prefixed ULAs like
                // fd72:8347:2ead::1 (Mac-facing) and fd72:8347:2ead::2
                // (device-facing). We accept the entire ULA range since
                // the prefix is regenerated per session.
                let bytes = addr.rawValue
                if bytes.count >= 1 && (bytes[0] & 0xFE) == 0xFC {
                    // RFC 4193 ULA range (fc00::/7) — fc* or fd* prefix.
                    return true
                }
                return false
            case .name(let name, _):
                return name == "localhost"
            @unknown default: return false
            }
        default: return false
        }
    }

    // MARK: Request handling

    private func handle(_ connection: NWConnection) {
        connection.start(queue: .global(qos: .userInitiated))
        receive(connection: connection, buffer: Data())
    }

    private static let maxBodyBytes = 1_048_576  // 1MB hard cap

    private func receive(connection: NWConnection, buffer: Data) {
        connection.receive(minimumIncompleteLength: 1, maximumLength: 65_536) { [weak self] data, _, isComplete, error in
            guard let self else { return }
            Task { @MainActor in
                var current = buffer
                if let data = data { current.append(data) }
                if current.count > Self.maxBodyBytes {
                    self.send(connection: connection, status: 413, body: ["error": "body_too_large"])
                    return
                }
                if let req = self.tryParseRequest(current) {
                    self.route(connection: connection, request: req)
                } else if isComplete || error != nil {
                    self.send(connection: connection, status: 400, body: ["error": "bad_request"])
                } else {
                    self.receive(connection: connection, buffer: current)
                }
            }
        }
    }

    struct ParsedRequest {
        let method: String
        let path: String
        let headers: [String: String]
        let body: Data
    }

    private func tryParseRequest(_ data: Data) -> ParsedRequest? {
        guard let headerEnd = data.range(of: Data("\r\n\r\n".utf8)) else { return nil }
        let headerData = data.subdata(in: 0..<headerEnd.lowerBound)
        let body = data.subdata(in: headerEnd.upperBound..<data.count)
        guard let headerStr = String(data: headerData, encoding: .utf8) else { return nil }
        let lines = headerStr.components(separatedBy: "\r\n")
        guard let requestLine = lines.first else { return nil }
        let parts = requestLine.components(separatedBy: " ")
        guard parts.count >= 2 else { return nil }

        var headers: [String: String] = [:]
        for line in lines.dropFirst() {
            guard let colon = line.firstIndex(of: ":") else { continue }
            let key = String(line[..<colon]).lowercased()
            let value = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces)
            headers[key] = value
        }

        if let lenStr = headers["content-length"], let len = Int(lenStr), body.count < len {
            return nil  // need more bytes
        }
        return ParsedRequest(method: parts[0], path: parts[1], headers: headers, body: body)
    }

    private func route(connection: NWConnection, request: ParsedRequest) {
        // Update display attribution from header (display only — never trusted
        // for auth).
        if let agent = request.headers["x-agent-identity"], !agent.isEmpty, agent.count < 200 {
            lastAgentIdentity = agent
        }

        let path = request.path

        // 1. Public on loopback: /healthz.
        if request.method == "GET" && path == "/healthz" {
            send(connection: connection, status: 200, body: [
                "version": "1.0.0",
                "build": appBuildId,
                "accessor_hash": accessorHash,
            ])
            return
        }

        // 2. Auth bootstrap: /auth/rotate is the ONLY endpoint that accepts the
        //    boot token. Everything else requires the rotated token.
        if request.method == "POST" && path == "/auth/rotate" {
            handleAuthRotate(connection: connection, request: request)
            return
        }

        // 3. All other endpoints require Bearer auth with the rotated token.
        guard authorize(request: request) else {
            send(connection: connection, status: 401, body: ["error": "unauthorized"])
            return
        }

        switch (request.method, path) {
        case ("POST", "/session/acquire"): handleSessionAcquire(connection: connection)
        case ("POST", "/session/release"): handleSessionRelease(connection: connection)
        case ("POST", "/session/heartbeat"): handleSessionHeartbeat(connection: connection, request: request)
        case ("GET", "/state/snapshot"): handleSnapshotGet(connection: connection)
        case ("POST", "/state/restore"): handleSnapshotRestore(connection: connection, request: request)
        case ("GET", "/elements"): handleElements(connection: connection)
        case ("GET", "/screenshot"): handleScreenshot(connection: connection)
        case ("POST", "/tap"): handleMutation(connection: connection, request: request, op: "tap")
        case ("POST", "/swipe"): handleMutation(connection: connection, request: request, op: "swipe")
        case ("POST", "/type"): handleMutation(connection: connection, request: request, op: "type")
        case ("GET", let p) where p.hasPrefix("/state/"):
            let key = String(p.dropFirst("/state/".count))
            handleStateGet(connection: connection, key: key)
        case ("POST", let p) where p.hasPrefix("/state/"):
            let key = String(p.dropFirst("/state/".count))
            handleStateWrite(connection: connection, request: request, key: key)
        default:
            send(connection: connection, status: 404, body: ["error": "not_found", "path": path])
        }
    }

    // MARK: Auth

    private func authorize(request: ParsedRequest) -> Bool {
        guard let auth = request.headers["authorization"], auth.hasPrefix("Bearer ") else { return false }
        let token = String(auth.dropFirst("Bearer ".count))
        return token == rotatedToken
    }

    private func handleAuthRotate(connection: NWConnection, request: ParsedRequest) {
        // Validate boot token (still alive AND used only once).
        guard bootTokenValid,
              let auth = request.headers["authorization"],
              auth.hasPrefix("Bearer "),
              String(auth.dropFirst("Bearer ".count)) == bootToken else {
            send(connection: connection, status: 401, body: ["error": "boot_token_invalid"])
            return
        }

        guard let dict = try? JSONSerialization.jsonObject(with: request.body) as? JSONDict,
              let newToken = dict["new_token"] as? String,
              newToken.count >= 16 else {
            send(connection: connection, status: 400, body: ["error": "invalid_rotate_payload"])
            return
        }

        rotatedToken = newToken
        bootTokenValid = false
        // Best-effort scrub of on-disk boot token file.
        try? FileManager.default.removeItem(atPath: bootTokenPath)

        logger.notice("Boot token rotated; original now invalid")
        send(connection: connection, status: 200, body: ["ok": true])
    }

    // MARK: Session lock

    private static let mutatingPaths: Set<String> = ["/tap", "/swipe", "/type", "/state/restore"]

    private func mutatingPathRequiresSession(_ path: String, method: String) -> Bool {
        if method != "POST" { return false }
        if path.hasPrefix("/state/") && path != "/state/restore" { return true }  // /state/<key> writes
        return Self.mutatingPaths.contains(path)
    }

    private func requireSession(in request: ParsedRequest, connection: NWConnection) -> Bool {
        guard let id = request.headers["x-session-id"] else {
            send(connection: connection, status: 409, body: ["error": "session_required"])
            return false
        }
        guard let current = activeSession, current.id == id else {
            send(connection: connection, status: 409, body: ["error": "session_invalid_or_expired"])
            return false
        }
        // Mutation slides the lock; reads do not.
        activeSession?.lastMutationAt = Date()
        return true
    }

    private func handleSessionAcquire(connection: NWConnection) {
        // Reap orphaned session.
        if let s = activeSession, Date().timeIntervalSince(s.lastMutationAt) > sessionTtlSeconds {
            activeSession = nil
        }
        if activeSession != nil {
            send(connection: connection, status: 423, body: ["error": "device_locked"])
            return
        }
        let id = UUID().uuidString
        activeSession = Session(id: id, lastMutationAt: Date())
        send(connection: connection, status: 200, body: [
            "session_id": id,
            "ttl_seconds": Int(sessionTtlSeconds),
        ])
    }

    private func handleSessionRelease(connection: NWConnection) {
        activeSession = nil
        send(connection: connection, status: 200, body: ["ok": true])
    }

    private func handleSessionHeartbeat(connection: NWConnection, request: ParsedRequest) {
        guard let id = request.headers["x-session-id"],
              activeSession?.id == id else {
            send(connection: connection, status: 409, body: ["error": "session_invalid_or_expired"])
            return
        }
        activeSession?.lastMutationAt = Date()
        send(connection: connection, status: 200, body: ["ok": true, "ttl_seconds": Int(sessionTtlSeconds)])
    }

    // MARK: State handlers

    private func handleStateGet(connection: NWConnection, key: String) {
        guard let handler = readHandlers[key] else {
            send(connection: connection, status: 404, body: ["error": "unknown_key", "key": key])
            return
        }
        let value = handler() ?? NSNull()
        send(connection: connection, status: 200, body: ["key": key, "value": value])
    }

    private func handleStateWrite(connection: NWConnection, request: ParsedRequest, key: String) {
        guard requireSession(in: request, connection: connection) else { return }
        guard let handler = writeHandlers[key] else {
            send(connection: connection, status: 404, body: ["error": "unknown_key", "key": key])
            return
        }
        guard let payload = try? JSONSerialization.jsonObject(with: request.body) as? JSONDict,
              let value = payload["value"] else {
            send(connection: connection, status: 400, body: ["error": "missing_value"])
            return
        }
        let ok = handler(value)
        if ok {
            send(connection: connection, status: 200, body: ["ok": true])
        } else {
            send(connection: connection, status: 400, body: ["error": "type_mismatch", "expected": typeNames[key] ?? "?"])
        }
    }

    private func handleSnapshotGet(connection: NWConnection) {
        var keys: JSONDict = [:]
        for (k, read) in readHandlers {
            keys[k] = read() ?? NSNull()
        }
        let envelope: JSONDict = [
            "_schema_version": 1,
            "_app_build_id": appBuildId,
            "_accessor_hash": accessorHash,
            "keys": keys,
        ]
        send(connection: connection, status: 200, body: envelope)
    }

    private func handleSnapshotRestore(connection: NWConnection, request: ParsedRequest) {
        guard requireSession(in: request, connection: connection) else { return }
        guard let envelope = try? JSONSerialization.jsonObject(with: request.body) as? JSONDict else {
            send(connection: connection, status: 400, body: ["error": "invalid_json"])
            return
        }
        // Schema gate.
        if let hash = envelope["_accessor_hash"] as? String, hash != accessorHash {
            send(connection: connection, status: 409, body: [
                "error": "schema_mismatch",
                "expected_hash": accessorHash,
                "got_hash": hash,
            ])
            return
        }
        guard let keys = envelope["keys"] as? JSONDict else {
            send(connection: connection, status: 400, body: ["error": "missing_keys"])
            return
        }
        guard let restore = atomicRestore else {
            send(connection: connection, status: 503, body: ["error": "atomic_restore_not_registered"])
            return
        }
        // Validate-then-apply via the codegen-supplied closure. The closure does
        // a single struct-assignment so SwiftUI sees one change notification.
        switch restore(keys) {
        case .ok:
            send(connection: connection, status: 200, body: ["ok": true])
        case .missingKey(let k):
            send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "missing"])
        case .typeMismatch(let k):
            send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "type-mismatch"])
        case .schemaMismatch(let expected, let got):
            send(connection: connection, status: 409, body: ["error": "schema_mismatch", "expected_hash": expected, "got_hash": got])
        }
    }

    // MARK: Stubs (real impls live in DebugBridgeManager + UIKit)

    private func handleElements(connection: NWConnection) {
        let tree = ElementsBridge.snapshot()
        send(connection: connection, status: 200, body: ["elements": tree])
    }

    private func handleScreenshot(connection: NWConnection) {
        if let png = ScreenshotBridge.capturePNG() {
            send(connection: connection, status: 200, body: ["png_base64": png.base64EncodedString()])
        } else {
            send(connection: connection, status: 500, body: ["error": "screenshot_unavailable"])
        }
    }

    private func handleMutation(connection: NWConnection, request: ParsedRequest, op: String) {
        guard requireSession(in: request, connection: connection) else { return }
        guard let payload = try? JSONSerialization.jsonObject(with: request.body) as? JSONDict else {
            send(connection: connection, status: 400, body: ["error": "invalid_json"])
            return
        }
        let ok = MutationBridge.dispatch(op: op, payload: payload)
        send(connection: connection, status: ok ? 200 : 400, body: ["op": op, "ok": ok])
    }

    // MARK: Response

    private func send(connection: NWConnection, status: Int, body: JSONDict) {
        let json = (try? JSONSerialization.data(withJSONObject: body)) ?? Data("{}".utf8)
        let statusText: String
        switch status {
        case 200: statusText = "OK"
        case 400: statusText = "Bad Request"
        case 401: statusText = "Unauthorized"
        case 404: statusText = "Not Found"
        case 409: statusText = "Conflict"
        case 413: statusText = "Payload Too Large"
        case 423: statusText = "Locked"
        case 429: statusText = "Too Many Requests"
        case 500: statusText = "Internal Server Error"
        case 503: statusText = "Service Unavailable"
        default: statusText = "Status"
        }
        let header = "HTTP/1.1 \(status) \(statusText)\r\nContent-Type: application/json\r\nContent-Length: \(json.count)\r\nConnection: close\r\n\r\n"
        var packet = Data(header.utf8)
        packet.append(json)
        connection.send(content: packet, completion: .contentProcessed { _ in
            connection.cancel()
        })
    }
}

// MARK: - Bridges (implementation provided by DebugBridgeManager)

@MainActor
public enum ElementsBridge {
    public static var resolver: () -> [JSONDict] = { [] }
    static func snapshot() -> [JSONDict] { resolver() }
}

@MainActor
public enum ScreenshotBridge {
    public static var resolver: () -> Data? = { nil }
    static func capturePNG() -> Data? { resolver() }
}

@MainActor
public enum MutationBridge {
    public static var resolver: (String, JSONDict) -> Bool = { _, _ in false }
    static func dispatch(op: String, payload: JSONDict) -> Bool { resolver(op, payload) }
}

#endif // DEBUG
