#!/usr/bin/env bun
/**
 * PreToolUse hook: File Size Bloat Guard
 *
 * Prevents single-file bloat by checking line count before Write/Edit.
 * Uses tiered approach: warn via PostToolUse (soft), block via deny (hard).
 *
 * Detection:
 * - Write: counts lines in proposed content
 * - Edit: reads existing file, applies edit, counts resulting lines
 *
 * Thresholds (per-extension, configurable via .claude/file-size-guard.json):
 *   Default: warn >500 lines, block >1000 lines
 *   .rs:    warn >500, block >1000 (Rust — PyO3 bindings tend to bloat)
 *   .py:    warn >500, block >1000
 *   .ts:    warn >500, block >1000
 *   .md:    warn >800, block >1500 (docs are naturally longer)
 *
 * Escape hatches:
 *   - "# FILE-SIZE-OK" comment anywhere in file
 *   - Plan mode (auto-skipped)
 *   - Glob patterns in config excludes
 *
 * ADR: (pending — will be created if hook proves valuable)
 */

import { readFileSync, existsSync } from "fs";
import { join } from "path";
import { spawnSync } from "child_process";
import {
  parseStdinOrAllow,
  allow,
  deny,
  isPlanMode,
  createHookLogger,
  trackHookError,
  type PreToolUseInput,
} from "./pretooluse-helpers.ts";
import {
  ALLOW_DECISION,
  denyDecision,
  isFileEditToolNameHonoredByPreToolUseBlockingSubhook,
  type PreToolUseSubhookDecision,
} from "./lib/pretooluse-subhook-contract-for-in-process-orchestrator-inlining-iter84.ts";

// ============================================================================
// Configuration
// ============================================================================

interface ExtensionThresholds {
  warn: number;
  block: number;
}

interface GuardConfig {
  /** Default thresholds for any file extension */
  defaults: ExtensionThresholds;
  /** Per-extension overrides (key is extension with dot, e.g. ".rs") */
  extensions: Record<string, ExtensionThresholds>;
  /** Glob patterns to exclude from checking (e.g. "*.generated.ts") */
  excludes: string[];
  /** Escape hatch comment — if found in file, skip check */
  escapeComment: string;
}

const DEFAULT_CONFIG: GuardConfig = {
  defaults: { warn: 500, block: 1000 },
  extensions: {
    ".rs": { warn: 500, block: 1000 },
    ".py": { warn: 500, block: 1000 },
    ".ts": { warn: 500, block: 1000 },
    ".tsx": { warn: 500, block: 1000 },
    ".js": { warn: 500, block: 1000 },
    ".jsx": { warn: 500, block: 1000 },
    ".go": { warn: 500, block: 1000 },
    ".md": { warn: 800, block: 1500 },
    ".toml": { warn: 200, block: 500 },
    ".json": { warn: 1000, block: 3000 },
  },
  excludes: [
    "*.generated.*",
    "*.min.js",
    "*.min.css",
    "package-lock.json",
    "Cargo.lock",
    "uv.lock",
    "*.lock",
  ],
  escapeComment: "FILE-SIZE-OK",
};

// ============================================================================
// Config Loading
// ============================================================================

function loadConfig(): GuardConfig {
  const config = { ...DEFAULT_CONFIG };

  // Check project .claude/ dir first, then global ~/.claude/
  const candidates = [
    join(process.cwd(), ".claude", "file-size-guard.json"),
    join(process.env.HOME || "", ".claude", "file-size-guard.json"),
  ];

  for (const candidate of candidates) {
    if (existsSync(candidate)) {
      try {
        const override = JSON.parse(readFileSync(candidate, "utf8"));
        if (override.defaults) {
          config.defaults = { ...config.defaults, ...override.defaults };
        }
        if (override.extensions) {
          config.extensions = { ...config.extensions, ...override.extensions };
        }
        if (override.excludes) {
          config.excludes = [...config.excludes, ...override.excludes];
        }
        if (override.escapeComment) {
          config.escapeComment = override.escapeComment;
        }
        break; // First found wins (project > global)
      } catch {
        // Invalid JSON — ignore and use defaults
      }
    }
  }

  return config;
}

// ============================================================================
// Detection
// ============================================================================

function getExtension(filePath: string): string {
  const parts = filePath.split("/").pop()?.split(".") || [];
  return parts.length > 1 ? `.${parts[parts.length - 1]}` : "";
}

