import Foundation
import Containerization
import ContainerizationOCI

/// Rootfs management, per-container clones, seeding, and on-disk GC.
///
/// The old runtime built the sandbox image on-device with `container build`. The framework has no
/// Dockerfile builder, and provisioning in a builder VM at runtime is slow and needs the agent's
/// package registries reachable — so instead the `nucleic-sandbox` image is built in CI and pushed
/// to a registry, and the engine just **pulls + unpacks** it to an ext4 (cached per image version),
/// then **clones** that per container so each gets an isolated writable rootfs. Users install no
/// build tools; the toolchain ships inside the published image.
extension ContainerEngine {
    // MARK: - On-disk layout

    private var rootfsDir: URL { storageRoot.appendingPathComponent("rootfs", isDirectory: true) }
    private var instancesDir: URL { storageRoot.appendingPathComponent("instances", isDirectory: true) }

    /// A filesystem-safe form of an image reference (e.g. `ghcr.io/abkslm/nucleic-sandbox:v3` →
    /// `ghcr.io_abkslm_nucleic-sandbox_v3`), used as the cache filename stem.
    static func sanitizeRef(_ ref: String) -> String {
        String(ref.map { ($0 == "/" || $0 == ":") ? "_" : $0 })
    }

    /// Cache file for an unpacked image, keyed by a filesystem-safe form of the ref.
    private func rootfsCacheURL(for imageRef: String) -> URL {
        rootfsDir.appendingPathComponent(Self.sanitizeRef(imageRef) + ".ext4")
    }

    /// Per-container writable rootfs clone.
    func instanceRootfsURL(for name: String) -> URL {
        instancesDir.appendingPathComponent(name + ".ext4")
    }

    // MARK: - Per-container rootfs (clone of the cached image)

    /// Produce the writable rootfs `Mount` for a container: ensure the image's rootfs is built and
    /// cached, then return a per-container clone of it. `freshlyCloned` is false when an existing
    /// clone is reused (idle-restart / relaunch — its instrumentation is already installed, so
    /// seeding is skipped).
    func instanceRootfs(for spec: ContainerSpec) async throws
        -> (rootfs: Containerization.Mount, freshlyCloned: Bool)
    {
        try FileManager.default.createDirectory(at: instancesDir, withIntermediateDirectories: true)
        let instanceURL = instanceRootfsURL(for: spec.name)
        if FileManager.default.fileExists(atPath: instanceURL.path) {
            return (Containerization.Mount.block(format: "ext4", source: instanceURL.path, destination: "/"), false)
        }
        let cacheURL = try await ensureCachedRootfs(imageRef: spec.image)
        let base = Containerization.Mount.block(format: "ext4", source: cacheURL.path, destination: "/")
        let cloned = try base.clone(to: instanceURL.path)
        return (cloned, true)
    }

    // MARK: - Cached image rootfs

    /// Ensure the rootfs for `imageRef` is pulled and cached, returning the cache file URL. Every
    /// image — the default `nucleic-sandbox` (built in CI, pushed to a registry) and any custom
    /// bring-your-own ref alike — is simply pulled and unpacked. There is no on-device provisioning:
    /// the toolchain + Claude Code live in the published image, so users need no build tools.
    private func ensureCachedRootfs(imageRef: String) async throws -> URL {
        let cacheURL = rootfsCacheURL(for: imageRef)
        if FileManager.default.fileExists(atPath: cacheURL.path) { return cacheURL }
        try FileManager.default.createDirectory(at: rootfsDir, withIntermediateDirectories: true)

        // The dominant first-run download — surface it to the Control panel + chat for the whole
        // pull-then-unpack span (one begin/end, with the phase advanced from .image to .unpack).
        beginDownload(.image)
        defer { endDownload() }

        // Unpack into a temp file (EXT4Unpacker refuses to overwrite an existing path), then publish
        // atomically so a partial/interrupted pull never leaves a corrupt cache in place.
        let tmpURL = rootfsDir.appendingPathComponent(".pull-\(UUID().uuidString).ext4")
        try? FileManager.default.removeItem(at: tmpURL)
        do {
            let store = try sharedImageStore()
            let image: Containerization.Image
            do {
                // Pull with registry credentials when configured (private GHCR), else anonymously.
                // The progress handler streams layer byte totals into the panel's determinate bar.
                image = try await store.pull(
                    reference: imageRef, platform: .current, auth: registryAuth(for: imageRef),
                    progress: { [self] events in await advanceDownload(events, phase: .image) })
            } catch {
                throw ContainerError.imagePullFailed("\(imageRef): \(error)")
            }
            // 8 GiB ext4: room for the image contents plus the agent's working files. Unpack reports
            // no byte stream, so this stage shows as an indeterminate "Unpacking sandbox image" bar.
            setDownloadPhase(.unpack)
            let unpacker = EXT4Unpacker(blockSizeInBytes: 8 * 1024 * 1024 * 1024)
            _ = try await unpacker.unpack(image, for: .current, at: tmpURL)
            try? FileManager.default.removeItem(at: cacheURL)
            try FileManager.default.moveItem(at: tmpURL, to: cacheURL)
            return cacheURL
        } catch {
            try? FileManager.default.removeItem(at: tmpURL)
            if let ce = error as? ContainerError { throw ce }
            throw ContainerError.rootfsBuildFailed("\(imageRef): \(error)")
        }
    }

