'use strict';

const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFile } = require('node:child_process');

/**
 * User-defined lifecycle hooks for the coding agent, loaded from
 * `<projectRoot>/.walle/hooks.json` and `~/.walle/hooks.json`.
 *
 * Config shape (either a bare array or `{ "hooks": [...] }`):
 *   {
 *     "event":      "pre_tool" | "post_tool" | "stop" | "pre_compact" | "session_start",
 *     "matcher":    "run_shell|edit_file",   // optional regex on tool name
 *     "command":    "./scripts/check.sh",    // shell hook: payload JSON on stdin
 *     "js":         "./hooks/check.js",      // OR a JS module exporting async (payload) => verdict
 *     "timeout_ms": 10000,
 *     "failMode":   "open" | "closed"        // hook crash/timeout: continue (default) or deny
 *   }
 *
 * Shell protocol: exit 0 = continue (stdout may be JSON `{updatedInput}` for
 * pre_tool, `{reason}` for context); exit 2 = deny, with the reason on stderr
 * (or stdout). Any other failure follows failMode.
 *
 * `stop` deny means "the agent may not finish yet" — the orchestrator feeds
 * the reason back to the model and continues the loop (bounded).
 */

const USER_HOOK_EVENTS = Object.freeze(['pre_tool', 'post_tool', 'stop', 'pre_compact', 'session_start']);
const DEFAULT_HOOK_TIMEOUT_MS = 10000;
const DENY_EXIT_CODE = 2;
const MAX_HOOK_OUTPUT = 1024 * 1024;

function loadHookConfigs({ projectRoot = '', homeDir = os.homedir() } = {}) {
  const hooks = [];
  const files = [];
  if (homeDir) files.push(path.join(homeDir, '.walle', 'hooks.json'));
  if (projectRoot) {
    const projectFile = path.resolve(projectRoot, '.walle', 'hooks.json');
    if (!files.includes(projectFile)) files.push(projectFile);
  }
  for (const file of files) {
    let raw;
    try {
      raw = fs.readFileSync(file, 'utf8');
    } catch {
      continue; // missing config is the normal case
    }
    try {
      const parsed = JSON.parse(raw);
      const entries = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.hooks) ? parsed.hooks : [];
      for (const entry of entries) {
        if (!entry || typeof entry !== 'object') continue;
        if (!USER_HOOK_EVENTS.includes(entry.event)) continue;
        if (!entry.command && !entry.js) continue;
        hooks.push({ ...entry, source: file });
      }
    } catch (err) {
      console.warn(`[user-hooks] Ignoring invalid hooks config ${file}: ${err.message}`);
    }
  }
  return hooks;
}

class UserHooks {
  constructor({ hooks = [], cwd = process.cwd(), env = process.env, logger = console } = {}) {
    this.hooks = hooks;
    this.cwd = cwd;
    this.env = env;
    this.logger = logger;
  }

  hasHooks(event) {
    return this.hooks.some((hook) => hook.event === event);
  }

  _matching(event, payload = {}) {
    return this.hooks.filter((hook) => {
      if (hook.event !== event) return false;
      if (!hook.matcher) return true;
      if (!payload.tool) return true;
      try {
        return new RegExp(hook.matcher).test(payload.tool);
      } catch {
        return false;
      }
    });
  }

  /**
   * Run all matching hooks sequentially. First deny short-circuits.
   * @returns {Promise<{decision: 'allow'|'deny', reason: string, updatedInput: object|null}>}
   */
  async run(event, payload = {}) {
    const verdict = { decision: 'allow', reason: '', updatedInput: null };
    for (const hook of this._matching(event, payload)) {
      let outcome;
      try {
        outcome = hook.js
          ? await this._runJsHook(hook, { event, ...payload })
          : await this._runShellHook(hook, { event, ...payload });
      } catch (err) {
        if (hook.failMode === 'closed') {
          return { decision: 'deny', reason: `Hook failed (failMode=closed): ${err.message}`, updatedInput: null };
        }
        this.logger?.warn?.(`[user-hooks] ${event} hook from ${hook.source || 'config'} failed open: ${err.message}`);
        continue;
      }
      if (outcome?.decision === 'deny') {
        return { decision: 'deny', reason: outcome.reason || 'denied by user hook', updatedInput: null };
      }
      if (outcome?.updatedInput && typeof outcome.updatedInput === 'object') {
        verdict.updatedInput = outcome.updatedInput;
      }
    }
    return verdict;
  }

