import Foundation
import Testing
@testable import ClientTLS

// C-iOS · DeviceEnrollmentClient request-building + response-mapping, driven by a
// stub transport (no network). Mirrors the A4 contract exactly.

private let baseURL = URL(string: "https://cp.terminal.yaojia.wang")!
private let bearer = "device-enroll-token-abc"

/// Records the last request and replays a canned response. `@unchecked Sendable`:
/// the mutable capture is guarded by a lock.
private final class StubTransport: EnrollmentTransport, @unchecked Sendable {
    private let lock = NSLock()
    private var _lastRequest: URLRequest?
    private let status: Int
    private let body: Data

    init(status: Int, body: Data) {
        self.status = status
        self.body = body
    }

    var lastRequest: URLRequest? { lock.withLock { _lastRequest } }

    func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
        lock.withLock { _lastRequest = request }
        let response = HTTPURLResponse(
            url: request.url!, statusCode: status, httpVersion: "HTTP/1.1", headerFields: nil
        )!
        return (body, response)
    }
}

private func enrollBody(
    deviceId: String = "dev-1",
    cert: Data = Data([0x30, 0x01, 0x02]),
    caChain: [Data] = [Data([0x30, 0xAA])],
    notBefore: String = "2026-07-08T00:00:00.000Z",
    notAfter: String = "2026-10-06T00:00:00.000Z",
    renewAfter: String = "2026-09-05T00:00:00.000Z"
) -> Data {
    let json: [String: Any] = [
        "deviceId": deviceId,
        "cert": cert.base64EncodedString(),
        "caChain": caChain.map { $0.base64EncodedString() },
        "notBefore": notBefore,
        "notAfter": notAfter,
        "renewAfter": renewAfter,
    ]
    return try! JSONSerialization.data(withJSONObject: json)
}

@Test("enroll builds a bearer-authenticated POST /device/enroll with the A4 body")
func enrollBuildsRequest() async throws {
    // Arrange
    let stub = StubTransport(status: 201, body: enrollBody())
    let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub)
    let csr = Data([0xDE, 0xAD, 0xBE, 0xEF])

    // Act
    _ = try await client.enroll(csrDER: csr, subdomain: "alice", deviceName: "Alice iPhone")

    // Assert
    let request = try #require(stub.lastRequest)
    #expect(request.httpMethod == "POST")
    #expect(request.url?.absoluteString == "https://cp.terminal.yaojia.wang/device/enroll")
    #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer \(bearer)")
    #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")

    let sent = try #require(request.httpBody)
    let object = try #require(
        try JSONSerialization.jsonObject(with: sent) as? [String: Any]
    )
    #expect(object["csr"] as? String == csr.base64EncodedString())
    #expect(object["keyAlg"] as? String == "ec-p256")
    #expect(object["subdomain"] as? String == "alice")
    #expect(object["deviceName"] as? String == "Alice iPhone")
    #expect(object["attestation"] == nil) // omitted when not provided
}

@Test("enroll maps a 201 response into a typed EnrollmentResult")
func enrollMapsResponse() async throws {
    // Arrange
    let cert = Data([0x30, 0x82, 0x01, 0x23])
    let ca = Data([0x30, 0x82, 0x02, 0x00])
    let stub = StubTransport(status: 201, body: enrollBody(deviceId: "dev-xyz", cert: cert, caChain: [ca]))
    let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub)

    // Act
    let result = try await client.enroll(csrDER: Data([0x01]), subdomain: "alice", deviceName: "iPhone")

    // Assert
    #expect(result.deviceId == "dev-xyz")
    #expect(result.certificate == cert)
    #expect(result.caChain == [ca])
    #expect(result.notAfter != nil)
    #expect(result.renewAfter != nil)
    // renewAfter (2026-09-05) is before notAfter (2026-10-06).
    #expect(result.renewAfter! < result.notAfter!)
}

@Test("isRenewalDue flips at renewAfter")
func renewalDueSeam() async throws {
    let stub = StubTransport(status: 201, body: enrollBody())
    let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub)
    let result = try await client.enroll(csrDER: Data([0x01]), subdomain: "a", deviceName: "d")

    let before = ISO8601DateFormatter().date(from: "2026-09-04T00:00:00Z")!
    let after = ISO8601DateFormatter().date(from: "2026-09-06T00:00:00Z")!
    #expect(result.isRenewalDue(asOf: before) == false)
    #expect(result.isRenewalDue(asOf: after) == true)
}

@Test("attestation passphrase is forwarded when provided")
func enrollForwardsAttestation() async throws {
    let stub = StubTransport(status: 201, body: enrollBody())
    let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub)
    _ = try await client.enroll(
        csrDER: Data([0x01]), subdomain: "a", deviceName: "d", attestation: "attest-blob"
    )
    let object = try JSONSerialization.jsonObject(
        with: stub.lastRequest!.httpBody!
    ) as! [String: Any]
    #expect(object["attestation"] as? String == "attest-blob")
}

