/// <reference types="node" />
import os from "node:os";
import path from "node:path";
import { readFile, writeFile, mkdir, rename, rm } from "node:fs/promises";
import { existsSync } from "node:fs";
import { getLogger } from "./logger.js";
import { ensureNotifyScript } from "./claude-hook-config.js";

const log = getLogger("codex-hook");

/**
 * Manages Codex CLI notification hooks at user scope.
 *
 * Codex stores config in two files:
 *   ~/.codex/config.toml  — must contain [features] hooks = true to load hooks
 *   ~/.codex/hooks.json   — hook definitions (same nested shape as Claude)
 *
 * Codex hooks were gated off on Windows until PR #17268 (merged 2026-04-09),
 * shipped in Codex CLI v0.121.0. strIDEterm requires that version or newer on
 * Windows; older versions will accept the config but not fire hooks.
 *
 * Codex's hook payload on stdin includes `hook_event_name` and `cwd`, so the
 * shared notify.mjs resolves notify URLs by matching payload.cwd against the
 * workspace cwds registered in notify-urls.json. Codex runs that fire outside
 * any registered workspace are dropped, so external `codex` invocations do
 * not leak notifications into strIDEterm.
 *
 * Codex's event names match Claude's for the events we care about (Stop,
 * UserPromptSubmit), so no alias mapping is needed — argv[2] is passed through
 * to the dispatcher unchanged.
 */

// Events we register. Codex supports: SessionStart, PreToolUse, PostToolUse,
// UserPromptSubmit, Stop. For strIDEterm task runner and notifications we only
// need Stop (fires onAgentIdle) and UserPromptSubmit (resets idle bookkeeping).
// PreToolUse/PostToolUse only fire for Bash today and would flood; skip.
// SessionStart has no strIDEterm consumer; skip.
export const HOOKS_TO_REGISTER = Object.freeze(["Stop", "UserPromptSubmit"]);

const HOOK_MARKERS = Object.freeze(["hooks/notify.mjs", "hooks\\notify.mjs"]);

export function getCodexConfigPath(): string {
  return path.join(os.homedir(), ".codex", "config.toml");
}

export function getCodexHooksPath(): string {
  return path.join(os.homedir(), ".codex", "hooks.json");
}

function getNotifyScriptPath(userDataPath: string): string {
  return path.join(userDataPath, "hooks", "notify.mjs");
}

function buildCodexHookEntry(notifyScriptPath: string, hookName: string) {
  const normalized = notifyScriptPath.replace(/\\/g, "/");
  return {
    matcher: "",
    hooks: [
      {
        type: "command",
        command: `node "${normalized}" ${hookName}`,
        timeout: 5,
      },
    ],
  };
}

export function findExistingHook(settings: Record<string, unknown>, hookName: string): number {
  const hooksSection = (settings?.hooks as Record<string, unknown> | undefined)?.[hookName];
  if (!Array.isArray(hooksSection)) return -1;
  return hooksSection.findIndex(
    (entry: unknown) =>
      Array.isArray((entry as Record<string, unknown>)?.hooks) &&
      ((entry as Record<string, unknown>).hooks as unknown[]).some(
        (h: unknown) =>
          typeof (h as Record<string, unknown>)?.command === "string" &&
          HOOK_MARKERS.some((m) => (h as Record<string, string>).command.includes(m)),
      ),
  );
}

async function readHooksJson(): Promise<{
  ok: boolean;
  data: Record<string, unknown> | null;
  path: string;
  error?: string;
}> {
  const hooksPath = getCodexHooksPath();
  try {
    const raw = await readFile(hooksPath, "utf8");
    return { ok: true, data: JSON.parse(raw) as Record<string, unknown>, path: hooksPath };
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code === "ENOENT") return { ok: true, data: null, path: hooksPath };
    return { ok: false, data: null, error: (error as NodeJS.ErrnoException).message, path: hooksPath };
  }
}

