/**
 * Copyright (c) 2026 Shiva Deore (Taracod).
 * Licensed under AGPL-3.0. See LICENSE for details.
 *
 * Aiden — local-first agent.
 */
/**
 * moat/approvalEngine.ts — Aiden v4.0.0
 *
 * The central gate every write/execute tool passes through. Three modes:
 *
 *   manual (default) — every mutating call prompts the user via
 *                      `callbacks.promptUser`. Read tools never gate.
 *   smart            — calls auxiliary `callbacks.riskAssess` for
 *                      flagged commands. safe → auto-allow,
 *                      dangerous → auto-deny, caution → prompt.
 *   off              — YOLO. Everything auto-allows. Decision is
 *                      still logged via `callbacks.onDecision`.
 *
 * Allowlist scoping:
 *   - `allowForSession(tool, sig)`        — cleared on `resetSession()`.
 *   - `allowAlways(tool, sig)`            — fired through the optional
 *                                            `callbacks.persistAllow`
 *                                            sink (Phase 6 ConfigManager
 *                                            wires this up; for Phase 9
 *                                            the in-memory entry is the
 *                                            same as the session list).
 *
 * heartbeat/HTTP approval surface yet; that lands in Phase 14-15 TUI).
 *
 * Status: PHASE 9.
 */

export type ApprovalMode = 'manual' | 'smart' | 'off';
export type ApprovalDecision =
  | 'allow'
  | 'deny'
  | 'allow_session'
  | 'allow_always';
export type RiskTier = 'safe' | 'caution' | 'dangerous';
export type ToolCategory =
  | 'read'
  | 'write'
  | 'execute'
  | 'network'
  | 'browser';

export interface ApprovalRequest {
  toolName: string;
  category: ToolCategory;
  args: Record<string, unknown>;
  /** Pre-flagged risk tier from the dangerous-patterns catalog. */
  riskTier?: RiskTier;
  /** Why was this flagged? (description from the matching pattern) */
  reason?: string;
  /**
   * v4.4 Phase 4 — dangerous-tier auto-preview. When the executor
   * computed a `buildPreview` for this call (only for dangerous-tier
   * tools with the method defined), the result is forwarded here so
   * the `promptUser` callback can render "this is what would
   * happen" alongside the y/n choices. Opaque structurally to keep
   * the moat → core direction one-way; consumers cast to the
   * imported `WouldExecute` type when they need to read it.
   */
  preview?: unknown;
}

export interface ApprovalCallbacks {
  /** Called when the user must decide. CLI implements this with a prompt. */
  promptUser?: (req: ApprovalRequest) => Promise<ApprovalDecision>;
  /** Smart-mode auxiliary risk assessment (auxiliary LLM). */
  riskAssess?: (req: ApprovalRequest) => Promise<{
    tier: RiskTier;
    rationale: string;
  }>;
  /** Logging hook — fired AFTER every decision (allow or deny). */
  onDecision?: (req: ApprovalRequest, decision: ApprovalDecision) => void;
  /**
   * v4.9.0 Slice 12b — fired BEFORE the engine decides. Observer-only;
   * the engine ignores the return value. Wiring path: `aiden hooks`
   * subsystem fires `approval.requested` so registered hooks can
   * observe (but not preempt) tool-approval gates.
   */
  onRequested?: (req: ApprovalRequest) => void;
  /** Permanent-allowlist sink. Phase 6 ConfigManager wires this up. */
  persistAllow?: (toolName: string, argSignature: string) => void;
  /**
   * v4.8.0 Phase 2.5 — semantic ui_* event sink. The engine fires
   * `ui_approval_request` immediately before `promptUser` so the
   * display layer can paint a structured row alongside the existing
   * y/n prompt. Additive only — the y/n flow is unchanged.
   */
  onUiEvent?: (name: string, args: Record<string, unknown>) => void;
}

/**
 * Phase 16f: built-in low-risk tool list — never prompts, never gated by
 * smart-mode classifier. These are read-only or read-mostly tools whose
 * worst case is "exposes information already visible to the user." A
 * read-category short-circuit, but explicit about which tools fall
 * under it.
 *
 * Adding to this list is a deliberate trust call. Don't add anything
 * that mutates filesystem, runs shell, or hits arbitrary URLs.
 */
export const BUILTIN_SAFE_TOOLS: ReadonlySet<string> = new Set([
  'file_read',
  'file_list',
  'fetch_url',
  'web_search',
  'session_search',
  'memory_add',
  'memory_replace',
  'memory_remove',
  'memory_list',
  'system_info',
  'now_playing',
  'browser_screenshot',
  'browser_get_url',
  'open_url', // shell launch to user's default browser — same trust as a link click.
]);