    // MARK: - Seeding (per fresh container)

    /// One-time per-container setup, run as root via `exec` (replaces the old `container exec sh -c`
    /// seed). Marks the bind-mounted repos as a git "safe directory" (the agent runs as the host
    /// uid against root-owned mounts, which trips git's dubious-ownership guard), gives the agent's
    /// uid a passwd entry, and — for Nucleic Control containers — installs the `git`/`gh` interceptor
    /// shims and the command interceptor ahead of the real binaries on PATH. Best-effort.
    func seed(_ spec: ContainerSpec, in container: LinuxContainer) async {
        var script = "git config --system --add safe.directory '*' 2>/dev/null || true"
        if let uid = spec.runAsUID {
            let gid = spec.runAsGID ?? 0
            script += "; getent passwd \(uid) >/dev/null 2>&1"
                + " || echo 'nucleic:x:\(uid):\(gid):Nucleic Agent:/tmp:/bin/bash' >> /etc/passwd 2>/dev/null || true"
        }
        if spec.installGitInterceptor {
            script += "; mkdir -p /usr/local/bin"
            script += installShimSnippet(Self.gitInterceptorSource, at: "/usr/local/bin/git")
            script += installShimSnippet(Self.ghInterceptorSource, at: "/usr/local/bin/gh")
            script += "; " + CommandInterceptor.installScript()
        }
        _ = try? await runToCompletion(container, ["sh", "-c", script])
    }

    // MARK: - Disk GC (the daemonless replacement for orphan reaping)

    /// On launch / after teardown: delete per-container rootfs clones whose name isn't in
    /// `keepNames` (dead per-session containers, plus the shared control containers — which are
    /// always rebuilt fresh), then prune superseded image caches. There are no live VMs to reap;
    /// the registry is empty in a fresh process.
    ///
    /// GC is scoped to THIS build channel: several installed builds (release + beta + local dev)
    /// share this store, each owning only its `ContainerManager.channelSuffix`-tagged clones, so a
    /// beta launch must not reap a release's per-session clones (and vice-versa).
    public func reconcileDisk(keepNames: Set<String>) async {
        if let clones = try? FileManager.default.contentsOfDirectory(
            at: instancesDir, includingPropertiesForKeys: nil)
        {
            for clone in clones where clone.pathExtension == "ext4" {
                let name = clone.deletingPathExtension().lastPathComponent
                guard ContainerManager.ownsContainer(named: name) else { continue }  // not our channel
                if !keepNames.contains(name) {
                    try? FileManager.default.removeItem(at: clone)
                }
            }
        }
        await pruneObsoleteRootfs()
    }

    /// Force a re-pull of the default image: delete its cached rootfs so the next session pulls it
    /// fresh. Mirrors the old `removeDefaultImage` used by "Force re-creation".
    public func removeDefaultRootfs() async {
        try? FileManager.default.removeItem(at: rootfsCacheURL(for: ProjectSandbox.defaultImage))
    }