async function writeHooksJson(data: Record<string, unknown>): Promise<{ ok: boolean; error?: string }> {
  const hooksPath = getCodexHooksPath();
  const dir = path.dirname(hooksPath);
  const tmpPath = hooksPath + ".strideterm-tmp";
  try {
    if (!existsSync(dir)) await mkdir(dir, { recursive: true });
    await writeFile(tmpPath, JSON.stringify(data, null, 2) + "\n", "utf8");
    await rename(tmpPath, hooksPath);
    return { ok: true };
  } catch (error) {
    await rm(tmpPath, { force: true }).catch(() => {});
    return { ok: false, error: (error as NodeJS.ErrnoException).message };
  }
}

function findFeaturesSection(content: string): { bodyStart: number; bodyEnd: number; body: string } | null {
  const header = /(^|\n)\[features\][ \t]*(?:\n|$)/.exec(content);
  if (!header) return null;

  const bodyStart = header.index + header[0].length;
  const nextSection = /\n\[[^\]\n]+\]/g;
  nextSection.lastIndex = bodyStart;
  const next = nextSection.exec(content);
  const bodyEnd = next ? next.index : content.length;
  return {
    bodyStart,
    bodyEnd,
    body: content.slice(bodyStart, bodyEnd),
  };
}

function hasFeatureFlag(body: string, flagName: "hooks" | "codex_hooks", expectedTrue?: boolean): boolean {
  const line = body.split("\n").find((candidate) => {
    const equalIndex = candidate.indexOf("=");
    if (equalIndex < 0) return false;
    return candidate.slice(0, equalIndex).trim() === flagName;
  });
  if (!line) return false;
  if (expectedTrue === undefined) return true;
  const rawValue = line.slice(line.indexOf("=") + 1);
  const commentIndex = rawValue.indexOf("#");
  const value = commentIndex >= 0 ? rawValue.slice(0, commentIndex) : rawValue;
  return value.trim().toLowerCase() === "true";
}

/**
 * Minimalist TOML mutation for the single Codex hooks feature flag.
 *
 * Handles three cases:
 *   1. Flag already present and true  → no-op
 *   2. Flag present with different value  → replace the line
 *   3. [features] section missing  → append new section at end
 *   4. [features] exists, flag missing  → add line immediately after section header
 *
 * Uses text-based handling instead of a TOML parser because we only ever touch
 * this one key; a full round-trip TOML parser would rewrite user comments and
 * formatting we want to preserve.
 */
export async function ensureCodexHooksFeatureFlag() {
  const configPath = getCodexConfigPath();
  let raw = "";
  try {
    raw = await readFile(configPath, "utf8");
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code !== "ENOENT")
      return { ok: false, error: (error as NodeJS.ErrnoException).message, path: configPath };
  }

  // Work with LF-normalized text for regex consistency; re-emit as UTF-8 LF.
  const content = raw.replace(/\r\n/g, "\n");

  const section = findFeaturesSection(content);
  if (section && hasFeatureFlag(section.body, "hooks", true) && !hasFeatureFlag(section.body, "codex_hooks")) {
    return { ok: true, changed: false, path: configPath };
  }

  let updated;
  if (section) {
    const sectionPrefix = content.slice(0, section.bodyStart);
    const before = sectionPrefix.endsWith("\n") ? sectionPrefix : `${sectionPrefix}\n`;
    const after = content.slice(section.bodyEnd);
    let body = section.body.replace(/(^|\n)[ \t]*codex_hooks\s*=[^\n]*(?=\n|$)/g, "$1");
    if (hasFeatureFlag(body, "hooks")) {
      body = body.replace(/(^|\n)[ \t]*hooks\s*=[^\n]*/, "$1hooks = true");
    } else {
      body = `hooks = true\n${body}`;
    }
    updated = `${before}${body}${after}`;
  } else {
    // No [features] section — append
    const sep = content && !content.endsWith("\n") ? "\n" : "";
    updated = content + sep + (content ? "\n" : "") + "[features]\nhooks = true\n";
  }

  const dir = path.dirname(configPath);
  const tmpPath = configPath + ".strideterm-tmp";
  try {
    if (!existsSync(dir)) await mkdir(dir, { recursive: true });
    await writeFile(tmpPath, updated, "utf8");
    await rename(tmpPath, configPath);
    return { ok: true, changed: true, path: configPath };
  } catch (error) {
    await rm(tmpPath, { force: true }).catch(() => {});
    return { ok: false, error: (error as NodeJS.ErrnoException).message, path: configPath };
  }
}

