/**
 * @file Matches hook events against pipeline-defined stage-detection rules.
 * Inputs and templates are untrusted, so each exported function is total and
 * compiled regular expressions are cached once for each pipeline object.
 * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
 */

const compiledPipelines = new WeakMap();

// Only fields that identify WHAT a tool did, never editor payload like
// old_string/new_string, which can be whole code blocks.
const FLATTEN_KEYS = new Set([
  "command",
  "file_path",
  "skill",
  "prompt",
  "pattern",
  "description",
  "subagent_type",
]);

const SIGNAL_MAX = 120;

/** Collapse whitespace and cap to a bounded, readable length. */
function capSignal(text) {
  const collapsed = text.replace(/\s+/g, " ").trim();
  return collapsed.length > SIGNAL_MAX ? `${collapsed.slice(0, SIGNAL_MAX)}…` : collapsed;
}

/** Characters of surrounding context kept on each side of a matched span. */
const SIGNAL_CONTEXT = 24;

/**
 * The span the rule actually matched, plus a little context — the whole
 * flattened input is usually a long shell line whose interesting part is a few
 * words in the middle (`cd /very/long/path && npm run test:server 2>&1 | tail`).
 * A rule with no regex matched on the tool name alone and has no span, so the
 * caller keeps the flattened input.
 */
function matchedSpan(regex, input) {
  if (!regex) return null;
  // `regex` may carry /g from a user template; lastIndex would make exec()
  // stateful across calls, so search from a known position every time.
  regex.lastIndex = 0;
  const m = regex.exec(input);
  if (!m || typeof m.index !== "number") return null;
  const start = Math.max(0, m.index - SIGNAL_CONTEXT);
  const end = Math.min(input.length, m.index + m[0].length + SIGNAL_CONTEXT);
  const prefix = start > 0 ? "…" : "";
  const suffix = end < input.length ? "…" : "";
  return `${prefix}${input.slice(start, end)}${suffix}`;
}

/** Return known identifying string fields from a tool input without recursively walking it. */
function flattenInput(toolInput) {
  try {
    if (typeof toolInput === "string") return toolInput;
    if (toolInput === null || typeof toolInput !== "object") return "";

    const strings = [];
    if (Array.isArray(toolInput)) {
      for (const value of toolInput) {
        if (typeof value === "string") strings.push(value);
        else if (value && typeof value === "object") {
          for (const [key, nestedValue] of Object.entries(value)) {
            if (FLATTEN_KEYS.has(key) && typeof nestedValue === "string") strings.push(nestedValue);
          }
        }
      }
    } else {
      for (const [key, value] of Object.entries(toolInput)) {
        if (FLATTEN_KEYS.has(key) && typeof value === "string") strings.push(value);
      }
    }
    return strings.join(" ");
  } catch {
    return "";
  }
}

/** Compile valid node rules once, omitting malformed nodes and regex patterns. */
function compileRules(pipeline) {
  if (!pipeline || typeof pipeline !== "object") return [];
  const cached = compiledPipelines.get(pipeline);
  if (cached) return cached;

  let nodes = [];
  try {
    nodes = Array.isArray(pipeline.nodes) ? pipeline.nodes : [];
  } catch {
    return [];
  }

  const compiled = nodes.map((node) => {
    const rules = [];
    try {
      const detectRules = Array.isArray(node && node.detect) ? node.detect : [];
      for (const rule of detectRules) {
        if (!rule || typeof rule !== "object" || typeof rule.tool !== "string") continue;
        if (rule.match === undefined) {
          rules.push({ tool: rule.tool, regex: null });
          continue;
        }
        if (typeof rule.match !== "string") continue;
        try {
          rules.push({ tool: rule.tool, regex: new RegExp(rule.match) });
        } catch {
          // A user-supplied invalid regex must never block hook ingestion.
        }
      }
    } catch {
      // Ignore a malformed node while preserving the remaining template.
    }
    return { nodeId: node && node.id, rules };
  });

  compiledPipelines.set(pipeline, compiled);
  return compiled;
}

/** Infer the last matching pipeline stage from one hook event, or return null. */
function detect(pipeline, event) {
  try {
    if (!event || typeof event.tool_name !== "string" || !event.tool_name) return null;
    const input = flattenInput(event.tool_input);
    const fallback = input || event.tool_name;
    let match = null;

    for (const node of compileRules(pipeline)) {
      if (typeof node.nodeId !== "string" || !node.nodeId) continue;
      for (const rule of node.rules) {
        if (rule.tool !== event.tool_name) continue;
        if (!rule.regex || rule.regex.test(input)) {
          const span = matchedSpan(rule.regex, input);
          match = { nodeId: node.nodeId, signal: `\`${capSignal(span || fallback)}\`` };
          break;
        }
      }
    }
    return match;
  } catch {
    return null;
  }
}

module.exports = { flattenInput, compileRules, detect };
