import Foundation

/// Recovers the user's *login-shell* `PATH` so a GUI-launched app spawns CLIs the same way a
/// Terminal launch would.
///
/// **The bug this fixes.** A macOS app launched from Finder/Dock/Spotlight inherits launchd's
/// minimal environment, whose `PATH` is just `/usr/bin:/bin:/usr/sbin:/sbin`. That omits every
/// place the agent CLIs actually install: the Claude Code native installer drops `claude` in
/// `~/.claude/local`, Homebrew uses `/opt/homebrew/bin` (Apple silicon) or `/usr/local/bin`,
/// nvm/Volta/pnpm put node tools under the home dir, etc. A GUI launch never sources the shell
/// rc files (`~/.zprofile`, `~/.zshrc`, …) that add those dirs. So every host spawn that resolves
/// a bare name through `/usr/bin/env <name>` — a session's `claude`/`codex`/`grok`, and the
/// `ProviderAvailability` "installed" probe — fails with `env: claude: No such file or directory`
/// (exit 127), and Settings shows the provider "Not installed", even though a Terminal launch of
/// the very same app works. (Nucleic-control sessions are immune: they run `claude` inside a
/// container with a known PATH. That's why the failure is specific to non-control, host-spawned
/// runs, and only "sometimes" — it depends on how the app was launched and where `claude` sits.)
///
/// **The fix.** Ask the user's login shell to print its `PATH` (`$SHELL -ilc 'printf $PATH'`),
/// exactly as the `host_exec` and Build panels already shell out through `zsh -lc`. The login +
/// interactive shell sources the same rc files a Terminal session would, so its `PATH` includes
/// everything the user can run from a prompt. We merge that in front of the inherited PATH (plus a
/// curated safety-net of well-known install dirs) at the single host-spawn chokepoint
/// (``ChildProcess``), so the augmentation is independent of how `ProcessInfo`'s environment is
/// cached and covers every host backend and probe uniformly.
///
/// **On Windows** the premise is different and the mechanics change with it (docs/WINDOWS_PORT.md
/// §4.3). A Windows GUI app inherits the *full* user + machine environment from Explorer, so there
/// is no minimal-PATH bug to fix and no login shell to probe. What survives is the narrower
/// problem this type also solves: an installer that appends to `Path` writes the **registry**, and
/// already-running processes keep their stale copy until relaunch — so a `git` installed after
/// `nucleic-hostd` started would be invisible. The Windows leg therefore reads
/// `HKCU\Environment` / `HKLM\…\Session Manager\Environment` instead of running a shell, and the
/// PATH list separator is `;`, not `:` (splitting a Windows PATH on `:` would shred every
/// `C:\…` entry into `C` plus a path).
enum LoginShellPATH {
    /// The platform's PATH list separator.
    static let separator: Character = {
        #if os(Windows)
        return ";"
        #else
        return ":"
        #endif
    }()

    /// Result of running the login shell: its exit status and captured stdout.
    typealias ShellResult = (status: Int32, stdout: String)
    /// Injectable shell runner (real one by default; tests substitute a stub).
    typealias ShellRunner = (_ shellPath: String, _ args: [String]) -> ShellResult?

    /// Sentinels fencing the PATH value off from any banner/noise an rc file prints to stdout.
    private static let begin = "___NUCLEIC_LOGIN_PATH_BEGIN___"
    private static let end = "___NUCLEIC_LOGIN_PATH_END___"

    // MARK: - Public surface (host spawn)

    /// The login-shell PATH, resolved once per process and memoized. `nil` if the resolution shell
    /// couldn't be run or printed nothing usable (we then fall back to the inherited PATH plus the
    /// well-known dirs in ``augment(_:)``).
    static let cached: String? = resolve()

    /// Return `environment` with `PATH` widened to include the login-shell PATH and the well-known
    /// install dirs, in addition to whatever PATH it already carries. Order-preserving and additive:
    /// it never drops an entry, and an explicit `PATH` in `environment` keeps its existing entries —
    /// the login/well-known dirs are appended only where they're missing. Used by every host spawn.
    static func augment(_ environment: [String: String]) -> [String: String] {
        var env = environment
        env["PATH"] = merged(
            loginPATH: cached, currentPATH: environment["PATH"], home: NSHomeDirectory())
        return env
    }

    // MARK: - Merge (pure; unit-tested directly)

    /// Compose the final PATH: the login-shell PATH first (the user's real ordering, where `claude`
    /// actually resolves), then any inherited entries not already present, then the well-known
    /// install dirs as a safety net for when shell resolution misses or fails. Duplicates and empty
    /// segments are dropped while preserving first-seen order.
    static func merged(loginPATH: String?, currentPATH: String?, home: String) -> String {
        var ordered: [String] = []
        func add(_ raw: String?) {
            guard let raw else { return }
            for entry in raw.split(separator: separator, omittingEmptySubsequences: true)
                .map(String.init)
            where !ordered.contains(entry) {
                ordered.append(entry)
            }
        }
        add(loginPATH)
        add(currentPATH)
        for dir in wellKnownDirs(home: home) where !ordered.contains(dir) { ordered.append(dir) }
        return ordered.joined(separator: String(separator))
    }

