// tools/router-gate-decide.mjs
/**
 * Core decision module — router-gate v4 spec §4 (4 поведения) + §10.1.
 * Pure: вся behavior-логика allow/block/unlock. Safe-baseline metering, skill-scope,
 * Bash-content — отдельные слои в хуке (Stream B/G), поверх decide().
 *
 * NOTE on knownInRegistry: directInvocation.knownInRegistry===false is NOT handled
 * here. Caller (Stream G hook) MUST check knownInRegistry and emit AskUser (with
 * fuzzy-match suggestions) before calling decide(). decide() unlocks direct
 * invocations regardless of registry membership.
 */

export const SAFE_BASELINE_TOOLS = ['Read', 'Grep', 'Glob', 'LS', 'TodoWrite', 'AskUserQuestion'];
export const MUTATING_TOOLS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash', 'Skill', 'Task'];

const SAFE_SET = new Set(SAFE_BASELINE_TOOLS);
const MUT_SET = new Set(MUTATING_TOOLS);

export function isSafeBaselineTool(name) { return SAFE_SET.has(name); }
export function isMutatingTool(name) { return MUT_SET.has(name); }

function normName(n) { return String(n || '').trim().toLowerCase(); }
function suffixMatch(a, b) {
  const x = normName(a); const y = normName(b);
  return x === y || x.endsWith(':' + y) || y.endsWith(':' + x);
}

export function nodeMatches(recommendation, toolUse, resolveAlias = (x) => x) {
  if (!recommendation || !toolUse) return false;
  const resolved = resolveAlias(recommendation);
  if (toolUse.name === 'Skill') {
    return suffixMatch(resolved, toolUse.input?.skill);
  }
  if (toolUse.name === 'Task') {
    return suffixMatch(resolved, toolUse.input?.subagent_type);
  }
  return false;
}

export const DIRECT_INVOCATION_PATTERNS = [
  { re: /^\/([a-z0-9_-]+)(\s|$)/iu, type: 'slash', group: 1 },
  // Fix 1: left boundary (?<![\p{L}\p{N}_]) guards against fused-word matches (e.g. вообщевызови).
  // Fix 2: polite imperatives вызовите/примените added alongside вызови/примени.
  { re: /(?<![\p{L}\p{N}_])(?:вызови|вызовите|примени|примените)\s+Skill\(([^)]+)\)/iu, type: 'skill_call', group: 1 },
  { re: /использу(?:й|йте)\s+#(\d+)/iu, type: 'registry_num', group: 1 },
  // Fix 1: left boundary on Cyrillic-leading literal_name pattern (mirrors todowrite-skill-verifier).
  { re: /(?<![\p{L}\p{N}_])(?:делай|сделай|вызови|примени|используй)\s+([a-z][a-z0-9:-]+)/iu, type: 'literal_name', group: 1 },
];

export function detectDirectInvocation(userPrompt, opts = {}) {
  const { registryHas = () => true } = opts;
  const text = String(userPrompt || '');
  for (const { re, type, group } of DIRECT_INVOCATION_PATTERNS) {
    const m = text.match(re);
    if (m) {
      const name = m[group];
      const known = type === 'registry_num' ? registryHas('#' + name) : registryHas(name);
      return { matched: true, type, name, knownInRegistry: known };
    }
  }
  return { matched: false };
}

// Used by Stream G hook layer pre-decide(); decide() does not call this.
export function crossCheckSelfSuggested(directName, recentControllerResponses) {
  const target = normName(directName);
  const patterns = [
    /Skill\(([^)]+)\)/giu,
    /(?:делай|используй|вызови)\s+([a-z][a-z0-9:-]+)/giu,
    /\/([a-z0-9_-]+)/giu,
  ];
  for (const resp of recentControllerResponses || []) {
    for (const re of patterns) {
      for (const m of String(resp).matchAll(re)) {
        if (suffixMatch(target, m[1])) return { selfSuggested: true };
      }
    }
  }
  return { selfSuggested: false };
}

const DAY_MS = 86_400_000;

export function newChainState(chain, nowMs) {
  const iso = new Date(nowMs).toISOString();
  return {
    schema_version: 1,
    chain_active: chain,
    chain_step: 0,
    initialized_at: iso,
    last_step_at: iso,
    expires_at: new Date(nowMs + DAY_MS).toISOString(),
  };
}