/**
 * Phase 16f: domain allowlist for `browser_navigate`. URLs to these
 * hostnames auto-approve in smart mode; non-allowlisted domains prompt.
 * Mirrors the built-in tool list philosophy — common dev / docs /
 * reference sites the user almost certainly trusts at click level.
 */
export const BUILTIN_SAFE_DOMAINS: ReadonlySet<string> = new Set([
  'google.com',
  'www.google.com',
  'duckduckgo.com',
  'wikipedia.org',
  'en.wikipedia.org',
  'github.com',
  'gitlab.com',
  'stackoverflow.com',
  'serverfault.com',
  'superuser.com',
  'npmjs.com',
  'www.npmjs.com',
  'pypi.org',
  'crates.io',
  'docs.rs',
  'developer.mozilla.org',
  'taracod.com',
  'www.taracod.com',
]);

/** Extract a hostname from a URL for safe-domain matching. Lowercased. */
export function hostnameOf(url: string): string | null {
  try {
    return new URL(url).hostname.toLowerCase();
  } catch {
    return null;
  }
}

/** Stable signature for an approval request (for allowlist matching). */
export function argSignature(
  toolName: string,
  args: Record<string, unknown>,
): string {
  // Extract the primary mutating argument so we don't bloat the
  // signature with timeouts / cwd / etc. that don't change risk.
  const primary =
    (args.command as string) ??
    (args.path as string) ??
    (args.url as string) ??
    (args.code as string) ??
    '';
  return `${toolName}::${(primary || JSON.stringify(args)).slice(0, 200)}`;
}

/**
 * Phase 20: Pro feature gate that ApprovalEngine consults before batching
 * consecutive same-signature tool calls into a single prompt. Free tier
 * sees one prompt per call (current Phase 16f behaviour); Pro tier can
 * batch via the upcoming N+50 batch-prompt UI. The engine exposes the
 * gate so the prompt UI can decide which mode to render.
 *
 * Kept as an opaque async predicate so the License subsystem owns the
 * cache lookup — ApprovalEngine never imports `core/v4/license` directly,
 * preserving the moat → core dependency direction.
 */
export interface BatchApprovalGate {
  /** True when the user has Pro and the multi_tool_approval feature is on. */
  canBatch(): Promise<boolean>;
}

export class ApprovalEngine {
  private sessionAllow = new Set<string>();
  private permanentAllow = new Set<string>();
  private batchGate?: BatchApprovalGate;

  constructor(
    private mode: ApprovalMode = 'manual',
    private callbacks: ApprovalCallbacks = {},
  ) {}

  /**
   * Phase 20 wiring: late-binding installer for the Pro batch-approval
   * gate. Called by `aidenCLI` after the LicenseClient has loaded its
   * cache. ApprovalEngine itself never gates on this — UI consumers ask
   * `getBatchGate()?.canBatch()` and choose batched-vs-per-call rendering.
   */
  setBatchGate(gate: BatchApprovalGate | undefined): void {
    this.batchGate = gate;
  }

  /** Gate accessor for the prompt UI. May be undefined when not wired. */
  getBatchGate(): BatchApprovalGate | undefined {
    return this.batchGate;
  }

  setMode(mode: ApprovalMode): void {
    this.mode = mode;
  }

  getMode(): ApprovalMode {
    return this.mode;
  }

  allowForSession(toolName: string, signature: string): void {
    this.sessionAllow.add(`${toolName}::${signature}`);
  }

  allowAlways(toolName: string, signature: string): void {
    const key = `${toolName}::${signature}`;
    this.permanentAllow.add(key);
    this.sessionAllow.add(key); // permanent ⊂ session
    this.callbacks.persistAllow?.(toolName, signature);
  }

  resetSession(): void {
    this.sessionAllow = new Set(this.permanentAllow);
  }

  /**
   * Phase 16f: pre-load permanent allowlist entries from a persisted file.
   * `aidenCLI` calls this once at boot with the contents of
   * `~/.aiden/approvals.json`. The format is `[{tool, signature}]`. Each
   * entry feeds both `permanentAllow` and `sessionAllow`. Idempotent.
   */
  loadPersistentAllowlist(
    entries: ReadonlyArray<{ tool: string; signature: string }>,
  ): void {
    for (const e of entries) {
      const key = `${e.tool}::${e.signature}`;
      this.permanentAllow.add(key);
      this.sessionAllow.add(key);
    }
  }