function getThresholds(
  config: GuardConfig,
  filePath: string
): ExtensionThresholds {
  const ext = getExtension(filePath);
  return config.extensions[ext] || config.defaults;
}

const EXEMPT_MIN_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 1 week

/**
 * Returns the age in ms since the file's first git commit, or null if the
 * file has no git history (untracked) or git is unavailable.
 */
function gitFirstCommitAgeMs(filePath: string): number | null {
  try {
    const r = spawnSync("git", ["log", "--format=%at", "--", filePath], {
      cwd: process.cwd(),
      encoding: "utf8",
      timeout: 3000,
    });
    if (r.status !== 0 || !r.stdout.trim()) return null;
    const lines = r.stdout.trim().split("\n");
    const oldest = parseInt(lines[lines.length - 1], 10);
    if (Number.isNaN(oldest)) return null;
    return Date.now() - oldest * 1000;
  } catch {
    return null;
  }
}

function isExcluded(config: GuardConfig, filePath: string): boolean {
  const fileName = filePath.split("/").pop() || "";

  for (const pattern of config.excludes) {
    if (pattern.startsWith("*.")) {
      // Wildcard patterns (*.lock, *.generated.*) — always exempt
      const suffix = pattern.slice(1);
      if (fileName.endsWith(suffix)) return true;
    } else if (fileName === pattern) {
      // Named file pattern: only exempt if first committed > 1 week ago.
      // Untracked files (null age) are never exempt — they're new.
      const ageMs = gitFirstCommitAgeMs(filePath);
      if (ageMs === null || ageMs < EXEMPT_MIN_AGE_MS) return false;
      return true;
    }
  }

  return false;
}

// Iter-110: migrated to the iter-107 canonical shared escape-hatch-marker
// detection helper. Closes the iter-107 → iter-109 migration arc by
// converting the last hand-rolled escape-hatch consumer (config-string-
// based, not regex-literal-based — which is why earlier iters' inventory
// audit didn't flag this hook). Behavior-preserving: the pre-iter-110
// implementation was `content.includes(escapeComment)`, which is exactly
// what the helper's CASE_SENSITIVE mode does (pure substring match).
// Distinguishing feature of file-size-guard: the marker token is loaded
// at runtime from per-project config (default "FILE-SIZE-OK", overridable
// via .claude/file-size-guard.json), so the marker is supplied via the
// classifier's `config.escapeComment` field rather than a hardcoded
// configuration object at module load time.
import { hasFileWideEscapeHatchMarkerInContent } from "./lib/shared-escape-hatch-marker-detection-helper-cross-pretooluse-and-posttooluse-iter107.ts";
function hasEscapeComment(content: string, escapeComment: string): boolean {
  return hasFileWideEscapeHatchMarkerInContent(content, {
    markerNameTokenIncludingSuffix: escapeComment,
    caseSensitivityMode: "CASE_SENSITIVE",
  });
}

/**
 * Apply an Edit tool's old_string → new_string to existing content.
 * Returns the resulting content after edit.
 */
function applyEdit(
  existing: string,
  oldString: string,
  newString: string
): string {
  const idx = existing.indexOf(oldString);
  if (idx === -1) return existing; // Can't find old_string — return as-is
  return existing.slice(0, idx) + newString + existing.slice(idx + oldString.length);
}

// ============================================================================
// Pure classifier (iter-84 orchestrator-inlineable contract)
// ============================================================================

const logger = createHookLogger("FILE-SIZE-GUARD");

/**
 * Pure classifier conforming to PreToolUseSubhookClassifierFunction.
 *
 * Same logic as the standalone main() below, but factored out so the
 * iter-84 `pretooluse-edit-time-orchestrator-combining-multiple-subhooks-into-single-bun-process-iter66-precedent.ts`
 * can call it directly without subprocess-spawning this file (which would
 * cost a full bun cold-start per Write|Edit and defeat the orchestrator's
 * purpose).
 *
 * MUST NOT call allow()/deny() or touch stdin/stdout. Returns a decision
 * object that the caller (standalone main OR orchestrator) translates to
 * the appropriate Claude Code response.
 */
