import Foundation
import Testing
import NucleicProtocol

#if canImport(CryptoKit)
import CryptoKit
#else
import Crypto
#endif

@testable import NucleicCore

/// The credential-mesh crypto seams (docs/COVALENCE_RUNNER.md §6, item 5): the runner landing a
/// sealed record, and the runner→device mirror-back — both exercised without touching a real
/// Keychain by driving the vault against a temp HOME and known keypairs. Serialized: the
/// env-backed landing/removal tests mutate the process environment (`setenv`/`unsetenv`), so
/// parallel execution races their assertions against each other.
@Suite("Credential mesh", .serialized)
struct CredentialMeshTests {
    private func tempHome() -> URL {
        let dir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
            .appendingPathComponent("nucleic-credmesh-\(UUID().uuidString)", isDirectory: true)
        try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
        return dir
    }

    /// A device seals a credential to the runner's key; the runner lands it 0600 at the CLI path.
    @Test func runnerLandsASealedCredential() throws {
        let home = tempHome()
        defer { try? FileManager.default.removeItem(at: home) }
        let runnerKey = Curve25519.KeyAgreement.PrivateKey()
        let vault = RunnerCredentialVault(home: home, sealingKeyBytes: runnerKey.rawRepresentation)

        let secret = Data(#"{"claudeAiOauth":{"accessToken":"a","refreshToken":"r","expiresAt":9}}"#.utf8)
        let stub = SealedCredentialRecord(
            kind: .claudeOAuth, updatedAt: Date(),
            box: SealedCredentialBox(ephemeralPublicKey: Data(), ciphertext: Data()))
        let box = try SealedCredentialBox.seal(
            secret, to: runnerKey.publicKey.rawRepresentation, additionalData: stub.additionalData)
        let record = SealedCredentialRecord(kind: .claudeOAuth, updatedAt: Date(), box: box)

        let path = try vault.land(record)
        let landed = try Data(contentsOf: URL(fileURLWithPath: path))
        #expect(landed == secret)
        // 0600.
        let perms = try FileManager.default.attributesOfItem(atPath: path)[.posixPermissions] as? NSNumber
        #expect(perms?.int16Value == 0o600)
        // The kind is now held, so it drops out of the missing set.
        #expect(!vault.missingKinds().contains(.claudeOAuth))
    }

    /// The mirror-back: the runner seals its current credential to a *device's* key; the device
    /// opens it. Round-trips the plaintext and binds the kind as AAD (item 5, the
    /// syncClaudeLoginBack analogue).
    @Test func runnerMirrorsBackToADeviceKey() throws {
        let home = tempHome()
        defer { try? FileManager.default.removeItem(at: home) }
        let runnerKey = Curve25519.KeyAgreement.PrivateKey()
        let vault = RunnerCredentialVault(home: home, sealingKeyBytes: runnerKey.rawRepresentation)

        // Seed a landed claude credential (as if the CLI just refreshed it).
        let refreshed = Data(#"{"claudeAiOauth":{"accessToken":"new","refreshToken":"r2","expiresAt":99}}"#.utf8)
        let claudeDir = home.appendingPathComponent(".claude", isDirectory: true)
        try FileManager.default.createDirectory(at: claudeDir, withIntermediateDirectories: true)
        try refreshed.write(to: claudeDir.appendingPathComponent(".credentials.json"))

        let deviceKey = Curve25519.KeyAgreement.PrivateKey()
        let envelope = vault.mirrorBackEnvelope(
            for: [.claudeOAuth], toSealingKey: deviceKey.publicKey.rawRepresentation)
        #expect(envelope != nil)
        let record = try #require(envelope?.records.first)
        let opened = try record.box.open(with: deviceKey, additionalData: record.additionalData)
        #expect(opened == refreshed)

        // A box sealed for this device must not open as a different kind (AAD binding).
        #expect(throws: SealedCredentialBox.SealedCredentialError.decryptFailed) {
            _ = try record.box.open(
                with: deviceKey, additionalData: Data(CredentialKind.githubToken.rawValue.utf8))
        }
    }

    /// Every kind the provider advertises has a runner landing row, and vice versa — the two
    /// tables stay in lockstep (a new kind needs both, or it half-works).
    @Test func providerAndVaultKindsAgree() {
        let vaultKinds = Set(RunnerCredentialVault.supportedKinds)
        let providerKinds = Set(CredentialProvider.allKinds)
        #expect(vaultKinds == providerKinds)
        // The rotating logins mirror back; static keys / the git bundle don't.
        #expect(Set(RunnerCredentialVault.mirrorableKinds) == [.claudeOAuth, .codexAuth])
        // The git bundle is store-backed, so it has no file `Landing` row.
        #expect(!RunnerCredentialVault.landings.map(\.kind).contains(.gitCredentialBundle))
    }

    /// The Git-identity bundle survives a full JSON round-trip (the sealed payload shape) with every
    /// field — mode, secrets, and flags — intact.
    @Test func gitBundleEncodesAndDecodes() throws {
        let bundle = GitCredentialBundle(
            authMode: "managed", token: "ghp_x", sshPrivateKey: nil, sshPassphrase: nil,
            managedPrivateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nk\n-----END OPENSSH PRIVATE KEY-----",
            managedPublicKey: "ssh-ed25519 AAAA nucleic-managed", signCommits: false,
            ghForceSSH: true, gitForceSSH: true, availableInSandbox: false,
            sshConfig: "Host github.com\n  Port 443", commitName: "Nucleic", commitEmail: "n@x.dev")
        #expect(try GitCredentialBundle.decode(bundle.encoded()) == bundle)
    }

    /// A device with no usable Git credential (mode `.none` / empty slots) offers nothing to forward.
    /// Runs in the test process's own defaults domain, so it reflects an unconfigured device.
    @Test func gitBundleCaptureNilWhenInactive() {
        // The test binary has no Managed Git configured, so capture is nil (nothing to forward).
        #expect(GitCredentialBundle.capture() == nil)
    }

    /// A fresh runner (no active Git identity yet) lists the bundle in what it asks devices for, so
    /// the clone-first dispatch can seed it before it ever tries a private clone.
    @Test func freshRunnerAsksForGitBundle() {
        let home = tempHome()
        defer { try? FileManager.default.removeItem(at: home) }
        let runnerKey = Curve25519.KeyAgreement.PrivateKey()
        let vault = RunnerCredentialVault(home: home, sealingKeyBytes: runnerKey.rawRepresentation)
        #expect(vault.missingKinds().contains(.gitCredentialBundle))
    }

    #if !canImport(Security)
    /// A landed github token is adopted as the Managed Git credential (token mode) when nothing
    /// else is configured — closing the createProject-before-gitCredentialBundle race — and a
    /// mesh-wide token deletion tears the adopted config back down. Linux-only: adoption writes
    /// the real credential store, which on a Mac is the developer's own Keychain.
    @Test func landedGitHubTokenAdoptedAndRevoked() throws {
        let home = tempHome()
        let runnerKey = Curve25519.KeyAgreement.PrivateKey()
        let vault = RunnerCredentialVault(
            home: home, sealingKeyBytes: runnerKey.rawRepresentation,
            tombstones: tempTombstoneStore())
        defer {
            try? FileManager.default.removeItem(at: home)
            GitHubCredentialStore.deleteToken()
            UserDefaults.standard.removeObject(forKey: GitHubCredentialSettings.authModeKey)
            unsetenv("GITHUB_TOKEN")
        }
        #expect(!GitHubCredentialConfig.current().isActive)  // the test domain starts unconfigured

        // Land a sealed github token (writes the 0600 file + exports GITHUB_TOKEN).
        let token = Data("ghp_adopted".utf8)
        let stub = SealedCredentialRecord(
            kind: .githubToken, updatedAt: Date(),
            box: SealedCredentialBox(ephemeralPublicKey: Data(), ciphertext: Data()))
        let box = try SealedCredentialBox.seal(
            token, to: runnerKey.publicKey.rawRepresentation, additionalData: stub.additionalData)
        _ = try vault.land(SealedCredentialRecord(kind: .githubToken, updatedAt: Date(), box: box))

        #expect(vault.adoptGitHubTokenFallback())
        #expect(GitHubCredentialSettings.authMode == .token)
        #expect(GitHubCredentialStore.loadToken() == "ghp_adopted")
        #expect(GitHubCredentialConfig.current().isActive)
        // A second adopt is a no-op: the config is already active.
        #expect(!vault.adoptGitHubTokenFallback())

        // Mesh-wide deletion: the adopted token-mode config is torn down with the file.
        #expect(vault.remove(.githubToken))
        #expect(GitHubCredentialSettings.authMode == .none)
        #expect(GitHubCredentialStore.loadToken() == nil)
    }
    #endif

    /// A newly added kind (Codex auth) lands to its CLI path and mirrors back like Claude.
    @Test func codexAuthLandsAndMirrors() throws {
        let home = tempHome()
        defer { try? FileManager.default.removeItem(at: home) }
        let runnerKey = Curve25519.KeyAgreement.PrivateKey()
        let vault = RunnerCredentialVault(home: home, sealingKeyBytes: runnerKey.rawRepresentation)

        let auth = Data(#"{"tokens":{"refresh_token":"r"},"last_refresh":"2026-07-09T10:56:59Z"}"#.utf8)
        let stub = SealedCredentialRecord(
            kind: .codexAuth, updatedAt: Date(),
            box: SealedCredentialBox(ephemeralPublicKey: Data(), ciphertext: Data()))
        let box = try SealedCredentialBox.seal(
            auth, to: runnerKey.publicKey.rawRepresentation, additionalData: stub.additionalData)
        let path = try vault.land(SealedCredentialRecord(kind: .codexAuth, updatedAt: Date(), box: box))
        #expect(path.hasSuffix(".codex/auth.json"))
        #expect(try Data(contentsOf: URL(fileURLWithPath: path)) == auth)
        // Codex is mirrorable, so a device key can receive the rotation.
        let deviceKey = Curve25519.KeyAgreement.PrivateKey()
        let env = vault.mirrorBackEnvelope(for: [.codexAuth], toSealingKey: deviceKey.publicKey.rawRepresentation)
        let record = try #require(env?.records.first)
        #expect(try record.box.open(with: deviceKey, additionalData: record.additionalData) == auth)
    }

    /// An env-backed key (xAI) lands to its file AND exports its env var for spawned agents.
    @Test func apiKeyLandsToFileAndEnv() throws {
        let home = tempHome()
        defer {
            try? FileManager.default.removeItem(at: home)
            unsetenv("XAI_API_KEY")
        }
        let runnerKey = Curve25519.KeyAgreement.PrivateKey()
        let vault = RunnerCredentialVault(home: home, sealingKeyBytes: runnerKey.rawRepresentation)
        let stub = SealedCredentialRecord(
            kind: .xaiAPIKey, updatedAt: Date(),
            box: SealedCredentialBox(ephemeralPublicKey: Data(), ciphertext: Data()))
        let box = try SealedCredentialBox.seal(
            Data("xai-key-123".utf8), to: runnerKey.publicKey.rawRepresentation,
            additionalData: stub.additionalData)
        _ = try vault.land(SealedCredentialRecord(kind: .xaiAPIKey, updatedAt: Date(), box: box))
        #expect(ProcessInfo.processInfo.environment["XAI_API_KEY"] == "xai-key-123")
    }

    // MARK: Mesh-wide credential deletion (tombstones)

    /// A tombstone store scoped to a throwaway defaults suite, so tests never touch the real
    /// domain and never see each other's stones.
    private func tempTombstoneStore() -> CredentialTombstoneStore {
        CredentialTombstoneStore(suiteName: "nucleic-test-tombstones-\(UUID().uuidString)")
    }

    /// The store merges `deletedAt`-monotonic: a fresh stone lands (and persists), a replay
    /// and a stale copy are no-ops — that no-op is what terminates the gossip wave.
    @Test func tombstoneStoreMergesMonotonically() {
        let store = tempTombstoneStore()
        let early = CredentialTombstone(
            kind: .claudeOAuth, deletedAt: Date(timeIntervalSince1970: 1_000),
            originDeviceID: "dev-a")
        #expect(store.record([early]) == [early])
        #expect(store.record([early]).isEmpty)  // replay: no new information
        let stale = CredentialTombstone(
            kind: .claudeOAuth, deletedAt: Date(timeIntervalSince1970: 500),
            originDeviceID: "dev-b")
        #expect(store.record([stale]).isEmpty)  // older: no new information
        let bumped = CredentialTombstone(
            kind: .claudeOAuth, deletedAt: Date(timeIntervalSince1970: 2_000),
            originDeviceID: "dev-b")
        #expect(store.record([bumped]) == [bumped])  // a bump supersedes
        #expect(store.all() == [bumped])
        #expect(store.suppresses(kind: .claudeOAuth, updatedAt: Date(timeIntervalSince1970: 2_000)))
        #expect(!store.suppresses(kind: .claudeOAuth, updatedAt: Date(timeIntervalSince1970: 2_001)))
        #expect(!store.suppresses(kind: .codexAuth, updatedAt: Date(timeIntervalSince1970: 0)))
    }

    /// The runner's landing side of a deletion: the landed file is removed and the exported
    /// env var unset, so spawned agents stop inheriting the deleted secret.
    @Test func vaultRemoveDeletesFileAndEnv() throws {
        let home = tempHome()
        defer {
            try? FileManager.default.removeItem(at: home)
            unsetenv("XAI_API_KEY")
        }
        let runnerKey = Curve25519.KeyAgreement.PrivateKey()
        let vault = RunnerCredentialVault(home: home, sealingKeyBytes: runnerKey.rawRepresentation)
        let stub = SealedCredentialRecord(
            kind: .xaiAPIKey, updatedAt: Date(),
            box: SealedCredentialBox(ephemeralPublicKey: Data(), ciphertext: Data()))
        let box = try SealedCredentialBox.seal(
            Data("xai-key-123".utf8), to: runnerKey.publicKey.rawRepresentation,
            additionalData: stub.additionalData)
        let path = try vault.land(SealedCredentialRecord(kind: .xaiAPIKey, updatedAt: Date(), box: box))
        #expect(FileManager.default.fileExists(atPath: path))

        #expect(vault.remove(.xaiAPIKey))
        #expect(!FileManager.default.fileExists(atPath: path))
        #expect(ProcessInfo.processInfo.environment["XAI_API_KEY"] == nil)
        // The kind is missing again, so the runner re-asks connecting devices for it.
        #expect(vault.missingKinds().contains(.xaiAPIKey))
        // Idempotent: removing an absent kind reports nothing removed.
        #expect(!vault.remove(.xaiAPIKey))
    }

    /// The resurrection gate: a stale holder re-provisioning a deleted revision is refused,
    /// while a strictly newer login (post-deletion re-auth) still lands.
    @Test func vaultRefusesRecordsATombstoneSuppresses() throws {
        let home = tempHome()
        defer { try? FileManager.default.removeItem(at: home) }
        let runnerKey = Curve25519.KeyAgreement.PrivateKey()
        let store = tempTombstoneStore()
        let vault = RunnerCredentialVault(
            home: home, sealingKeyBytes: runnerKey.rawRepresentation, tombstones: store)

        let deletedAt = Date(timeIntervalSince1970: 1_700_000_000)
        store.record([CredentialTombstone(
            kind: .claudeOAuth, deletedAt: deletedAt, originDeviceID: "dev-a")])

        func sealedRecord(updatedAt: Date) throws -> SealedCredentialRecord {
            let stub = SealedCredentialRecord(
                kind: .claudeOAuth, updatedAt: updatedAt,
                box: SealedCredentialBox(ephemeralPublicKey: Data(), ciphertext: Data()))
            let box = try SealedCredentialBox.seal(
                Data(#"{"claudeAiOauth":{"accessToken":"a","refreshToken":"r"}}"#.utf8),
                to: runnerKey.publicKey.rawRepresentation, additionalData: stub.additionalData)
            return SealedCredentialRecord(kind: .claudeOAuth, updatedAt: updatedAt, box: box)
        }

        // The stale revision (at the deletion instant) is refused…
        #expect(throws: RunnerCredentialVault.SuppressedByTombstone.self) {
            _ = try vault.land(try sealedRecord(updatedAt: deletedAt))
        }
        // …a genuinely newer login lands.
        let fresh = try sealedRecord(updatedAt: deletedAt.addingTimeInterval(60))
        #expect(try vault.land(fresh).hasSuffix(".claude/.credentials.json"))
    }

    /// The suppression clock is the credential's OWN stamp, parsed from the payload: Claude's
    /// `expiresAt` (ms) and Codex's `last_refresh` (s). A mirror-back's wire stamp is just
    /// "now", so judging suppression on it would let a bumped Claude tombstone (future-dated)
    /// wrongly swallow a genuine post-deletion re-login.
    @Test func intrinsicStampParsesTheCredentialClock() {
        let claude = Data(#"{"claudeAiOauth":{"accessToken":"a","refreshToken":"r","expiresAt":2000000}}"#.utf8)
        #expect(CredentialProvider.intrinsicStamp(of: .claudeOAuth, claude)
            == Date(timeIntervalSince1970: 2_000))
        let codex = Data(#"{"tokens":{"refresh_token":"r"},"last_refresh":"2026-07-09T10:00:00Z"}"#.utf8)
        #expect(CredentialProvider.intrinsicStamp(of: .codexAuth, codex) != nil)
        // Stampless kinds (API keys) and junk fall back to the wire stamp (nil here).
        #expect(CredentialProvider.intrinsicStamp(of: .anthropicAPIKey, Data("sk-x".utf8)) == nil)
        #expect(CredentialProvider.intrinsicStamp(of: .claudeOAuth, Data("junk".utf8)) == nil)
    }

    /// The change-gate digest: identical bytes give one digest, a rotation gives another.
    @Test func mirrorDigestTracksRotation() throws {
        let home = tempHome()
        defer { try? FileManager.default.removeItem(at: home) }
        let vault = RunnerCredentialVault(
            home: home, sealingKeyBytes: Curve25519.KeyAgreement.PrivateKey().rawRepresentation)
        #expect(vault.digest(for: .claudeOAuth) == nil)  // nothing landed yet

        let claudeDir = home.appendingPathComponent(".claude", isDirectory: true)
        try FileManager.default.createDirectory(at: claudeDir, withIntermediateDirectories: true)
        let file = claudeDir.appendingPathComponent(".credentials.json")
        try Data("v1".utf8).write(to: file)
        let d1 = vault.digest(for: .claudeOAuth)
        try Data("v2".utf8).write(to: file)
        let d2 = vault.digest(for: .claudeOAuth)
        #expect(d1 != nil && d2 != nil && d1 != d2)
    }
}
