import Foundation

/// Fetches a **single-blob OCI artifact** (one raw file pushed with `oras push … file:mediaType`)
/// from a registry to a local path, speaking the distribution API directly: exchange credentials for
/// a pull-scoped bearer token, fetch the manifest, then download its single layer blob. Used for the
/// prebuilt guest artifacts the Mac host can't build itself — the sandbox kernel
/// (`ContainerEngine.downloadKernel`) and the Linux VM kernel/rootfs
/// (`MacVMEngine.resolveLinuxArtifacts`). A token is **optional**: a public package pulls
/// anonymously (GHCR hands back an anonymous read token), which is the point of publishing these as
/// public packages while the repo stays private.
enum OCIArtifact {
    /// Errors from a single-blob artifact pull. `CustomStringConvertible` so callers can wrap the
    /// message into their own domain error (`ContainerError` / `MacVMError`) without losing detail.
    enum FetchError: Error, CustomStringConvertible {
        case authFailed(ref: String, hadToken: Bool)
        case manifestMissing(ref: String, status: Int)
        case noLayer(ref: String)
        case blobFailed(ref: String, status: Int)

        var description: String {
            switch self {
            case let .authFailed(ref, hadToken):
                return hadToken
                    ? "could not authenticate to \(ref) — check the token's `read:packages` scope"
                    : "\(ref) appears to be a private package and no registry token is configured "
                        + "(make the package public, or add a GitHub token with `read:packages`)"
            case let .manifestMissing(ref, status): return "manifest for \(ref) not found (HTTP \(status))"
            case let .noLayer(ref): return "\(ref) has no layer blob to download"
            case let .blobFailed(ref, status): return "blob download for \(ref) failed (HTTP \(status))"
            }
        }
    }

    /// Resolve the **content digest** of `imageRef`'s single layer blob without downloading it — the
    /// stable identity of what a (mutable) tag currently points at. Callers cache the digest alongside
    /// a downloaded blob so a republished `:latest` / `:26.07` invalidates the cache instead of being
    /// silently reused forever (docs/LINUX_VM.md §artifact cache).
    static func resolveLayerDigest(imageRef: String, explicitToken: String? = nil) async throws -> String {
        try await resolve(imageRef: imageRef, explicitToken: explicitToken).digest
    }

    /// Download the single layer blob of `imageRef` (`host/repo[:tag]`) to `dest`. Resolves a pull
    /// token from `explicitToken`, else `NUCLEIC_REGISTRY_TOKEN`, else the app's GitHub token, else
    /// anonymous. Overwrites `dest` atomically. Does not set execute permissions — that's the
    /// caller's call. Returns the layer's content digest (the cache key callers pin against).
    @discardableResult
    static func fetchSingleBlob(imageRef: String, to dest: URL, explicitToken: String? = nil) async throws
        -> String
    {
        let r = try await resolve(imageRef: imageRef, explicitToken: explicitToken)

        // Download the layer blob (URLSession follows the redirect to blob storage).
        let blobURL = URL(string: "https://\(r.host)/v2/\(r.repo)/blobs/\(r.digest)")!
        var req = URLRequest(url: blobURL)
        req.setValue("Bearer \(r.bearer)", forHTTPHeaderField: "Authorization")
        req.setValue("application/octet-stream", forHTTPHeaderField: "Accept")
        let (tmpFile, dlResp) = try await URLSession.shared.download(for: req)
        guard (dlResp as? HTTPURLResponse)?.statusCode == 200 else {
            throw FetchError.blobFailed(
                ref: "\(r.repo):\(r.tag)", status: (dlResp as? HTTPURLResponse)?.statusCode ?? -1)
        }
        try? FileManager.default.removeItem(at: dest)
        try FileManager.default.moveItem(at: tmpFile, to: dest)
        return r.digest
    }

    /// What a pull needs after authenticating and walking the manifest: the bearer to reuse for the
    /// blob GET, the parsed ref, and the single layer's digest.
    private struct Resolved {
        let host: String, repo: String, tag: String, bearer: String, digest: String
    }

    /// Authenticate and resolve `imageRef`'s single layer digest. Shared by ``resolveLayerDigest``
    /// (digest only) and ``fetchSingleBlob`` (digest + blob), so both speak one code path.
    private static func resolve(imageRef: String, explicitToken: String?) async throws -> Resolved {
        let (host, repo, tag) = try ContainerEngine.parseImageRef(imageRef)
        let env = ProcessInfo.processInfo.environment
        let token: String? = explicitToken.flatMap { $0.isEmpty ? nil : $0 }
            ?? env["NUCLEIC_REGISTRY_TOKEN"].flatMap { $0.isEmpty ? nil : $0 }
            ?? GitHubCredentialStore.loadToken().flatMap { $0.isEmpty ? nil : $0 }
        let owner = repo.split(separator: "/").first.map(String.init)
        let username = env["NUCLEIC_REGISTRY_USER"].flatMap { $0.isEmpty ? nil : $0 }
            ?? ContainerServiceSettings.registryUsername
            ?? owner
            ?? "x-access-token"

        // 1. Exchange credentials for a pull-scoped bearer token (anonymous when none supplied).
        var tokenReq = URLRequest(url: URL(string:
            "https://\(host)/token?service=\(host)&scope=repository:\(repo):pull")!)
        if let token {
            let basic = Data("\(username):\(token)".utf8).base64EncodedString()
            tokenReq.setValue("Basic \(basic)", forHTTPHeaderField: "Authorization")
        }
        let (tokData, tokResp) = try await URLSession.shared.data(for: tokenReq)
        guard (tokResp as? HTTPURLResponse)?.statusCode == 200,
            let tokJSON = try? JSONSerialization.jsonObject(with: tokData) as? [String: Any],
            let bearer = (tokJSON["token"] ?? tokJSON["access_token"]) as? String
        else {
            throw FetchError.authFailed(ref: "\(host)/\(repo)", hadToken: token != nil)
        }

        func authed(_ url: URL, accept: String) -> URLRequest {
            var r = URLRequest(url: url)
            r.setValue("Bearer \(bearer)", forHTTPHeaderField: "Authorization")
            r.setValue(accept, forHTTPHeaderField: "Accept")
            return r
        }

        // 2. Fetch the manifest and resolve the single layer's digest. ORAS pushes a plain image
        //    manifest for a one-file artifact; tolerate a multi-platform index by following its first
        //    entry.
        let manifestAccept = "application/vnd.oci.image.manifest.v1+json,"
            + "application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.v2+json"
        func fetchManifest(_ ref: String) async throws -> [String: Any] {
            let url = URL(string: "https://\(host)/v2/\(repo)/manifests/\(ref)")!
            let (data, resp) = try await URLSession.shared.data(for: authed(url, accept: manifestAccept))
            guard (resp as? HTTPURLResponse)?.statusCode == 200,
                let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
            else {
                throw FetchError.manifestMissing(
                    ref: "\(repo):\(ref)", status: (resp as? HTTPURLResponse)?.statusCode ?? -1)
            }
            return json
        }
        var manifest = try await fetchManifest(tag)
        if let entries = manifest["manifests"] as? [[String: Any]],
            let first = entries.first?["digest"] as? String
        {
            manifest = try await fetchManifest(first)
        }
        guard let layers = manifest["layers"] as? [[String: Any]],
            let digest = layers.first?["digest"] as? String
        else {
            throw FetchError.noLayer(ref: "\(repo):\(tag)")
        }
        return Resolved(host: host, repo: repo, tag: tag, bearer: bearer, digest: digest)
    }
}
