import Foundation
import WireProtocol

/// Typed client for the server's HTTP surface (frozen contract, plan §3.4).
///
/// **Origin 铁律（安全语义，plan §3.4/§5.1）**: only the two G (state-changing)
/// endpoints stamp `Origin: endpoint.originHeader`; the four RO GETs never do.
/// Stamping lives in ONE place — `APIRoute.urlRequest(for:)` — and the value is
/// single-point derived by `HostEndpoint` (never hand-assembled).
///
/// The server is an UNTRUSTED input source at this boundary (plan §4): bodies
/// are decoded tolerantly (malformed entries dropped), statuses are mapped to
/// explicit `APIClientError`s, and nothing here ever crashes on bad input.
public struct APIClient: Sendable {
    public let endpoint: HostEndpoint
    private let http: any HTTPTransport

    public init(endpoint: HostEndpoint, http: any HTTPTransport) {
        self.endpoint = endpoint
        self.http = http
    }

    // MARK: - RO (read-only — NO Origin header)

    /// `GET /live-sessions` (src/server.ts:257-259) — the discovery list every
    /// device polls (`Tunables.listPollInterval` cadence, T-iOS-13).
    public func liveSessions() async throws -> [LiveSessionInfo] {
        let (data, response) = try await perform(Endpoints.liveSessions())
        guard response.statusCode == HTTPStatus.ok else {
            throw APIClientError.unexpectedStatus(response.statusCode)
        }
        return try LiveSessionInfo.decodeList(from: data)
    }

    /// `GET /live-sessions/:id/preview` (src/server.ts:314-326) — ring-buffer
    /// tail for a read-only thumbnail; no attach, no client registered.
    public func preview(id: UUID) async throws -> SessionPreview {
        let (data, response) = try await perform(Endpoints.preview(id: id))
        try Self.requireOK(response)
        guard let preview = try? JSONDecoder().decode(SessionPreview.self, from: data) else {
            throw APIClientError.invalidResponseBody
        }
        return preview
    }

    /// `GET /live-sessions/:id/events` (src/server.ts:528-540) — the A4
    /// activity timeline. Decoded via WireProtocol's tolerant helper: unknown
    /// `class` entries are dropped, a non-array body yields `[]` (the server
    /// itself replies `[]` when timeline capture is disabled).
    public func events(id: UUID) async throws -> [TimelineEvent] {
        let (data, response) = try await perform(Endpoints.events(id: id))
        try Self.requireOK(response)
        return TimelineEvent.decodeList(from: data)
    }

    /// `GET /config/ui` (src/server.ts:609-612) — `{allowAutoMode}`. Reserved
    /// for a future permission-mode switcher (plan §3.4); the plan-gate
    /// three-way UI does not consume it.
    public func uiConfig() async throws -> UiConfig {
        let (data, response) = try await perform(Endpoints.uiConfig())
        try Self.requireOK(response)
        guard let config = try? JSONDecoder().decode(UiConfig.self, from: data) else {
            throw APIClientError.invalidResponseBody
        }
        return config
    }

    // MARK: - G (state-changing — Origin required, byte-equal)

    /// `DELETE /live-sessions/:id` (src/server.ts:354-358). 403 = the Origin
    /// guard rejected us (src/server.ts:332-339); 404 = session already gone.
    public func killSession(id: UUID) async throws {
        let (_, response) = try await perform(Endpoints.killSession(id: id))
        switch response.statusCode {
        case HTTPStatus.noContent:
            return
        case HTTPStatus.notFound:
            throw APIClientError.sessionNotFound
        case HTTPStatus.forbidden:
            throw APIClientError.forbidden
        default:
            throw APIClientError.unexpectedStatus(response.statusCode)
        }
    }

    /// `POST /hook/decision` (src/server.ts:503-525) — resolve a held remote
    /// approval with a single-use capability `token` (arrives via push payload
    /// only; NEVER persist it, plan §5.3). Server limits: body ≤ 4 KB
    /// (src/server.ts:503), ≤ 10 requests/min/IP (src/server.ts:72,504-508).
    /// 403 → `.decisionRejected` (token expired / mismatched / already used).
    public func hookDecision(sessionId: UUID, decision: HookDecision, token: String) async throws {
        let route = try Endpoints.hookDecision(
            sessionId: sessionId, decision: decision, token: token
        )
        let (_, response) = try await perform(route)
        switch response.statusCode {
        case HTTPStatus.noContent:
            return
        case HTTPStatus.forbidden:
            throw APIClientError.decisionRejected
        case HTTPStatus.tooManyRequests:
            throw APIClientError.rateLimited
        default:
            throw APIClientError.unexpectedStatus(response.statusCode)
        }
    }

    // MARK: - Internals (shared with the P1 feature files, T-iOS-38)

    func perform(_ route: APIRoute) async throws -> (Data, HTTPURLResponse) {
        guard let request = route.urlRequest(for: endpoint) else {
            throw APIClientError.invalidRequest
        }
        return try await http.send(request)
    }

    /// 200 → ok; 404 → `.sessionNotFound`; anything else → `.unexpectedStatus`.
    static func requireOK(_ response: HTTPURLResponse) throws {
        switch response.statusCode {
        case HTTPStatus.ok:
            return
        case HTTPStatus.notFound:
            throw APIClientError.sessionNotFound
        default:
            throw APIClientError.unexpectedStatus(response.statusCode)
        }
    }
}

/// Named HTTP status codes used by the client (no magic numbers, plan §4).
enum HTTPStatus {
    static let ok = 200
    static let noContent = 204
    static let badRequest = 400
    static let forbidden = 403
    static let notFound = 404
    static let tooManyRequests = 429
    static let internalServerError = 500
}
