/**
 * Lease-enforcement activation probe (issue #869, Point-1 card C1).
 *
 * Root cause this exists to guard against (#828 version skew): the
 * ENFORCEMENT half of the lease broker (PreToolUse/Stop hooks —
 * `mutator-gate.py`, `receipt-observer-client.py` — wired via the framework
 * reseed) and the ACTIVATION half (`execLeaseGatedRuntime()` in `launch.ts`,
 * which chains the runtime through `launch-runtime.py`, injects
 * `MOSAIC_LEASE_*`, and requires a running `daemon.py` broker) ship on
 * different channels. When the published CLI tarball lags behind an
 * enforcement reseed, the gate correctly fails CLOSED on absent identity —
 * but every tool call then denies with GATE_UNAVAILABLE. That fail-closed
 * behavior is intentional and must not change (see the C-REGRESS note in
 * `runtime_tools_unittest.py`); this module exists so a downstream
 * install-ordering guard (C2, out of scope here) can refuse to WIRE
 * enforcement in the first place on a host that cannot ACTIVATE it.
 *
 * `leaseEnforcementActivatable()` answers one narrow question: "if
 * enforcement were wired right now, could activation actually satisfy it?"
 * It is a real capability probe — not a "does the source file exist" check
 * — and both of its inputs are injectable so tests can drive every branch
 * without a live broker or an installed CLI on PATH.
 */

import { execFileSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import type { Command } from 'commander';
import { defaultLeaseBrokerSocket, resolveTool } from './launch.js';

// ─── Capability signal (owned by the activation half) ──────────────────────

/**
 * Versioned identity for the activation contract `execLeaseGatedRuntime()`
 * implements. OWNED by the activation half of the lease broker. Bump
 * `version` only when the activation contract itself changes (env vars
 * injected, chaining behavior, socket protocol, etc.) — deliberately
 * independent of the package's npm semver, because #828 happened precisely
 * because the npm version was NOT bumped even though the shipped artifact
 * fell out of sync. A build that cannot advertise this exact
 * `{ name, version }` pair does not implement the contract a caller is
 * relying on, whatever its package.json claims.
 */
export interface LeaseActivationCapability {
  readonly name: string;
  readonly version: number;
}

export const LEASE_ACTIVATION_CAPABILITY: LeaseActivationCapability = {
  name: 'lease-runtime-activation',
  version: 1,
};

/** Hidden CLI probe subcommand name — wired via {@link registerLeaseCapabilityProbe}. */
export const LEASE_CAPABILITY_PROBE_COMMAND = '__lease-capability';

/**
 * Budget for the out-of-process capability probe. The probe launches a fresh
 * Node process on the built CLI entrypoint, whose cold start alone measures
 * 2.2-2.3s on a mid-range workstation (sb-it-1-dt, 2026-08-13) — so the
 * previous 2s budget made the probe time out and report NO capability on
 * such hosts, failing every launch with the #869 skew message even though
 * the capability matched. The timeout only bounds the pathological hang
 * case; the happy path returns as soon as the probe exits. Mirrors
 * PROBE_TIMEOUT_SECONDS in the enforcement half
 * (framework/tools/lease-broker/activation_version_gate.py).
 */
export const LEASE_CAPABILITY_PROBE_TIMEOUT_MS = 10_000;

function capabilityMatches(candidate: LeaseActivationCapability | null): boolean {
  return (
    candidate !== null &&
    candidate.name === LEASE_ACTIVATION_CAPABILITY.name &&
    candidate.version === LEASE_ACTIVATION_CAPABILITY.version
  );
}

/**
 * Register the hidden `__lease-capability` probe subcommand. Prints the
 * capability this BUILD advertises as compact JSON to stdout and exits 0.
 * Deliberately undocumented (hidden from `--help`): it is an internal signal
 * for {@link defaultCapabilityProbe}, not a user-facing command.
 */
export function registerLeaseCapabilityProbe(program: Command): void {
  program
    .command(LEASE_CAPABILITY_PROBE_COMMAND, { hidden: true })
    .description('Internal: print the lease-activation capability this build advertises')
    .action(() => {
      process.stdout.write(JSON.stringify(LEASE_ACTIVATION_CAPABILITY));
    });
}

/** Injectable Node module resolver — matches `require.resolve`'s signature
 * narrowly (specifier in, absolute path out, or throws). Defaults to the
 * real `createRequire(import.meta.url).resolve`. Injectable so tests can
 * exercise WHICH specifier {@link defaultResolveCliEntry} resolves (the
 * reviewer-found bug was resolving the wrong one) without depending on
 * whether `@mosaicstack/mosaic` has actually been built on the test host —
 * and without ever touching the real package's `dist/` to find out. */
export type ModuleResolver = (specifier: string) => string;

/**
 * Resolve the CLI's built entrypoint (`dist/cli.js`). Resolves via the
 * package's "." export (already present in package.json's `exports` map)
 * rather than a "./package.json" subpath — the latter is NOT exported, so
 * `require.resolve('@mosaicstack/mosaic/package.json')` throws
 * ERR_PACKAGE_PATH_NOT_EXPORTED on every real install. The "." export
 * resolves to `dist/index.js`; `cli.js` is its sibling in the same built
 * `dist/` directory (see package.json's `bin.mosaic`).
 *
 * Exported standalone (and injectable via {@link CapabilityProbeDeps}) so
 * tests can exercise this resolution logic in isolation, or point
 * {@link defaultCapabilityProbe} at a scratch directory instead of ever
 * touching the real installed package's `dist/` — a test corrupting a real
 * build artifact is exactly the artifact-integrity failure class this card
 * exists to prevent (#828).
 */
export function defaultResolveCliEntry(
  resolve: ModuleResolver = createRequire(import.meta.url).resolve,
): string {
  const mainEntry = resolve('@mosaicstack/mosaic');
  return join(dirname(mainEntry), 'cli.js');
}

/** Narrow injectable seam for the synchronous child process used by the
 * capability probe. */
export type CapabilityProbeExecFile = (
  file: string,
  args: string[],
  options: {
    encoding: BufferEncoding;
    timeout: number;
    stdio: ['ignore', 'pipe', 'ignore'];
  },
) => string;

/** Injectable inputs for {@link defaultCapabilityProbe}. */
export interface CapabilityProbeDeps {
  /** Resolve the CLI entrypoint (`cli.js`) to probe. Defaults to
   * {@link defaultResolveCliEntry}. Inject to point at an isolated scratch
   * location in tests — never at the real package's `dist/`. */
  resolveCliEntry?: () => string;
  /** Execute the resolved CLI entrypoint. Defaults to the real
   * `execFileSync`. Inject so transport behavior and options can be tested
   * without spawning a process. */
  execFile?: CapabilityProbeExecFile;
}

/**
 * Real capability lookup. Resolves the installed `@mosaicstack/mosaic`
 * package's BUILT entrypoint (`dist/cli.js` — the published artifact a user
 * actually runs, not this TypeScript source file) and executes its hidden
 * `__lease-capability` probe subcommand out-of-process. A build that lacks
 * the subcommand, fails to execute, or reports an incompatible
 * `{ name, version }` is treated as having NO activation capability.
 *
 * This is the check that would have caught #828's version skew: the
 * source-tree activation half existed, but the published tarball's `dist/`
 * did not carry it, so this probe — reading the actually-resolvable built
 * artifact rather than trusting source-tree presence — would report null.
 */
export function defaultCapabilityProbe(
  deps: CapabilityProbeDeps = {},
): LeaseActivationCapability | null {
  try {
    const resolveCliEntry = deps.resolveCliEntry ?? defaultResolveCliEntry;
    const cliEntry = resolveCliEntry();
    if (!existsSync(cliEntry)) return null;

    const execFile: CapabilityProbeExecFile = deps.execFile ?? execFileSync;
    const output = execFile(process.execPath, [cliEntry, LEASE_CAPABILITY_PROBE_COMMAND], {
      encoding: 'utf-8',
      timeout: LEASE_CAPABILITY_PROBE_TIMEOUT_MS,
      stdio: ['ignore', 'pipe', 'ignore'],
    });

    const parsed: unknown = JSON.parse(output);
    if (
      typeof parsed !== 'object' ||
      parsed === null ||
      typeof (parsed as Record<string, unknown>)['name'] !== 'string' ||
      typeof (parsed as Record<string, unknown>)['version'] !== 'number'
    ) {
      return null;
    }
    const candidate = parsed as { name: string; version: number };
    return { name: candidate.name, version: candidate.version };
  } catch {
    return null;
  }
}

// ─── Supervisor / socket resolution (detection only) ───────────────────────

/** Detection-only supervisor/socket probe result. Never starts the broker
 * and never connects to the socket — presence and path resolution only. */
export interface SupervisorProbeResult {
  /** The lease-broker supervisor artifacts (launcher + daemon) are present. */
  readonly supervisorPresent: boolean;
  /** Resolved broker socket path, or null if it could not be resolved. */
  readonly socketPath: string | null;
}

/**
 * Real supervisor/socket resolution: checks that the lease-broker's launcher
 * (`launch-runtime.py`) and supervisor (`daemon.py`) artifacts resolve on
 * disk via the same tool-resolution `execLeaseGatedRuntime()` uses, and that
 * a broker socket path resolves via the same logic as
 * `defaultLeaseBrokerSocket()`. Detection only — this never starts the
 * daemon and never connects to the socket.
 */
export function defaultSupervisorProbe(
  env: NodeJS.ProcessEnv = process.env,
): SupervisorProbeResult {
  const launcherPath = resolveTool('lease-broker', 'launch-runtime.py');
  const daemonPath = resolveTool('lease-broker', 'daemon.py');
  const supervisorPresent = existsSync(launcherPath) && existsSync(daemonPath);

  let socketPath: string | null = null;
  try {
    const resolved = defaultLeaseBrokerSocket(env);
    socketPath = resolved.trim().length > 0 ? resolved : null;
  } catch {
    socketPath = null;
  }

  return { supervisorPresent, socketPath };
}

// ─── Predicate ───────────────────────────────────────────────────────────

/** Injectable inputs for {@link leaseEnforcementActivatable}, so tests (and
 * downstream callers such as the C2 install-ordering guard) can drive every
 * branch without a live broker or an installed CLI on PATH. */
export interface ActivationProbeDeps {
  getCapability?: () => LeaseActivationCapability | null;
  probeSupervisor?: () => SupervisorProbeResult;
}

/**
 * True IFF lease enforcement can actually be ACTIVATED on this host:
 *
 *   (a) the resolvable CLI advertises a {@link LeaseActivationCapability}
 *       compatible with {@link LEASE_ACTIVATION_CAPABILITY}, AND
 *   (b) the broker supervisor is resolvable — launcher + `daemon.py`
 *       artifacts present AND a broker socket path resolves.
 *
 * Pure/testable: both probes default to the real, side-effect-free lookups
 * above but can be injected, so this predicate never itself starts a broker
 * or performs enforcement — it only reports whether activation *could*
 * satisfy enforcement if wired.
 */
export function leaseEnforcementActivatable(deps: ActivationProbeDeps = {}): boolean {
  const getCapability = deps.getCapability ?? defaultCapabilityProbe;
  const probeSupervisor = deps.probeSupervisor ?? defaultSupervisorProbe;

  if (!capabilityMatches(getCapability())) return false;

  const supervisor = probeSupervisor();
  return supervisor.supervisorPresent && supervisor.socketPath !== null;
}