    /// Remove cached rootfs files for superseded versions of the default image — the leftovers after
    /// the default image tag is bumped (e.g. an old `…nucleic-sandbox_v2` after moving to `v3`).
    /// Matches by the default ref's family (everything before its tag), so a custom bring-your-own
    /// cache is never touched. Best-effort.
    public func pruneObsoleteRootfs() async {
        let defaultRef = ProjectSandbox.defaultImage
        // The family is the ref without its tag; a ':' that sits after the last '/' is a tag
        // separator (not a registry host:port), so strip from there.
        let familyRef: String = {
            guard let colon = defaultRef.lastIndex(of: ":") else { return defaultRef }
            if let slash = defaultRef.lastIndex(of: "/"), slash > colon { return defaultRef }
            return String(defaultRef[..<colon])
        }()
        let familyPrefix = Self.sanitizeRef(familyRef) + "_"  // the sanitized tag separator
        let current = rootfsCacheURL(for: defaultRef).lastPathComponent
        guard let files = try? FileManager.default.contentsOfDirectory(
            at: rootfsDir, includingPropertiesForKeys: nil) else { return }
        for file in files where file.pathExtension == "ext4" {
            let base = file.lastPathComponent
            if base.hasPrefix(familyPrefix) && base != current {
                try? FileManager.default.removeItem(at: file)
            }
        }
    }

    // MARK: - Exec helper

    /// Run a one-shot command to completion in a container (no stdio piped) and return its exit
    /// code. Used for seeding and provisioning.
    private func runToCompletion(_ container: LinuxContainer, _ argv: [String], uid: UInt32 = 0)
        async throws -> Int32
    {
        let id = "admin-\(UUID().uuidString.prefix(8))"
        let process = try await container.exec(id) { p in
            p.arguments = argv
            p.user = ContainerizationOCI.User(uid: uid, gid: 0)
            p.terminal = false
        }
        try await process.start()
        return try await process.wait().exitCode
    }

    /// A `&& …` shell fragment that decodes a base64'd shim source to `path` and makes it
    /// executable. Centralized so the `git` and `gh` shims install identically.
    private func installShimSnippet(_ source: String, at path: String) -> String {
        let b64 = Data(source.utf8).base64EncodedString()
        return " && printf %s \(shellQuote(b64)) | base64 -d > \(path)"
            + " && chmod 0755 \(path)"
    }

    private func shellQuote(_ s: String) -> String {
        "'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'"
    }

    // The sandbox image's toolchain (git, gh, build-essential, python3, Claude Code, …) is now
    // baked into the published `nucleic-sandbox` image at build time — see
    // containers/nucleic-sandbox/Dockerfile and the CI workflow that pushes it. Nothing is
    // provisioned on-device; the engine only pulls + unpacks.

    // MARK: - In-guest interceptor shim sources

    /// Source of the `git` interceptor shim installed at `/usr/local/bin/git` (which precedes
    /// `/usr/bin` on the container's default PATH) in Nucleic Control containers. It runs the
    /// real git transparently (stdio inherited, exit status preserved), then — for mutating
    /// ops — reports the invocation to the Nucleic host via the per-session callback URL/token
    /// injected into the exec env (`NUCLEIC_GIT_HOOK_URL`, `NUCLEIC_GIT_HOOK_TOKEN`,
    /// `NUCLEIC_SESSION_ID`). Implemented in Node (always present in the image) so JSON
    /// encoding and the HTTP POST are robust; reporting is strictly best-effort.
    nonisolated static let gitInterceptorSource = #"""
        #!/usr/bin/env node
        'use strict';
        // Nucleic git interceptor. See ContainerEngine.gitInterceptorSource.
        const { spawnSync } = require('child_process');
        const http = require('http');

        const REAL_GIT = process.env.NUCLEIC_REAL_GIT || '/usr/bin/git';
        const args = process.argv.slice(2);

        const result = spawnSync(REAL_GIT, args, { stdio: 'inherit' });
        const code = (result.status != null) ? result.status : (result.signal ? 1 : 0);

        let done = false;
        function finish() { if (done) return; done = true; process.exit(code); }

        function firstSubcommand(a) {
          for (let i = 0; i < a.length; i++) {
            const t = a[i];
            if (t === '-C' || t === '-c') { i++; continue; }
            if (t.charAt(0) === '-') continue;
            return t;
          }
          return null;
        }

        // Report only mutating / state-changing ops; reads are skipped to avoid spamming the
        // host. The host re-classifies the argv authoritatively.
        const REPORTABLE = new Set([
          'merge','commit','rebase','cherry-pick','revert','reset','push','pull','fetch','clone',
          'switch','checkout','branch','tag','stash','init','am','restore','mv','rm','apply','format-patch',
        ]);

