import Foundation
import WireProtocol

/// HTTP method whitelist for the frozen endpoints (plan §3.4 + T-iOS-38 P1 增量).
enum HTTPMethod: String, Sendable {
    case get = "GET"
    case post = "POST"
    case put = "PUT"
    case delete = "DELETE"
}

/// Whether a route mutates server state — THE security split (plan §3.4/§5.1):
/// `Origin` is stamped **iff** `.guarded`.
enum OriginPolicy: Sendable, Equatable {
    /// Read-only GET — MUST NOT carry Origin. If the server ever reclassifies
    /// a RO route as guarded, integration tests go red instead of passing by
    /// coincidence (§3.4 铁律).
    case readOnly
    /// State-changing — MUST carry `Origin: endpoint.originHeader`, byte-equal;
    /// the server rejects missing/foreign Origin with 403 (src/server.ts:332-339).
    case guarded
}

/// Header/content-type names used by the builder (no magic strings inline).
enum HeaderName {
    static let origin = "Origin"
    static let contentType = "Content-Type"
}

enum ContentTypeValue {
    static let json = "application/json"
}

/// One buildable API route — an immutable snapshot; building never mutates.
struct APIRoute: Sendable, Equatable {
    let method: HTTPMethod
    let path: String
    let originPolicy: OriginPolicy
    let body: Data?
    /// Pre-encoded query string (`key=<strictly-percent-encoded value>`), or
    /// nil for no query. Percent-encoding happens ONCE, in the route builder
    /// (T-iOS-38: `/projects/detail?path=`) — never at call sites.
    let percentEncodedQuery: String?

    init(
        method: HTTPMethod,
        path: String,
        originPolicy: OriginPolicy,
        body: Data?,
        percentEncodedQuery: String? = nil
    ) {
        self.method = method
        self.path = path
        self.originPolicy = originPolicy
        self.body = body
        self.percentEncodedQuery = percentEncodedQuery
    }

    /// Build the `URLRequest` against `endpoint.baseURL`'s scheme/host/port:
    /// the path is REPLACED, the query is REPLACED by `percentEncodedQuery`
    /// (dropped when nil), fragment/credentials are dropped — the same
    /// derivation philosophy as `HostEndpoint.wsURL`. Origin stamping
    /// happens HERE and only here (single point; hand-stamping elsewhere is a
    /// review CRITICAL, plan §5.1).
    func urlRequest(for endpoint: HostEndpoint) -> URLRequest? {
        guard var components = URLComponents(
            url: endpoint.baseURL, resolvingAgainstBaseURL: true
        ) else { return nil }
        components.path = path
        components.query = nil
        components.fragment = nil
        components.user = nil
        components.password = nil
        if let percentEncodedQuery {
            components.percentEncodedQuery = percentEncodedQuery
        }
        guard let url = components.url else { return nil }

        var request = URLRequest(url: url)
        request.httpMethod = method.rawValue
        if originPolicy == .guarded {
            request.setValue(endpoint.originHeader, forHTTPHeaderField: HeaderName.origin)
        }
        if let body {
            request.httpBody = body
            request.setValue(ContentTypeValue.json, forHTTPHeaderField: HeaderName.contentType)
        }
        return request
    }
}

/// Builders for the frozen endpoints (plan §3.4 + T-iOS-38 P1 增量). Route
/// table (verified against src/server.ts):
/// - RO — `GET /live-sessions` (:257) · `GET /live-sessions/:id/preview` (:314)
///        · `GET /live-sessions/:id/events` (:528) · `GET /config/ui` (:609)
///        · `GET /projects` (:262) · `GET /projects/detail?path=` (:293)
///        · `GET /prefs` (:273)
/// - G  — `DELETE /live-sessions/:id` (:354) · `POST /hook/decision` (:503)
///        · `PUT /prefs` (:278) · `POST|DELETE /push/apns-token` (frozen T-iOS-20
///          shape, mirrors `/push/subscribe` :461-498)
/// P1 builders live beside their feature models: `ApnsToken.swift`,
/// `Projects.swift`, `Prefs.swift` (T-iOS-38 single owner).
enum Endpoints {
    /// Strict RFC 3986 unreserved set — everything else gets percent-encoded.
    /// Deliberately stricter than `.urlQueryAllowed`: a bare `+` in a query is
    /// decoded as a SPACE by Express's qs parser, and `&`/`=` would split the
    /// parameter — strict encoding removes the whole ambiguity class.
    static let unreservedCharacters = CharacterSet(
        charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
    )
    static func liveSessions() -> APIRoute {
        APIRoute(method: .get, path: "/live-sessions", originPolicy: .readOnly, body: nil)
    }

    static func preview(id: UUID) -> APIRoute {
        APIRoute(
            method: .get, path: "/live-sessions/\(pathId(id))/preview",
            originPolicy: .readOnly, body: nil
        )
    }

    static func events(id: UUID) -> APIRoute {
        APIRoute(
            method: .get, path: "/live-sessions/\(pathId(id))/events",
            originPolicy: .readOnly, body: nil
        )
    }

    static func uiConfig() -> APIRoute {
        APIRoute(method: .get, path: "/config/ui", originPolicy: .readOnly, body: nil)
    }

    static func killSession(id: UUID) -> APIRoute {
        APIRoute(
            method: .delete, path: "/live-sessions/\(pathId(id))",
            originPolicy: .guarded, body: nil
        )
    }

    /// Body is exactly `{sessionId,decision,token}`. Server-enforced limits
    /// (they are the server's, not ours — documented for callers):
    /// body ≤ 4 KB (src/server.ts:503) and ≤ 10 requests/min/IP
    /// (src/server.ts:72,504-508).
    static func hookDecision(
        sessionId: UUID, decision: HookDecision, token: String
    ) throws -> APIRoute {
        let body = try JSONEncoder().encode(HookDecisionBody(
            sessionId: pathId(sessionId), decision: decision.rawValue, token: token
        ))
        return APIRoute(
            method: .post, path: "/hook/decision", originPolicy: .guarded, body: body
        )
    }

    /// Server session ids are lowercase `crypto.randomUUID()` strings and
    /// `:id` route params are matched as EXACT strings — always serialize
    /// lowercase (same rule as `MessageCodec`'s attach encoding).
    private static func pathId(_ id: UUID) -> String {
        id.uuidString.lowercased()
    }

    private struct HookDecisionBody: Encodable {
        let sessionId: String
        let decision: String
        let token: String
    }
}