export async function classifyFileSizeGuardForOrchestrator(
  input: PreToolUseInput,
): Promise<PreToolUseSubhookDecision> {
  const { tool_name, tool_input } = input;

  // Iter-102: route through canonical contract helper (closes the iter-101
  // documented residual gap — pre-iter-102 the hardcoded equality check
  // excluded MultiEdit even after iter-101 broadened the matcher).
  if (!isFileEditToolNameHonoredByPreToolUseBlockingSubhook(tool_name)) {
    return ALLOW_DECISION;
  }
  // Iter-102 staged-migration short-circuit: MultiEdit payload-shape
  // adaptation (extracting proposed final content from tool_input.edits[]
  // applied sequentially) is iter-103+ per-classifier work. Until then,
  // MultiEdit returns ALLOW to preserve status quo and prevent false-
  // positive violations from the downstream Edit branch accessing
  // undefined tool_input.old_string / tool_input.new_string fields.
  if (tool_name === "MultiEdit") {
    return ALLOW_DECISION;
  }

  // Skip in plan mode
  const planContext = isPlanMode(input, {
    checkPermission: true,
    checkPath: true,
  });
  if (planContext.inPlanMode) {
    logger.debug("Skipping file size check in plan mode", {
      hook_event: "PreToolUse",
      tool_name,
      trace_id: input.tool_use_id,
    });
    return ALLOW_DECISION;
  }

  const filePath = tool_input.file_path;
  if (!filePath) return ALLOW_DECISION;

  const config = loadConfig();

  // Check exclusions
  if (isExcluded(config, filePath)) return ALLOW_DECISION;

  // Get the proposed content
  let proposedContent: string;

  if (tool_name === "Write") {
    proposedContent = (tool_input.content as string) || "";
  } else {
    // Edit: apply old_string → new_string to existing file
    const oldString = (tool_input.old_string as string) || "";
    const newString = (tool_input.new_string as string) || "";

    if (!existsSync(filePath)) {
      return ALLOW_DECISION; // New file via Edit — unusual but allow
    }

    const existing = readFileSync(filePath, "utf8");
    proposedContent = applyEdit(existing, oldString, newString);
  }

  // Check escape hatch
  if (hasEscapeComment(proposedContent, config.escapeComment)) {
    return ALLOW_DECISION;
  }

  const lineCount = proposedContent.split("\n").length;
  const thresholds = getThresholds(config, filePath);

  // Under warn threshold — allow
  if (lineCount <= thresholds.warn) {
    return ALLOW_DECISION;
  }

  const ext = getExtension(filePath);
  const fileName = filePath.split("/").pop() || filePath;

  if (lineCount > thresholds.block) {
    // Above block threshold — deny with guidance
    const reason = [
      `[FILE-SIZE-GUARD] ${fileName} would be ${lineCount} lines (threshold: ${thresholds.block} for ${ext || "default"})`,
      "",
      `This file is very large. Large single files are harder to maintain,`,
      `review, and navigate. Consider splitting into multiple files.`,
      "",
      `**Suggestions:**`,
      ext === ".rs"
        ? `- Split into submodules: mod foo; (in separate .rs files)`
        : ext === ".py"
          ? `- Extract classes/functions into separate modules`
          : ext === ".ts" || ext === ".tsx"
            ? `- Extract components/utilities into separate files`
            : `- Split into smaller, focused files`,
      `- Add \`# ${config.escapeComment}\` comment to suppress this warning`,
      "",
      `Current: ${lineCount} lines | Warn: ${thresholds.warn} | Block: ${thresholds.block}`,
    ].join("\n");

    return denyDecision(reason);
  }

  // Between warn and block — allow through (PostToolUse reminder handles soft notification for code files)
  return ALLOW_DECISION;
}

// ============================================================================
// Standalone main (backward-compat for direct invocation from hooks.json)
// ============================================================================

async function main(): Promise<void> {
  const input = await parseStdinOrAllow("FILE-SIZE-GUARD");
  if (!input) return;

  const decision = await classifyFileSizeGuardForOrchestrator(input);

  switch (decision.kind) {
    case "deny":
      return deny(decision.reason ?? "(no reason given)");
    case "ask":
      // file-size-guard doesn't currently use ask; treat as deny for safety
      return deny(decision.reason ?? "(no reason given)");
    default:
      return allow();
  }
}

// Only run main() when this file is invoked directly (bun pretooluse-file-size-guard.ts),
// not when the orchestrator imports the classifier function. import.meta.main is true
// only for the entry-point script.
if (import.meta.main) {
  main().catch((err) => {
    trackHookError("pretooluse-file-size-guard", err instanceof Error ? err.message : String(err));
    allow(); // Fail-open
  });
}