        const url = process.env.NUCLEIC_GIT_HOOK_URL;
        const token = process.env.NUCLEIC_GIT_HOOK_TOKEN;
        const sub = firstSubcommand(args);
        let target = null;
        if (url) { try { target = new URL(url); } catch (e) { target = null; } }

        if (!token || !target || !sub || !REPORTABLE.has(sub)) {
          finish();
        } else {
          const body = JSON.stringify({
            argv: args, cwd: process.cwd(), exitCode: code,
            subcommand: sub, sessionId: process.env.NUCLEIC_SESSION_ID || null,
          });
          const req = http.request({
            hostname: target.hostname,
            port: target.port,
            path: target.pathname || '/git-event',
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
              'Authorization': 'Bearer ' + token,
              'Content-Length': Buffer.byteLength(body),
            },
            timeout: 1500,
          }, function (res) { res.resume(); res.on('end', finish); res.on('error', finish); });
          req.on('error', finish);
          req.on('timeout', function () { req.destroy(); finish(); });
          req.end(body);
        }
        """#

    /// Source of the `gh` interceptor shim installed at `/usr/local/bin/gh` (ahead of `/usr/bin/gh`
    /// on PATH) in Nucleic Control containers. The structural twin of `gitInterceptorSource`: it
    /// runs real `gh` transparently (stdio inherited, exit status preserved), then — for the
    /// agentic-action nouns (`pr`, `issue`, `release`, `repo`, `api`, …) — reports the invocation to
    /// the host via the per-session callback (`NUCLEIC_GH_HOOK_URL`, `NUCLEIC_GH_HOOK_TOKEN`,
    /// `NUCLEIC_SESSION_ID`). The host re-classifies argv authoritatively (`GhCommandSummary`),
    /// dropping reads; reporting is strictly best-effort and never blocks or alters the gh call.
    /// If real `gh` is missing it exits 127 rather than masking the failure with the shim's own 0.
    nonisolated static let ghInterceptorSource = #"""
        #!/usr/bin/env node
        'use strict';
        // Nucleic gh interceptor. See ContainerEngine.ghInterceptorSource.
        const { spawnSync } = require('child_process');
        const http = require('http');

        const REAL_GH = process.env.NUCLEIC_REAL_GH || '/usr/bin/gh';
        const args = process.argv.slice(2);

        const result = spawnSync(REAL_GH, args, { stdio: 'inherit' });
        // A spawn error (e.g. real gh absent) must not be reported as success.
        const code = (result.status != null) ? result.status : (result.error ? 127 : (result.signal ? 1 : 0));

        let done = false;
        function finish() { if (done) return; done = true; process.exit(code); }

        // The first non-flag token is gh's noun (`pr`, `issue`, …); gh has no value-taking global
        // flags before it. The host decides verb-level mutating-vs-read.
        function firstNoun(a) {
          for (let i = 0; i < a.length; i++) {
            if (a[i].charAt(0) === '-') continue;
            return a[i];
          }
          return null;
        }

        // Report only the nouns that can carry an agentic action; the host (GhCommandSummary)
        // drops the read verbs (list/view/status) within them.
        const REPORTABLE = new Set([
          'pr','issue','release','repo','gist','workflow','run','secret','variable','label',
          'cache','ssh-key','gpg-key','api',
        ]);

        const url = process.env.NUCLEIC_GH_HOOK_URL;
        const token = process.env.NUCLEIC_GH_HOOK_TOKEN;
        const noun = firstNoun(args);
        let target = null;
        if (url) { try { target = new URL(url); } catch (e) { target = null; } }

        if (!token || !target || !noun || !REPORTABLE.has(noun)) {
          finish();
        } else {
          const body = JSON.stringify({
            argv: args, cwd: process.cwd(), exitCode: code,
            subcommand: noun, sessionId: process.env.NUCLEIC_SESSION_ID || null,
          });
          const req = http.request({
            hostname: target.hostname,
            port: target.port,
            path: target.pathname || '/gh-event',
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
              'Authorization': 'Bearer ' + token,
              'Content-Length': Buffer.byteLength(body),
            },
            timeout: 1500,
          }, function (res) { res.resume(); res.on('end', finish); res.on('error', finish); });
          req.on('error', finish);
          req.on('timeout', function () { req.destroy(); finish(); });
          req.end(body);
        }
        """#
}
