import Foundation

/// C-iOS · Talks to the control-plane device-enrollment API (A4):
///
///   `POST /device/enroll`            [Bearer device:enroll]
///     body { csr, keyAlg:'ec-p256', subdomain, deviceName, attestation? }
///     → 201 { deviceId, cert, caChain, notBefore, notAfter, renewAfter }
///
///   `POST /device/attest/challenge`  [Bearer device:enroll] → { challenge, expires_in }
///   `POST /device/:id/renew`         [Bearer device:enroll] (A6 seam)
///
/// Deliberately logic-free about TLS: it only builds requests and maps responses.
/// The `EnrollmentTransport` seam (same `send(_:)` shape as the app's
/// `HTTPTransport`) lets the App inject `URLSessionHTTPTransport` in production
/// and a stub in tests. The `csr` is sent as standard base64(DER), which the
/// server's `decodeCsrWire` accepts directly.
public struct DeviceEnrollmentClient: Sendable {
    private let baseURL: URL
    /// The one-time account `device:enroll` bearer obtained at login. `nil` on
    /// the renew path, which authenticates by the CURRENT device client cert
    /// (mTLS) — the silent rotation scheduler has no fresh bearer weeks later, so
    /// it constructs the client with `nil` and relies on the transport presenting
    /// the installed identity. When `nil`, no `Authorization` header is sent.
    private let bearerToken: String?
    private let transport: any EnrollmentTransport

    public init(baseURL: URL, bearerToken: String? = nil, transport: any EnrollmentTransport) {
        self.baseURL = baseURL
        self.bearerToken = bearerToken
        self.transport = transport
    }

    /// Enroll a freshly-generated hardware key: POST the CSR, receive the leaf.
    public func enroll(
        csrDER: Data, subdomain: String, deviceName: String, attestation: String? = nil
    ) async throws -> EnrollmentResult {
        var body: [String: String] = [
            "csr": csrDER.base64EncodedString(),
            "keyAlg": "ec-p256",
            "subdomain": subdomain,
            "deviceName": deviceName,
        ]
        if let attestation { body["attestation"] = attestation }
        let request = try makeJSONRequest(path: "/device/enroll", jsonObject: body)
        return try await sendExpectingLeaf(request)
    }

    /// Renew against the SAME hardware key (A6 seam): a fresh CSR to
    /// `/device/:id/renew`. The endpoint authenticates by the presented mTLS cert
    /// and its schema is strict — the body is `{ csr }` ONLY (NOT the enroll body).
    /// A stray `keyAlg` (or any non-`csr` field) trips server validation and 400s
    /// every silent renewal, so it is deliberately absent here.
    public func renew(deviceId: String, csrDER: Data) async throws -> EnrollmentResult {
        let body = ["csr": csrDER.base64EncodedString()]
        let path = "/device/\(deviceId)/renew"
        let request = try makeJSONRequest(path: path, jsonObject: body)
        return try await sendExpectingLeaf(request)
    }

    /// Fetch a short-TTL attestation challenge (stub server-side; shapes the
    /// keygen→attest→CSR flow so App Attest can layer in later — build order C·4).
    public func attestChallenge() async throws -> AttestChallenge {
        let request = try makeJSONRequest(path: "/device/attest/challenge", jsonObject: [:])
        let (data, response) = try await transport.send(request)
        guard response.statusCode == 200 else {
            throw DeviceEnrollmentError.http(status: response.statusCode, code: errorCode(in: data))
        }
        guard let dto = try? JSONDecoder().decode(AttestChallengeDTO.self, from: data) else {
            throw DeviceEnrollmentError.malformedResponse
        }
        return AttestChallenge(challenge: dto.challenge, expiresIn: dto.expires_in)
    }

    // MARK: - Request/response plumbing

    private func makeJSONRequest(path: String, jsonObject: [String: String]) throws -> URLRequest {
        guard let url = URL(string: path, relativeTo: baseURL) else {
            throw DeviceEnrollmentError.malformedResponse
        }
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        // Bearer only when present (enroll). The renew path passes nil and
        // authenticates via the presented client certificate (mTLS).
        if let bearerToken {
            request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization")
        }
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = try JSONSerialization.data(withJSONObject: jsonObject)
        return request
    }