  /**
   * Phase 16f: built-in policy short-circuit — auto-allow when the tool
   * name is in `BUILTIN_SAFE_TOOLS` OR when it's a `browser_navigate`
   * to a `BUILTIN_SAFE_DOMAINS` hostname. Returns true if the call
   * should auto-allow without prompting. Smart mode only.
   */
  private matchesBuiltinSafePolicy(req: ApprovalRequest): boolean {
    if (BUILTIN_SAFE_TOOLS.has(req.toolName)) return true;
    if (req.toolName === 'browser_navigate') {
      const url = (req.args.url as string | undefined) ?? '';
      const host = hostnameOf(url);
      if (host && BUILTIN_SAFE_DOMAINS.has(host)) return true;
    }
    return false;
  }

  /**
   * Main entry. Returns `true` to allow, `false` to deny. Read-only
   * categories are always allowed without consulting any callback.
   */
  async checkApproval(req: ApprovalRequest): Promise<boolean> {
    // v4.9.0 Slice 12b — fire `approval.requested` for any gated call.
    // Read-category short-circuits below; we still fire so observers
    // see ALL approval requests (not just the ones that prompt).
    this.callbacks.onRequested?.(req);
    if (req.category === 'read') {
      this.callbacks.onDecision?.(req, 'allow');
      return true;
    }

    // YOLO mode: auto-allow but log.
    if (this.mode === 'off') {
      this.callbacks.onDecision?.(req, 'allow');
      return true;
    }

    // Allowlist short-circuit (user-recorded).
    const sig = argSignature(req.toolName, req.args);
    const key = `${req.toolName}::${sig}`;
    if (this.sessionAllow.has(key)) {
      this.callbacks.onDecision?.(req, 'allow_session');
      return true;
    }

    // Phase 16f: in smart mode, consult the built-in policy before any
    // prompt or LLM call. Built-in safe tools / safe domains short-circuit
    // here; this is the bulk of the UX win — most calls in a normal
    // session match one of these patterns and never prompt.
    if (this.mode === 'smart' && this.matchesBuiltinSafePolicy(req)) {
      this.callbacks.onDecision?.(req, 'allow');
      return true;
    }

    if (this.mode === 'smart') {
      // Smart mode: trust the pre-flagged tier, otherwise ask the LLM.
      // Phase 16f: when neither a pre-flagged tier nor a riskAssess callback
      // is available, the call did NOT match BUILTIN_SAFE_TOOLS or the
      // user's recorded allowlist (those short-circuited above). Default
      // to 'caution' so the user gets a prompt — auto-allowing unflagged
      // calls under smart mode was the bug that made approvals feel like
      // they did nothing in 16e (every shell_exec / file_write
      // auto-approved).
      let tier: RiskTier = req.riskTier ?? 'caution';
      let rationale: string | undefined;
      if (!req.riskTier && this.callbacks.riskAssess) {
        const assessed = await this.callbacks.riskAssess(req);
        tier = assessed.tier;
        rationale = assessed.rationale;
      }
      if (tier === 'safe') {
        this.callbacks.onDecision?.(req, 'allow');
        return true;
      }
      if (tier === 'dangerous') {
        this.callbacks.onDecision?.(
          { ...req, reason: rationale ?? req.reason },
          'deny',
        );
        return false;
      }
      // 'caution' falls through to user prompt.
      req = { ...req, riskTier: tier, reason: rationale ?? req.reason };
    }

    // manual or smart-caution → prompt user.
    if (!this.callbacks.promptUser) {
      // No prompter wired (e.g. tests with no UI). Fail-closed.
      this.callbacks.onDecision?.(req, 'deny');
      return false;
    }
    // v4.8.0 Phase 2.5 — emit a structured ui_approval_request event
    // BEFORE the y/n prompt fires. Additive: the display layer paints
    // the gutter-integrated row, then the existing promptUser flow
    // runs unchanged. Moat-tier (safe/caution/dangerous) maps to the
    // ui schema's 4-tier scale; 'critical' is reserved for future
    // wiring and unreachable from this path.
    const uiTier: 'low' | 'medium' | 'high' =
      req.riskTier === 'safe'      ? 'low'  :
      req.riskTier === 'dangerous' ? 'high' : 'medium';
    const argsPreview = JSON.stringify(req.args).slice(0, 80);
    this.callbacks.onUiEvent?.('ui_approval_request', {
      prompt:    `${req.toolName} ${argsPreview}`,
      risk_tier: uiTier,
      reason:    req.reason,
    });
    const decision = await this.callbacks.promptUser(req);
    this.callbacks.onDecision?.(req, decision);

    if (decision === 'deny') return false;
    if (decision === 'allow') return true;
    if (decision === 'allow_session') {
      this.allowForSession(req.toolName, sig);
      return true;
    }
    if (decision === 'allow_always') {
      this.allowAlways(req.toolName, sig);
      return true;
    }
    return false;
  }
}