@Test("a non-201 response throws http with the server error code")
func enrollRejectSurfacesStatus() async throws {
    // 403 subdomain-not-owned → { error: "rejected" }.
    let body = try! JSONSerialization.data(withJSONObject: ["error": "rejected"])
    let stub = StubTransport(status: 403, body: body)
    let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub)

    await #expect(throws: DeviceEnrollmentError.http(status: 403, code: "rejected")) {
        _ = try await client.enroll(csrDER: Data([0x01]), subdomain: "bob", deviceName: "d")
    }
}

@Test("a malformed 201 body throws malformedResponse")
func enrollMalformedBody() async throws {
    let stub = StubTransport(status: 201, body: Data("not json".utf8))
    let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub)
    await #expect(throws: DeviceEnrollmentError.malformedResponse) {
        _ = try await client.enroll(csrDER: Data([0x01]), subdomain: "a", deviceName: "d")
    }
}

@Test("attestChallenge maps a 200 challenge response")
func attestChallengeMaps() async throws {
    let body = try! JSONSerialization.data(
        withJSONObject: ["challenge": "Y2hhbGxlbmdl", "expires_in": 120]
    )
    let stub = StubTransport(status: 200, body: body)
    let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub)

    let challenge = try await client.attestChallenge()
    #expect(challenge.challenge == "Y2hhbGxlbmdl")
    #expect(challenge.expiresIn == 120)
    #expect(stub.lastRequest?.url?.absoluteString
        == "https://cp.terminal.yaojia.wang/device/attest/challenge")
}

@Test("renew targets POST /device/:id/renew (A6 seam)")
func renewTargetsRenewEndpoint() async throws {
    let stub = StubTransport(status: 201, body: enrollBody())
    let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub)
    _ = try await client.renew(deviceId: "dev-9", csrDER: Data([0x02]))
    #expect(stub.lastRequest?.url?.absoluteString
        == "https://cp.terminal.yaojia.wang/device/dev-9/renew")
}

@Test("renew body is {csr}-ONLY (server's strict schema rejects any extra field)")
func renewSendsCsrOnlyBody() async throws {
    // The control-plane /device/:id/renew schema authenticates by the presented
    // mTLS cert and accepts a body of exactly { csr } — a stray `keyAlg` (or any
    // non-csr field) trips its strict validation and 400s every silent renewal.
    let stub = StubTransport(status: 201, body: enrollBody())
    let client = DeviceEnrollmentClient(baseURL: baseURL, transport: stub)
    let csr = Data([0x0A, 0x0B, 0x0C])

    _ = try await client.renew(deviceId: "dev-9", csrDER: csr)

    let request = try #require(stub.lastRequest)
    let sent = try #require(request.httpBody)
    let object = try #require(try JSONSerialization.jsonObject(with: sent) as? [String: Any])
    #expect(object["csr"] as? String == csr.base64EncodedString())
    #expect(object["keyAlg"] == nil) // NOT sent — the enroll-only field must be dropped
    #expect(object.keys.sorted() == ["csr"]) // exactly one field
}

@Test("renew with no bearer sends NO Authorization header (mTLS-authenticated)")
func renewMTLSOmitsBearer() async throws {
    // /device/:id/renew authenticates by the CURRENT device client cert (mTLS),
    // NOT a bearer — the silent rotation scheduler has no fresh bearer weeks
    // later, so it constructs the client with a nil bearer and relies on the
    // transport presenting the installed identity. The Authorization header
    // must therefore be ABSENT (a stale/empty "Bearer " would be wrong).
    let stub = StubTransport(status: 201, body: enrollBody())
    let client = DeviceEnrollmentClient(baseURL: baseURL, transport: stub) // bearer defaults to nil
    _ = try await client.renew(deviceId: "dev-9", csrDER: Data([0x02]))
    let request = try #require(stub.lastRequest)
    #expect(request.value(forHTTPHeaderField: "Authorization") == nil)
    // Content-Type is still set — it's a JSON POST regardless of auth mechanism.
    #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")
}

@Test("enroll still sets the bearer when one is supplied (unchanged contract)")
func enrollKeepsBearerWhenSupplied() async throws {
    let stub = StubTransport(status: 201, body: enrollBody())
    let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub)
    _ = try await client.enroll(csrDER: Data([0x01]), subdomain: "a", deviceName: "d")
    #expect(stub.lastRequest?.value(forHTTPHeaderField: "Authorization") == "Bearer \(bearer)")
}