/**
 * Check if Codex hooks are currently enabled in config.toml.
 * Returns true for the current `hooks = true` flag and the legacy
 * `codex_hooks = true` flag so old installs remain detectable.
 */
export async function isCodexHooksFeatureFlagEnabled(): Promise<boolean> {
  try {
    const raw = await readFile(getCodexConfigPath(), "utf8");
    const section = findFeaturesSection(raw.replace(/\r\n/g, "\n"));
    return (
      !!section && (hasFeatureFlag(section.body, "hooks", true) || hasFeatureFlag(section.body, "codex_hooks", true))
    );
  } catch {
    return false;
  }
}

async function isCurrentCodexHooksFeatureFlagEnabled(): Promise<boolean> {
  try {
    const raw = await readFile(getCodexConfigPath(), "utf8");
    const section = findFeaturesSection(raw.replace(/\r\n/g, "\n"));
    return !!section && hasFeatureFlag(section.body, "hooks", true);
  } catch {
    return false;
  }
}

/**
 * Configure Codex CLI lifecycle hooks.
 *
 * - Writes notify.mjs to <userDataPath>/hooks/notify.mjs (shared with Claude/Gemini)
 * - Enables [features] hooks = true in ~/.codex/config.toml (preserves other keys)
 * - Merges hook entries into ~/.codex/hooks.json (preserves user hooks)
 * - Idempotent: re-running replaces existing strIDEterm entries
 */
export async function configureCodexHook(userDataPath: string) {
  const scriptResult = await ensureNotifyScript(userDataPath);
  if (!scriptResult.ok) {
    return {
      ok: false,
      error: `Failed to write hook script: ${scriptResult.error}`,
      detail: "script-write-failed",
    };
  }

  const flagResult = await ensureCodexHooksFeatureFlag();
  if (!flagResult.ok) {
    return {
      ok: false,
      error: `Failed to write ${flagResult.path}: ${flagResult.error}`,
      detail: "config-write-failed",
    };
  }

  const readResult = await readHooksJson();
  if (!readResult.ok) {
    return {
      ok: false,
      error: `Cannot read ${readResult.path}: ${readResult.error}. Check if the file contains valid JSON.`,
      detail: "hooks-read-failed",
    };
  }

  const hooks = readResult.data || {};
  if (!hooks.hooks || typeof hooks.hooks !== "object") hooks.hooks = {};
  const hooksMap = hooks.hooks as Record<string, unknown[]>;

  const registered: string[] = [];
  for (const hookName of HOOKS_TO_REGISTER) {
    if (!Array.isArray(hooksMap[hookName])) hooksMap[hookName] = [];
    const entry = buildCodexHookEntry(scriptResult.path, hookName);
    const idx = findExistingHook(hooks, hookName);
    if (idx >= 0) hooksMap[hookName][idx] = entry;
    else hooksMap[hookName].push(entry);
    registered.push(hookName);
  }

  const writeResult = await writeHooksJson(hooks);
  if (!writeResult.ok) {
    return {
      ok: false,
      error: `Failed to write ${readResult.path}: ${writeResult.error}`,
      detail: "hooks-write-failed",
    };
  }

  log.info("codex hooks configured", {
    scriptPath: scriptResult.path,
    configPath: flagResult.path,
    hooksPath: readResult.path,
    featureFlagChanged: flagResult.changed,
    registered,
  });
  return {
    ok: true,
    scriptPath: scriptResult.path,
    configPath: flagResult.path,
    hooksPath: readResult.path,
    registered,
  };
}

