import Foundation

/// One HTTP call against the backend. Feature code builds one of these and
/// hands it to `APIClient.shared.send(...)` — nobody outside this file talks
/// to `URLSession` directly, so auth headers and error decoding stay in
/// exactly one place.
struct APIRequest {
    enum Method: String { case get = "GET", post = "POST", patch = "PATCH", delete = "DELETE" }

    var path: String
    var method: Method = .get
    var query: [URLQueryItem] = []
    var body: Data?
    var contentType: String = "application/json"
    /// False only for the couple of endpoints callable while signed out
    /// (login, signup, forgot-password). Everything else sends the Bearer
    /// token automatically.
    var requiresAuth: Bool = true

    static func json<Body: Encodable>(
        path: String,
        method: Method,
        body: Body,
        requiresAuth: Bool = true
    ) throws -> APIRequest {
        let encoder = JSONEncoder()
        encoder.dateEncodingStrategy = .iso8601
        return APIRequest(
            path: path,
            method: method,
            body: try encoder.encode(body),
            requiresAuth: requiresAuth
        )
    }

    /// Builds a single-file `multipart/form-data` body — used by
    /// `POST /api/scan`, which reads the file from a `file` form field (see
    /// `formData.get("file")` in src/app/api/scan/route.ts). Only one file
    /// field is needed anywhere in this app; if that changes, generalise this
    /// rather than hand-rolling multipart bodies elsewhere.
    static func multipartFile(
        path: String,
        fieldName: String = "file",
        fileName: String,
        mimeType: String,
        fileData: Data
    ) -> APIRequest {
        let boundary = "ScanReceipts-\(UUID().uuidString)"
        var body = Data()
        body.append("--\(boundary)\r\n".utf8)
        body.append("Content-Disposition: form-data; name=\"\(fieldName)\"; filename=\"\(fileName)\"\r\n".utf8)
        body.append("Content-Type: \(mimeType)\r\n\r\n".utf8)
        body.append(fileData)
        body.append("\r\n--\(boundary)--\r\n".utf8)

        return APIRequest(
            path: path,
            method: .post,
            body: body,
            contentType: "multipart/form-data; boundary=\(boundary)",
            requiresAuth: true
        )
    }
}

/// Posted whenever a request comes back 401. `AppState` observes this and
/// signs the user out locally — the server-side session is already gone by
/// the time this fires, so there is nothing left to clean up remotely.
extension Notification.Name {
    static let sessionExpired = Notification.Name("ScanReceipts.sessionExpired")
}

final class APIClient {
    static let shared = APIClient()

    private let session: URLSession
    private let baseURL: URL
    private let tokenStore: KeychainTokenStore

    init(
        baseURL: URL = APIEnvironment.current.baseURL,
        session: URLSession = .shared,
        tokenStore: KeychainTokenStore = .shared
    ) {
        self.baseURL = baseURL
        self.session = session
        self.tokenStore = tokenStore
    }

    /// Decodes a JSON response body as `T`.
    @discardableResult
    func send<T: Decodable>(_ request: APIRequest, as type: T.Type = T.self) async throws -> T {
        let data = try await sendRaw(request)
        do {
            let decoder = JSONDecoder()
            decoder.dateDecodingStrategy = .iso8601
            return try decoder.decode(T.self, from: data)
        } catch {
            throw APIError.decoding(error)
        }
    }

    /// For endpoints that return a raw file, not JSON — the three
    /// `/api/export/*` routes, which respond with the file bytes directly and
    /// the filename in `Content-Disposition` (see ExportAPI.swift). Still
    /// validates the status and decodes `{ error }` JSON on failure exactly
    /// like `send`.
    func sendForFile(_ request: APIRequest) async throws -> (data: Data, suggestedFileName: String?) {
        let (data, response) = try await sendRawWithResponse(request)
        let fileName = response.value(forHTTPHeaderField: "Content-Disposition")
            .flatMap { header -> String? in
                guard let range = header.range(of: "filename=\"") else { return nil }
                let rest = header[range.upperBound...]
                guard let end = rest.firstIndex(of: "\"") else { return nil }
                return String(rest[rest.startIndex..<end])
            }
        return (data, fileName)
    }

    /// For endpoints whose success body carries nothing the caller needs
    /// (e.g. `DELETE /api/receipts`) — still validates the status code and
    /// decodes `{ error }` on failure.
    func sendDiscardingResponse(_ request: APIRequest) async throws {
        _ = try await sendRaw(request)
    }

    private func sendRaw(_ apiRequest: APIRequest) async throws -> Data {
        try await sendRawWithResponse(apiRequest).data
    }

    private func sendRawWithResponse(_ apiRequest: APIRequest) async throws -> (data: Data, response: HTTPURLResponse) {
        var components = URLComponents(url: baseURL.appendingPathComponent(apiRequest.path), resolvingAgainstBaseURL: false)
        if !apiRequest.query.isEmpty {
            components?.queryItems = apiRequest.query
        }
        guard let url = components?.url else {
            throw APIError.unexpectedStatus(-1)
        }

        var urlRequest = URLRequest(url: url)
        urlRequest.httpMethod = apiRequest.method.rawValue
        // Identifies this as a native client so the backend (a) returns the
        // raw session token in the login/signup JSON body instead of only a
        // Set-Cookie, and (b) exempts the request from the browser-only CSRF
        // double-submit check. See src/lib/auth/config.ts#isMobileClientRequest
        // and src/lib/auth/csrf.ts#isCsrfExempt on the backend.
        urlRequest.setValue("ios", forHTTPHeaderField: "X-Client")

        if let body = apiRequest.body {
            urlRequest.httpBody = body
            urlRequest.setValue(apiRequest.contentType, forHTTPHeaderField: "Content-Type")
        }

        if apiRequest.requiresAuth {
            guard let token = tokenStore.token else {
                throw APIError.unauthorized
            }
            urlRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        }

        let data: Data
        let response: URLResponse
        do {
            (data, response) = try await session.data(for: urlRequest)
        } catch {
            throw APIError.network(error)
        }

        guard let http = response as? HTTPURLResponse else {
            throw APIError.unexpectedStatus(-1)
        }

        if (200..<300).contains(http.statusCode) {
            return (data, http)
        }

        if http.statusCode == 401 {
            tokenStore.token = nil
            NotificationCenter.default.post(name: .sessionExpired, object: nil)
            throw APIError.unauthorized
        }

        if let body = try? JSONDecoder().decode(APIErrorBody.self, from: data) {
            throw APIError.server(code: body.error, status: http.statusCode, retryAfterSeconds: body.retryAfter)
        }

        throw APIError.unexpectedStatus(http.statusCode)
    }
}
