import Foundation
import NucleicProtocol

#if canImport(CryptoKit)
import CryptoKit
#else
import Crypto
#endif
/// The runner half of the credential mesh (docs/COVALENCE_RUNNER.md §6, §0.2 item 5): the
/// credential-**sealing** keypair a runner generates at provisioning (distinct from its Noise
/// identity, so credentials can be re-sealed across identity rotation), the
/// `credentialNeeded` inventory, and the landing of provisioned records as 0600 files exactly
/// where the local sandbox seeding puts them — so the existing per-turn seeding (and the CLIs
/// themselves) pick them up unchanged.
///
/// Landing map (`Landing` table below — the single source of truth):
///   • file-native rotating logins the CLI reads directly: `claude-oauth` →
///     `~/.claude/.credentials.json`, `codex-auth` → `~/.codex/auth.json`.
///   • the file-native Grok config: `grok-config` → `~/.grok/config.toml`.
///   • env-backed secrets — the GitHub token + the Anthropic/OpenAI/xAI API keys — land to a
///     0600 nucleic-owned file AND export their env var (`GITHUB_TOKEN`, `ANTHROPIC_API_KEY`,
///     `OPENAI_API_KEY`, `XAI_API_KEY`), re-exported at boot so spawned agents inherit them.
///
/// The device side (`CredentialProvider`) is the counterpart — it captures these from a Mac's
/// Keychain/stores and seals them here.
public struct RunnerCredentialVault: Sendable {
    /// Landing root — the daemon's `$HOME` (agents and CLIs resolve their dot-dirs from it).
    public let home: URL
    /// Raw X25519 private key bytes (kept as `Data` — `Curve25519.*.PrivateKey` isn't
    /// `Sendable` on Linux, the DeviceIdentity gotcha from §11.2).
    private let sealingKeyBytes: Data
    /// Mesh-wide deletions this runner knows: `land` refuses any record a recorded tombstone
    /// suppresses (a stale holder re-provisioning a deleted kind), and `remove` is the landing
    /// side of a deletion. Injectable so tests stay off the real defaults domain.
    let tombstones: CredentialTombstoneStore

    private static let sealingAccount = "com.nucleic.runner.credential-sealing"

    /// The daemon's effective `$HOME` — the environment first (what spawned CLIs resolve their
    /// dot-dirs from; on Linux `homeDirectoryForCurrentUser` reads passwd and IGNORES an
    /// overridden `HOME`, which would land credentials where the CLIs never look).
    public static func processHome() -> URL {
        if let home = ProcessInfo.processInfo.environment["HOME"], !home.isEmpty {
            return URL(fileURLWithPath: home, isDirectory: true)
        }
        return FileManager.default.homeDirectoryForCurrentUser
    }

    /// Load the persisted sealing key or mint one (first boot). The private key never leaves
    /// this machine's secret store (Keychain on a self-hosted Mac, the 0600-file store on
    /// Linux); only the public half rides `HostMsg.credentialNeeded`.
    public static func loadOrCreate(
        home: URL = RunnerCredentialVault.processHome()
    ) -> RunnerCredentialVault {
        if let data = SealingKeyStore.load(), data.count == 32 {
            return RunnerCredentialVault(home: home, sealingKeyBytes: data)
        }
        let fresh = Curve25519.KeyAgreement.PrivateKey().rawRepresentation
        SealingKeyStore.save(fresh)
        return RunnerCredentialVault(home: home, sealingKeyBytes: fresh)
    }

    init(
        home: URL, sealingKeyBytes: Data,
        tombstones: CredentialTombstoneStore = CredentialTombstoneStore()
    ) {
        self.home = home
        self.sealingKeyBytes = sealingKeyBytes
        self.tombstones = tombstones
    }