export function isChainExpired(chainState, nowMs) {
  if (!chainState?.initialized_at) return false;
  return nowMs - new Date(chainState.initialized_at).getTime() > DAY_MS;
}

export function chainStateUpdate(chainState, matchedNode, nowMs) {
  const expected = chainState.chain_active?.[chainState.chain_step];
  if (matchedNode && expected && suffixMatch(matchedNode, expected)) {
    return {
      ...chainState,
      chain_step: chainState.chain_step + 1,
      last_step_at: new Date(nowMs).toISOString(),
      // initialized_at UNCHANGED — anti-tickle (v4.0 C19)
    };
  }
  return chainState;
}

/**
 * Core 4-behaviour decision function (§4.1-§4.4 spec).
 * NOTE: directInvocation.knownInRegistry===false is NOT handled here. Caller
 * (Stream G hook) MUST check knownInRegistry and emit AskUser (with fuzzy-match
 * suggestions) before calling decide(). decide() unlocks direct invocations
 * regardless of registry membership.
 */
export function decide(ctx) {
  const {
    classification = {}, turnState = {}, toolUse = {},
    directInvocation = null, chainState = null, nowMs = Date.now(),
    resolveAlias = (x) => x,
  } = ctx;
  const recNode = classification.recommended_node ?? null;
  const recChain = classification.recommended_chain ?? [];
  const { askuser_called = false, skill_invoked_matching = false } = turnState;
  const toolName = toolUse.name;

  // Поведение 1 — direct invocation precedence
  if (directInvocation?.matched) {
    if (toolName === 'Skill' || toolName === 'Task') {
      return { decision: 'unlock', reason: 'direct invocation', behavior_branch: '1_direct_invocation' };
    }
    return { decision: 'allow', reason: 'direct invocation precedence', behavior_branch: '1_direct_invocation' };
  }

  // Поведение 3 — chain active
  if (recChain.length >= 1 || chainState) {
    if (chainState && isChainExpired(chainState, nowMs)) {
      return { decision: 'block', reason: 'chain expired 24h from initialization (anti-tickle)', behavior_branch: '3_chain' };
    }
    const expectedNode = chainState?.chain_active?.[chainState?.chain_step] ?? recChain[0];
    if ((toolName === 'Skill' || toolName === 'Task') && nodeMatches(expectedNode, toolUse, resolveAlias)) {
      return { decision: 'unlock', reason: 'chain step match', behavior_branch: '3_chain' };
    }
    if (isSafeBaselineTool(toolName)) {
      return { decision: 'allow', reason: 'safe-baseline in chain', behavior_branch: '3_chain' };
    }
    if (askuser_called) {
      return { decision: 'allow', reason: 'askuser answered', behavior_branch: '3_chain' };
    }
    return { decision: 'block', reason: 'chain active — вызови AskUserQuestion для следующего шага', behavior_branch: '3_chain' };
  }

  // Поведение 2 — single recommendation
  if (recNode !== null && recChain.length === 0) {
    if (skill_invoked_matching) {
      return { decision: 'allow', reason: 'skill invoked matching', behavior_branch: '2_single_rec' };
    }
    if (askuser_called) {
      return { decision: 'allow', reason: 'askuser answered', behavior_branch: '2_single_rec' };
    }
    if ((toolName === 'Skill' || toolName === 'Task') && nodeMatches(recNode, toolUse, resolveAlias)) {
      return { decision: 'unlock', reason: 'skill matches rec_node', behavior_branch: '2_single_rec' };
    }
    if (isSafeBaselineTool(toolName)) {
      return { decision: 'allow', reason: 'safe-baseline', behavior_branch: '2_single_rec' };
    }
    return { decision: 'block', reason: `Router рекомендовал ${recNode}, вызови AskUserQuestion с предложениями`, behavior_branch: '2_single_rec' };
  }

  // Поведение 4 — silence
  if (isSafeBaselineTool(toolName)) {
    return { decision: 'allow', reason: 'safe-baseline (silence)', behavior_branch: '4_silence' };
  }
  if (askuser_called) {
    return { decision: 'allow', reason: 'askuser answered (silence)', behavior_branch: '4_silence' };
  }
  return { decision: 'block', reason: 'AskUserQuestion required для mutating tool (silence)', behavior_branch: '4_silence' };
}