    /// Directories where the agent CLIs commonly install, appended so a resolution miss (an exotic
    /// shell, an rc that doesn't export PATH) still finds them. The first entry is the Claude Code
    /// native installer's location — exactly the `claude` that goes missing in the GUI-launch bug.
    /// Nonexistent entries on PATH are harmless, so this list need not be filtered against the disk.
    static func wellKnownDirs(home: String) -> [String] {
        #if os(Windows)
        // Windows equivalents: no Homebrew, and the agent CLIs themselves live in the WSL
        // container (D2), so this list only has to cover the *host* tools a session shells out
        // to — git, gh, node-based helpers — for the case where the registry read missed.
        let env = ProcessInfo.processInfo.environment
        let appData = env["APPDATA"] ?? "\(home)\\AppData\\Roaming"
        let localAppData = env["LOCALAPPDATA"] ?? "\(home)\\AppData\\Local"
        let programFiles = env["ProgramFiles"] ?? "C:\\Program Files"
        return [
            "\(appData)\\npm",                        // npm global prefix
            "\(localAppData)\\Microsoft\\WindowsApps", // Store/winget shims
            "\(home)\\.local\\bin",                   // pipx / user installs
            "\(programFiles)\\Git\\cmd",              // Git for Windows
            "\(programFiles)\\GitHub CLI",            // gh
        ]
        #else
        return [
            "\(home)/.claude/local",    // Claude Code native installer
            "/opt/homebrew/bin",        // Homebrew (Apple silicon)
            "/usr/local/bin",           // Homebrew (Intel) / common installs
            "\(home)/.local/bin",       // pipx / user installs
            "\(home)/.npm-global/bin",  // npm global prefix
            "\(home)/bin",
        ]
        #endif
    }

    // MARK: - Resolution

    /// Run the user's login+interactive shell to print its PATH, fenced between sentinels. Returns
    /// `nil` on any failure (shell missing, nonzero exit, timeout, sentinels absent) — the caller
    /// degrades to the inherited PATH + well-known dirs.
    static func resolve(
        shell: String? = ProcessInfo.processInfo.environment["SHELL"],
        runner: ShellRunner = Self.runLoginShell
    ) -> String? {
        #if os(Windows)
        // No shell probe (see the type doc): the authoritative PATH is the registry's, which is
        // what a freshly launched process would have inherited.
        _ = shell
        _ = runner
        return WindowsEnvironment.registryPATH()
        #else
        let shellPath = (shell?.isEmpty == false ? shell! : "/bin/zsh")
        // `-l` sources login files (`~/.zprofile`/`~/.bash_profile`); `-i` sources interactive ones
        // (`~/.zshrc`/`~/.bashrc`) — the Claude native installer and many users add PATH in the
        // latter. The three printfs fence the (double-quoted, so unsplit) PATH between sentinels.
        let command =
            "printf '%s' '\(begin)'; printf '%s' \"$PATH\"; printf '%s' '\(end)'"
        guard let result = runner(shellPath, ["-ilc", command]), result.status == 0 else {
            return nil
        }
        return extractPATH(from: result.stdout)
        #endif
    }

    /// Pull the PATH value from between the sentinels in shell stdout (tolerating any rc noise that
    /// printed before or after). `nil` if the markers are absent or the value is empty.
    static func extractPATH(from output: String) -> String? {
        guard let b = output.range(of: begin),
            let e = output.range(of: end, range: b.upperBound..<output.endIndex)
        else { return nil }
        let path = String(output[b.upperBound..<e.lowerBound])
        return path.isEmpty ? nil : path
    }

    /// Default ``ShellRunner``: spawn the shell directly (absolute path → no PATH recursion), capture
    /// stdout, discard stderr/rc-noise, and bound the wait so a misbehaving rc file can't hang launch.
    /// Internal (not private): ``LoginShellEnv`` runs its one-shot `env -0` probe through the same
    /// mechanics rather than duplicating them. A function (not a stored closure) so strict
    /// concurrency has no shared-mutable-state complaint.
    static func defaultRunner(_ shellPath: String, _ args: [String]) -> ShellResult? {
        runLoginShell(shellPath, args)
    }

    private static func runLoginShell(_ shellPath: String, _ args: [String]) -> ShellResult? {
        let process = Process()
        process.executableURL = URL(fileURLWithPath: shellPath)
        process.arguments = args
        let outPipe = Pipe()
        process.standardOutput = outPipe
        process.standardError = FileHandle.nullDevice
        process.standardInput = FileHandle.nullDevice

        let done = DispatchSemaphore(value: 0)
        process.terminationHandler = { _ in done.signal() }
        do {
            try process.run()
        } catch {
            return nil
        }
        // The PATH payload is tiny (well under a pipe buffer), so the child exits without blocking on
        // a full pipe and we can read after it finishes. If an rc file wedges, terminate and bail.
        if done.wait(timeout: .now() + 5) == .timedOut {
            process.terminate()
            return nil
        }
        let data = outPipe.fileHandleForReading.readDataToEndOfFile()
        return (process.terminationStatus, String(decoding: data, as: UTF8.self))
    }
}