    private func sendExpectingLeaf(_ request: URLRequest) async throws -> EnrollmentResult {
        let (data, response) = try await transport.send(request)
        guard response.statusCode == 201 else {
            throw DeviceEnrollmentError.http(status: response.statusCode, code: errorCode(in: data))
        }
        guard let dto = try? JSONDecoder().decode(EnrollResponseDTO.self, from: data) else {
            throw DeviceEnrollmentError.malformedResponse
        }
        return try dto.toResult()
    }

    private func errorCode(in data: Data) -> String? {
        (try? JSONDecoder().decode(ErrorDTO.self, from: data))?.error
    }
}

// MARK: - Transport seam

/// The one exchange the enrollment client needs — identical in shape to the
/// app's `HTTPTransport.send`, so the production `URLSessionHTTPTransport` slots
/// in via a one-line adapter and tests inject a stub. Kept local so `ClientTLS`
/// stays a leaf package (no dependency on the WireProtocol transport contract).
public protocol EnrollmentTransport: Sendable {
    func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse)
}

// MARK: - Results & errors

public struct EnrollmentResult: Equatable, Sendable {
    public let deviceId: String
    /// Leaf certificate DER (decoded from the response's base64).
    public let certificate: Data
    /// Issuer chain DERs (device-CA etc.), leaf excluded.
    public let caChain: [Data]
    public let notBefore: Date?
    public let notAfter: Date?
    /// When to renew from the same hardware key (~2/3 of the lifetime).
    public let renewAfter: Date?

    public init(
        deviceId: String, certificate: Data, caChain: [Data],
        notBefore: Date?, notAfter: Date?, renewAfter: Date?
    ) {
        self.deviceId = deviceId
        self.certificate = certificate
        self.caChain = caChain
        self.notBefore = notBefore
        self.notAfter = notAfter
        self.renewAfter = renewAfter
    }

    /// The rotation seam: is the leaf due for renewal as of `now`?
    /// A missing `renewAfter` never triggers (fail-safe — the TLS stack is the
    /// real gate; the scheduler only pre-empts expiry).
    public func isRenewalDue(asOf now: Date = Date()) -> Bool {
        guard let renewAfter else { return false }
        return now >= renewAfter
    }
}

public struct AttestChallenge: Equatable, Sendable {
    public let challenge: String
    public let expiresIn: Int
}

public enum DeviceEnrollmentError: Error, Equatable, Sendable {
    /// Non-success HTTP status with the server's uniform `{ error }` code, if any
    /// (401 missing/rejected token, 403 subdomain-not-owned, 429 rate_limited,
    /// 400 rejected CSR/subdomain).
    case http(status: Int, code: String?)
    /// 2xx body that did not decode to the expected shape.
    case malformedResponse
}

// MARK: - Wire DTOs (base64 + ISO-8601 strings, mapped to typed values)

private struct EnrollResponseDTO: Decodable {
    let deviceId: String
    let cert: String
    let caChain: [String]
    let notBefore: String?
    let notAfter: String?
    let renewAfter: String?

    func toResult() throws -> EnrollmentResult {
        guard let certificate = Data(base64Encoded: cert) else {
            throw DeviceEnrollmentError.malformedResponse
        }
        let chain = try caChain.map { entry -> Data in
            guard let der = Data(base64Encoded: entry) else {
                throw DeviceEnrollmentError.malformedResponse
            }
            return der
        }
        return EnrollmentResult(
            deviceId: deviceId,
            certificate: certificate,
            caChain: chain,
            notBefore: ISO8601.date(notBefore),
            notAfter: ISO8601.date(notAfter),
            renewAfter: ISO8601.date(renewAfter)
        )
    }
}

private struct AttestChallengeDTO: Decodable {
    let challenge: String
    let expires_in: Int // swiftlint:disable:this identifier_name — wire field name
}

private struct ErrorDTO: Decodable {
    let error: String
}

/// The server emits `Date.toISOString()` (fractional-second UTC). Parse with and
/// without fractional seconds so both `...T00:00:00.000Z` and `...T00:00:00Z`
/// decode; an unparseable/absent value degrades to `nil` (dates are advisory).
private enum ISO8601 {
    static func date(_ text: String?) -> Date? {
        guard let text else { return nil }
        // Formatters are created per call: ISO8601DateFormatter is not Sendable,
        // so it cannot be a shared static under Swift 6 strict concurrency. Date
        // parsing here is rare (once per enroll/renew), so the cost is immaterial.
        let withFractional = ISO8601DateFormatter()
        withFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
        if let date = withFractional.date(from: text) { return date }
        return ISO8601DateFormatter().date(from: text)
    }
}