  /** Like run() but never denies — for observational events (post_tool). */
  async runObserved(event, payload = {}) {
    try {
      await this.run(event, payload);
    } catch (err) {
      this.logger?.warn?.(`[user-hooks] ${event} hook error ignored: ${err.message}`);
    }
  }

  async _runJsHook(hook, payload) {
    const base = hook.source ? path.dirname(hook.source) : this.cwd;
    const modulePath = path.isAbsolute(hook.js) ? hook.js : path.resolve(base, hook.js);
    const mod = require(modulePath);
    const handler = typeof mod === 'function' ? mod : mod?.handler;
    if (typeof handler !== 'function') throw new Error(`JS hook ${modulePath} does not export a function`);
    const result = await withTimeout(
      Promise.resolve(handler(payload)),
      hook.timeout_ms || DEFAULT_HOOK_TIMEOUT_MS,
      `JS hook ${hook.js}`
    );
    return normalizeVerdict(result);
  }

  _runShellHook(hook, payload) {
    const timeout = hook.timeout_ms || DEFAULT_HOOK_TIMEOUT_MS;
    return new Promise((resolve, reject) => {
      const child = execFile('/bin/sh', ['-c', hook.command], {
        cwd: this.cwd,
        env: { ...this.env, WALLE_HOOK_EVENT: payload.event || hook.event },
        timeout,
        maxBuffer: MAX_HOOK_OUTPUT,
      }, (err, stdout, stderr) => {
        if (err && err.code === DENY_EXIT_CODE) {
          resolve({
            decision: 'deny',
            reason: String(stderr || stdout || '').trim() || `hook ${hook.command} denied`,
          });
          return;
        }
        if (err) {
          reject(new Error(err.killed ? `hook timed out after ${timeout}ms` : (String(stderr || '').trim() || err.message)));
          return;
        }
        resolve(parseShellOutput(stdout));
      });
      try {
        child.stdin.write(JSON.stringify(payload));
        child.stdin.end();
      } catch {
        // hook may not read stdin — fine
      }
    });
  }
}

function parseShellOutput(stdout) {
  const text = String(stdout || '').trim();
  if (!text) return { decision: 'allow' };
  try {
    return normalizeVerdict(JSON.parse(text));
  } catch {
    return { decision: 'allow' };
  }
}

function normalizeVerdict(value) {
  if (!value || typeof value !== 'object') return { decision: 'allow' };
  return {
    decision: value.decision === 'deny' ? 'deny' : 'allow',
    reason: String(value.reason || ''),
    updatedInput: (value.updatedInput && typeof value.updatedInput === 'object') ? value.updatedInput : null,
  };
}

function withTimeout(promise, ms, label) {
  let timer;
  return Promise.race([
    promise,
    new Promise((_, rejectRace) => {
      timer = setTimeout(() => rejectRace(new Error(`${label} timed out after ${ms}ms`)), ms);
    }),
  ]).finally(() => clearTimeout(timer));
}

function createUserHooks({ projectRoot = '', cwd = '', env = process.env, logger = console } = {}) {
  if (process.env.WALLE_USER_HOOKS === '0') return null;
  const hooks = loadHookConfigs({ projectRoot: projectRoot || cwd });
  if (hooks.length === 0) return null;
  return new UserHooks({ hooks, cwd: cwd || projectRoot || process.cwd(), env, logger });
}

module.exports = {
  UserHooks,
  createUserHooks,
  loadHookConfigs,
  USER_HOOK_EVENTS,
  DEFAULT_HOOK_TIMEOUT_MS,
};