/**
 * Remove strIDEterm hook entries from ~/.codex/hooks.json.
 * The hooks feature flag in config.toml is left alone — the user may
 * have their own hooks that rely on it.
 */
export async function removeCodexHook() {
  const readResult = await readHooksJson();
  if (!readResult.ok) {
    log.warn("removeCodexHook: cannot read hooks", { path: readResult.path, err: readResult.error });
    return { ok: false, error: `Cannot read ${readResult.path}: ${readResult.error}` };
  }
  if (!readResult.data) {
    log.debug("removeCodexHook: hooks file not found, nothing to remove");
    return { ok: true, removed: false };
  }

  const hooks = readResult.data;
  const removedFrom: string[] = [];
  const hooksMap2 = (hooks.hooks || {}) as Record<string, unknown[]>;
  const hookKeys = new Set([...HOOKS_TO_REGISTER, ...Object.keys(hooksMap2)]);

  for (const hookName of hookKeys) {
    const idx = findExistingHook(hooks, hookName);
    if (idx < 0) continue;
    hooksMap2[hookName].splice(idx, 1);
    removedFrom.push(hookName);
    if (hooksMap2[hookName].length === 0) delete hooksMap2[hookName];
  }

  if (removedFrom.length === 0) {
    log.debug("removeCodexHook: no strIDEterm hooks found in hooks.json");
    return { ok: true, removed: false };
  }
  if (Object.keys(hooksMap2).length === 0) delete hooks.hooks;

  const writeResult = await writeHooksJson(hooks);
  if (!writeResult.ok) {
    log.warn("removeCodexHook: failed to write hooks", { path: readResult.path, err: writeResult.error });
    return { ok: false, error: `Failed to write ${readResult.path}: ${writeResult.error}` };
  }

  log.info("codex hooks removed", { hooksPath: readResult.path, removedFrom });
  return { ok: true, removed: true, removedFrom };
}

/**
 * Detect current Codex hook configuration status.
 *
 * status:
 *   "configured"     — flag set, hooks registered, script exists
 *   "partial"        — some hooks registered but not all
 *   "flag-missing"   — hooks registered but the current hooks feature flag is not true
 *   "script-missing" — config looks right but notify.mjs is gone
 *   "not-configured" — no strIDEterm entries in hooks.json
 *   "error"          — could not read hooks.json (parse error etc.)
 */
export async function detectCodexHookStatus(userDataPath: string) {
  const configPath = getCodexConfigPath();
  const hooksPath = getCodexHooksPath();
  const scriptPath = getNotifyScriptPath(userDataPath);
  const scriptExists = existsSync(scriptPath);

  const readResult = await readHooksJson();
  if (!readResult.ok) {
    return { status: "error", error: readResult.error, configPath, hooksPath, scriptPath };
  }
  if (!readResult.data) {
    return { status: "not-configured", configPath, hooksPath, scriptPath };
  }

  const registered: string[] = [];
  const missing: string[] = [];
  for (const event of HOOKS_TO_REGISTER) {
    const idx = findExistingHook(readResult.data, event);
    if (idx >= 0) registered.push(event);
    else missing.push(event);
  }

  if (registered.length === 0) {
    return { status: "not-configured", configPath, hooksPath, scriptPath };
  }
  if (!scriptExists) {
    return { status: "script-missing", configPath, hooksPath, scriptPath, registered, missingHooks: missing };
  }

  const flagEnabled = await isCurrentCodexHooksFeatureFlagEnabled();
  if (!flagEnabled) {
    return {
      status: "flag-missing",
      configPath,
      hooksPath,
      scriptPath,
      registered,
      missingHooks: missing,
    };
  }

  if (missing.length > 0) {
    return { status: "partial", configPath, hooksPath, scriptPath, registered, missingHooks: missing };
  }
  return { status: "configured", configPath, hooksPath, scriptPath, registered };
}

export { HOOK_MARKERS };