    /// The public key devices seal credentials to (rides in `credentialNeeded`).
    public var sealingPublicKey: Data {
        guard let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: sealingKeyBytes)
        else { return Data() }
        return key.publicKey.rawRepresentation
    }

    // MARK: - Landing map

    /// Where one credential kind lands on the runner and how it reaches spawned agents. The
    /// table is the single source of truth for the inventory, landing, and mirror-back — add a
    /// row (and a `CredentialProvider` source) to support a new kind; nothing else changes.
    struct Landing {
        let kind: CredentialKind
        /// Path under the daemon's HOME (dot-dir where the CLI reads it, or a nucleic-owned file
        /// for env-only secrets).
        let relativePath: String
        /// The env var to export so spawned agents inherit it (API keys / the GitHub token);
        /// nil for file-native credentials the CLI reads directly.
        let envVar: String?
        /// Whether the runner's session CLI *refreshes* this in place, so a rotation is mirrored
        /// back to devices. True for the two rotating OAuth logins; false for static keys.
        let mirrorable: Bool
    }

    /// The kinds the runner knows how to land. Claude/Codex are file-native rotating logins;
    /// the GitHub token + the three API keys land to a nucleic-owned file AND an env var (the
    /// value spawned agents read); Grok is a file-native config.
    static let landings: [Landing] = [
        Landing(kind: .claudeOAuth, relativePath: ".claude/.credentials.json", envVar: nil, mirrorable: true),
        Landing(kind: .codexAuth, relativePath: ".codex/auth.json", envVar: nil, mirrorable: true),
        Landing(kind: .grokConfig, relativePath: ".grok/config.toml", envVar: nil, mirrorable: false),
        Landing(kind: .githubToken, relativePath: ".nucleic/github-token", envVar: "GITHUB_TOKEN", mirrorable: false),
        Landing(kind: .anthropicAPIKey, relativePath: ".nucleic/anthropic-api-key", envVar: "ANTHROPIC_API_KEY", mirrorable: false),
        Landing(kind: .openAIAPIKey, relativePath: ".nucleic/openai-api-key", envVar: "OPENAI_API_KEY", mirrorable: false),
        Landing(kind: .xaiAPIKey, relativePath: ".nucleic/xai-api-key", envVar: "XAI_API_KEY", mirrorable: false),
    ]

    /// Every kind the runner can ingest — the file-native `landings` plus the store-backed Git
    /// identity bundle (which lands into `UserDefaults`/`GitHubCredentialStore`, not a file, so it
    /// has no `Landing` row). Kept in lockstep with `CredentialProvider.allKinds`.
    public static let supportedKinds: [CredentialKind] = landings.map(\.kind) + [.gitCredentialBundle]

    private func landing(for kind: CredentialKind) -> Landing? {
        Self.landings.first { $0.kind == kind }
    }
    private func path(for kind: CredentialKind) -> URL? {
        landing(for: kind).map { home.appendingPathComponent($0.relativePath) }
    }

    /// The kinds this runner is missing — what `credentialNeeded` asks connected devices for.
    /// A device only seals the subset it actually holds, so asking for the whole missing set is
    /// safe (over-asking costs nothing).
    public func missingKinds() -> [CredentialKind] {
        let fm = FileManager.default
        var missing: [CredentialKind] = Self.landings.compactMap { landing in
            if let env = landing.envVar, ProcessInfo.processInfo.environment[env] != nil { return nil }
            let path = home.appendingPathComponent(landing.relativePath).path
            return fm.fileExists(atPath: path) ? nil : landing.kind
        }
        // The Git identity is store-backed, not a file: it's "missing" until an applied bundle makes
        // `GitHubCredentialConfig.current()` active, so a fresh runner asks a holding device for it.
        if !GitHubCredentialConfig.current().isActive {
            missing.append(.gitCredentialBundle)
        }
        return missing
    }

    // MARK: - Landing

    public struct UnsupportedKind: Error, CustomStringConvertible {
        public let description: String
    }

    /// The record's kind was deleted mesh-wide and this revision doesn't post-date the
    /// deletion — refused so a stale holder can't resurrect it.
    public struct SuppressedByTombstone: Error, CustomStringConvertible {
        public let description: String
    }

    /// Open one sealed record (the kind string is the AAD — a box can't be replayed as a
    /// different kind) and land the plaintext at its path, 0600 under a 0700 parent, exporting
    /// its env var when it has one. Returns the landed path for logging.
    @discardableResult
    public func land(_ record: SealedCredentialRecord) throws -> String {
        guard !tombstones.suppresses(kind: record.kind, updatedAt: record.updatedAt) else {
            throw SuppressedByTombstone(
                description: "credential kind \(record.kind.rawValue) was deleted mesh-wide; "
                    + "revision \(record.updatedAt) does not post-date the deletion")
        }
        let key = try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: sealingKeyBytes)
        let plaintext = try record.box.open(with: key, additionalData: record.additionalData)

        // The Git identity bundle lands into the runner's credential store + defaults (not a file),
        // so `GitHubCredentialConfig.current()` resolves it and every git op behaves like the Mac's.
        if record.kind == .gitCredentialBundle {
            let bundle = try GitCredentialBundle.decode(plaintext)
            bundle.apply()
            return "git credential bundle (mode \(bundle.authMode))"
        }

        guard let landing = landing(for: record.kind) else {
            throw UnsupportedKind(description: "no landing path for credential kind \(record.kind.rawValue)")
        }
        let destination = home.appendingPathComponent(landing.relativePath)

        try SecretFile.createDirectory(at: destination.deletingLastPathComponent())
        try SecretFile.write(plaintext, to: destination)

        if let env = landing.envVar, let value = Self.envValue(from: plaintext) {
            setenv(env, value, 1)
        }
        return destination.path
    }

    /// The landing side of a mesh-wide deletion: remove the landed file for `kind` and unset
    /// its env var so spawned agents stop inheriting the deleted secret. Returns whether a
    /// file was actually removed (for logging). The store-owned Git identity has no `Landing`
    /// row and isn't deletable here.
    @discardableResult
    public func remove(_ kind: CredentialKind) -> Bool {
        guard let landing = landing(for: kind) else { return false }
        if let env = landing.envVar { unsetenv(env) }
        // A deleted github token also tears down the token-mode Managed Git config it was
        // adopted into (`adoptGitHubTokenFallback`) — matched on the stored value, so a token
        // the user configured independently of the landed file is left alone.
        if kind == .githubToken, GitHubCredentialSettings.authMode == .token,
           let data = currentPlaintext(for: .githubToken),
           let token = Self.envValue(from: data), GitHubCredentialStore.loadToken() == token {
            GitHubCredentialStore.deleteToken()
            UserDefaults.standard.removeObject(forKey: GitHubCredentialSettings.authModeKey)
        }
        let destination = home.appendingPathComponent(landing.relativePath)
        guard FileManager.default.fileExists(atPath: destination.path) else { return false }
        return (try? FileManager.default.removeItem(at: destination)) != nil
    }

    /// The env-var string for an env-backed credential: the trimmed contents (a token / key is a
    /// single line). Nil when blank.
    private static func envValue(from plaintext: Data) -> String? {
        guard let s = String(data: plaintext, encoding: .utf8)?
            .trimmingCharacters(in: .whitespacesAndNewlines), !s.isEmpty else { return nil }
        return s
    }

    /// Boot-time re-export: env-backed credentials landed on a previous boot live in files; put
    /// each back into the daemon's environment so spawned agents inherit it (the value's still on
    /// disk from the last boot, but the env var is fresh each process).
    public func exportPersistedTokens() {
        for landing in Self.landings {
            guard let env = landing.envVar,
                  ProcessInfo.processInfo.environment[env] == nil,
                  let data = try? Data(contentsOf: home.appendingPathComponent(landing.relativePath)),
                  let value = Self.envValue(from: data)
            else { continue }
            setenv(env, value, 1)
        }
    }

    /// Adopt a landed `github-token` as the runner's Managed Git credential (token mode) when no
    /// git credential is configured — so a remote `createProject` clone, and every later
    /// push/PR, can authenticate even before (or without) the device's `gitCredentialBundle`.
    /// Writes the same stores `GitCredentialBundle.apply()` does, so
    /// `GitHubCredentialConfig.current()` resolves it and every provisioning path picks it up
    /// unchanged; a bundle that lands later overwrites it wholesale (its apply is total), so the
    /// fallback never shadows the user's real choice. No-op when a credential is already active
    /// or no token has landed. Returns whether it adopted (for logging).
    @discardableResult
    public func adoptGitHubTokenFallback() -> Bool {
        guard !GitHubCredentialConfig.current().isActive,
              let data = currentPlaintext(for: .githubToken),
              let token = Self.envValue(from: data)
        else { return false }
        UserDefaults.standard.set(
            GitHubAuthMode.token.rawValue, forKey: GitHubCredentialSettings.authModeKey)
        GitHubCredentialStore.saveToken(token)
        return true
    }

    // MARK: - Mirror-back (runner → device, the syncClaudeLoginBack analogue)

    /// The kinds whose in-place refresh the runner mirrors back to devices — the rotating OAuth
    /// logins (Claude ~daily, Codex on its own cadence). Static keys and file configs don't
    /// rotate, so a change there flows the other way (device→runner). Derived from the table.
    public static let mirrorableKinds: [CredentialKind] =
        landings.filter(\.mirrorable).map(\.kind)

    /// The current on-disk plaintext for a kind the runner holds — what a session's CLI may have
    /// *refreshed* in place during a turn. Nil when absent.
    public func currentPlaintext(for kind: CredentialKind) -> Data? {
        guard let path = path(for: kind) else { return nil }
        return try? Data(contentsOf: path)
    }

    /// A content digest of the credential the runner holds for `kind` — the change signal that
    /// gates a mirror-back push (only rotated credentials propagate, so a device's Keychain isn't
    /// re-written every sweep). Nil when the runner holds none.
    public func digest(for kind: CredentialKind) -> String? {
        guard let data = currentPlaintext(for: kind) else { return nil }
        return SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
    }

    /// Seal the runner's current credentials for `kinds` to a device's sealing key — the
    /// `credentialUpdate` envelope mirrored back after a refresh. Kind string is the AAD.
    /// Nil when nothing is held for any requested kind.
    public func mirrorBackEnvelope(
        for kinds: [CredentialKind], toSealingKey deviceKey: Data
    ) -> SealedCredentialEnvelope? {
        var records: [SealedCredentialRecord] = []
        for kind in kinds {
            guard let plaintext = currentPlaintext(for: kind) else { continue }
            let updatedAt = Date()
            let stub = SealedCredentialRecord(
                kind: kind, updatedAt: updatedAt,
                box: SealedCredentialBox(ephemeralPublicKey: Data(), ciphertext: Data()))
            guard let box = try? SealedCredentialBox.seal(
                plaintext, to: deviceKey, additionalData: stub.additionalData) else { continue }
            records.append(SealedCredentialRecord(kind: kind, updatedAt: updatedAt, box: box))
        }
        return records.isEmpty ? nil : SealedCredentialEnvelope(records: records)
    }
}

/// Sealing-key persistence: Keychain on Darwin (a self-hosted Mac runner), the 0600-file
/// secret store on Linux (the runner container).
private enum SealingKeyStore {
    private static let account = "com.nucleic.runner.credential-sealing"

    static func load() -> Data? {
        SecretStores.default.get(service: nil, account: account)
    }

    static func save(_ data: Data) {
        SecretStores.default.set(service: nil, account: account, data: data)
    }
}
