import type { DagContextBudget, DagNode, DagNodeDefinition, DagNodeRouting } from "./dag.js";
import { existsSync, readdirSync, readFileSync } from "fs";
import { homedir } from "os";
import { isAbsolute, join, relative, resolve, sep } from "path";
import { normalizeUserHomePath } from "../util/fs.js";
import { assignSkills } from "./skill-assigner.js";
import {
  OMK_RELEASE_GUARD_PRESET,
  OMK_TS_PRODUCT_PRESET,
  OMK_WORKTREE_TEAM_PRESET,
} from "../runtime/core-verified-preset.js";

type RouteKind = "skill" | "mcp" | "tool" | "hook";
type RouteSource = "project" | "global" | "builtin";
type WriteRisk = "none" | "low" | "high";

interface RouteCandidate {
  kind: RouteKind;
  id: string;
  source: RouteSource;
  roles: string[];
  keywords: string[];
  readOnly: boolean;
  writeRisk: WriteRisk;
  contextCost: 1 | 2 | 3;
  capabilities: string[];
}

interface RouteScoreFeatures {
  roleMatch: number;
  keywordScore: number;
  evidenceFit: number;
  contextFit: number;
  safetyFit: number;
  keywordMatches: number;
}

interface RouteScoreTrace {
  id: string;
  kind: RouteKind;
  source: RouteSource;
  baseScore: number;
  sourcePrior: number;
  score: number;
  features: RouteScoreFeatures;
  reason: string;
}

interface ScoredRoute {
  candidate: RouteCandidate;
  score: number;
  baseScore: number;
  sourcePrior: number;
  features: RouteScoreFeatures;
  reason: string;
}

export type RoutingInput = Pick<DagNodeDefinition, "id" | "name" | "role" | "inputs" | "outputs" | "cost" | "routing">;

const ROUTE_SCORE_WEIGHTS = {
  role: 0.30,
  keyword: 0.25,
  evidence: 0.20,
  context: 0.15,
  safety: 0.10,
} as const;

const ROUTE_SOURCE_PRIOR: Record<RouteSource, number> = {
  project: 0.02,
  builtin: 0.01,
  global: 0,
};

const ROUTE_SCORE_SCALE = 1_000_000;

const ROUTE_BASE_SCORE_MIN_GAP = computeRouteBaseScoreMinGap(ROUTE_SCORE_WEIGHTS);

assertRoutePriorTiebreakInvariant(ROUTE_SOURCE_PRIOR, ROUTE_BASE_SCORE_MIN_GAP);

const MAX_SKILLS = 3;
const MAX_MCP_SERVERS = 2;
const MAX_TOOLS = 4;
const MAX_HOOKS = 4;
const OMK_PROJECT_TOOLS = [
  "omk_search_memory",
  "omk_read_memory",
  "omk_memory_mindmap",
  "omk_graph_query",
  "omk_list_agents",
  "omk_read_agent",
  "omk_list_runs",
  "omk_read_run",
  "omk_run_quality_gate",
  "omk_save_checkpoint",
  "omk_list_checkpoints",
  "omk_search_snippets",
  "omk_get_snippet",
];

export interface RoutingInventory {
  skills: Map<string, RouteSource>;
  mcpServers: Map<string, RouteSource>;
  hooks: Map<string, RouteSource>;
  tools: Set<string>;
  diagnostics: RoutingDiagnostic[];
  skillsScope: "project" | "all" | "none";
  mcpScope: "project" | "all" | "none";
  hooksScope: "project" | "all" | "none";
}

export interface RoutingDiagnostic {
  kind: "mcp-config";
  source: "project" | "global";
  path: string;
  message: string;
}

let inventoryCache: { key: string; value: RoutingInventory } | undefined;

const ROUTE_CANDIDATES: RouteCandidate[] = [
  {
    kind: "skill",
    id: "omk-repo-explorer",
    source: "project",
    roles: ["explorer", "researcher", "architect", "planner"],
    keywords: ["repo", "repository", "file", "symbol", "grep", "search", "map", "inspect", "discover", "코드", "검색", "탐색"],
    readOnly: false,
    writeRisk: "low",
    contextCost: 1,
    capabilities: ["repo-map", "symbol-search", "small-context"],
  },
  {
    kind: "skill",
    id: "omk-industrial-control-loop",
    source: "project",
    roles: ["planner", "architect", "orchestrator", "router"],
    keywords: ["dag", "dependency", "graph", "workflow", "orchestration", "scheduler", "agent", "앙상블", "에이전트"],
    readOnly: true,
    writeRisk: "none",
    contextCost: 1,
    capabilities: ["dag-design", "dependency-check", "quality-gate-shape"],
  },
  {
    kind: "skill",
    id: "omk-quality-gate",
    source: "project",
    roles: ["qa", "reviewer", "verifier", "tester"],
    keywords: ["quality", "gate", "test", "lint", "build", "typecheck", "verify", "검증", "테스트"],
    readOnly: false,
    writeRisk: "low",
    contextCost: 1,
    capabilities: ["verification", "test-command-selection"],
  },
  {
    kind: "skill",
    id: "omk-test-debug-loop",
    source: "project",
    roles: ["debugger", "tester", "coder"],
    keywords: ["bug", "error", "failure", "regression", "debug", "fix", "버그", "오류", "실패"],
    readOnly: false,
    writeRisk: "low",
    contextCost: 2,
    capabilities: ["debug-loop", "regression-tests"],
  },
  {
    kind: "skill",
    id: "omk-code-review",
    source: "project",
    roles: ["reviewer", "architect", "verifier"],
    keywords: ["review", "audit", "risk", "maintainability", "품질", "리뷰"],
    readOnly: true,
    writeRisk: "none",
    contextCost: 1,
    capabilities: ["code-review", "risk-check"],
  },
  {
    kind: "skill",
    id: "omk-security-review",
    source: "project",
    roles: ["security", "reviewer"],
    keywords: ["security", "secret", "auth", "permission", "token", "credential", "보안", "시크릿"],
    readOnly: true,
    writeRisk: "none",
    contextCost: 1,
    capabilities: ["security-review", "trust-boundary"],
  },
  {
    kind: "skill",
    id: "omk-secret-guard",
    source: "project",
    roles: ["security", "reviewer"],
    keywords: ["secret", "token", "credential", "env", "api key", "시크릿", "키"],
    readOnly: true,
    writeRisk: "none",
    contextCost: 1,
    capabilities: ["secret-hygiene"],
  },
  {
    kind: "skill",
    id: "omk-research-verify",
    source: "project",
    roles: ["researcher", "architect", "planner"],
    keywords: ["paper", "docs", "official", "current", "api", "research", "논문", "문서", "공식"],
    readOnly: true,
    writeRisk: "none",
    contextCost: 2,
    capabilities: ["official-docs", "citation-backed"],
  },
  {
    kind: "skill",
    id: "omk-context-broker",
    source: "project",
    roles: ["router", "planner", "architect"],
    keywords: ["context", "memory", "handoff", "checkpoint", "small context", "컨텍스트", "메모리"],
    readOnly: true,
    writeRisk: "none",
    contextCost: 1,
    capabilities: ["context-pruning", "handoff"],
  },
  {
    kind: "skill",
    id: "omk-typescript-strict",
    source: "project",
    roles: ["coder", "reviewer"],
    keywords: ["typescript", "ts", "type", "strict", "타입"],
    readOnly: false,
    writeRisk: "low",
    contextCost: 1,
    capabilities: ["typescript", "strict-types"],
  },
  {
    kind: "skill",
    id: "omk-frontend-implementation",
    source: "project",
    roles: ["coder", "designer"],
    keywords: ["frontend", "ui", "component", "react", "design", "폼", "화면"],
    readOnly: false,
    writeRisk: "low",
    contextCost: 2,
    capabilities: ["frontend-implementation"],
  },
  {
    kind: "skill",
    id: "omk-design-system",
    source: "project",
    roles: ["designer", "reviewer"],
    keywords: ["design", "visual", "brand", "token", "ui", "디자인"],
    readOnly: true,
    writeRisk: "none",
    contextCost: 1,
    capabilities: ["design-system"],
  },
  {
    kind: "mcp",
    id: "omk-project",
    source: "project",
    roles: ["router", "planner", "qa", "reviewer", "explorer"],
    keywords: ["memory", "quality", "run", "agent", "config", "checkpoint", "worktree", "mcp"],
    readOnly: false,
    writeRisk: "low",
    contextCost: 1,
    capabilities: ["project-memory", "quality-gate", "run-state", "agent-registry"],
  },
  {
    kind: "tool",
    id: "omk_search_memory",
    source: "project",
    roles: ["router", "planner", "explorer"],
    keywords: ["memory", "prior", "previous", "recall", "메모리", "이전"],
    readOnly: true,
    writeRisk: "none",
    contextCost: 1,
    capabilities: ["memory-search"],
  },
  {
    kind: "tool",
    id: "omk_run_quality_gate",
    source: "project",
    roles: ["qa", "verifier", "reviewer"],
    keywords: ["quality", "gate", "lint", "typecheck", "test", "build", "검증"],
    readOnly: false,
    writeRisk: "low",
    contextCost: 1,
    capabilities: ["quality-gate"],
  },
  {
    kind: "tool",
    id: "SearchWeb",
    source: "builtin",
    roles: ["researcher", "architect", "planner"],
    keywords: ["paper", "official", "docs", "current", "latest", "논문", "최신", "공식"],
    readOnly: true,
    writeRisk: "none",
    contextCost: 2,
    capabilities: ["external-research"],
  },
  {
    kind: "tool",
    id: "FetchURL",
    source: "builtin",
    roles: ["researcher", "architect", "planner"],
    keywords: ["paper", "official", "docs", "source", "citation", "논문", "문서"],
    readOnly: true,
    writeRisk: "none",
    contextCost: 2,
    capabilities: ["source-reading"],
  },
  {
    kind: "hook",
    id: "awesome-agent-skills-router.sh",
    source: "project",
    roles: ["router", "orchestrator", "planner"],
    keywords: ["prompt", "route", "skill", "agent", "orchestration", "hook", "프롬프트", "라우팅", "에이전트"],
    readOnly: true,
    writeRisk: "none",
    contextCost: 1,
    capabilities: ["prompt-routing", "skill-activation"],
  },
  {
    kind: "hook",
    id: "subagent-stop-audit.sh",
    source: "project",
    roles: ["reviewer", "qa", "verifier", "orchestrator"],
    keywords: ["subagent", "agent", "audit", "review", "verify", "parallel", "병렬", "검증"],
    readOnly: true,
    writeRisk: "none",
    contextCost: 1,
    capabilities: ["subagent-audit", "completion-check"],
  },
  {
    kind: "hook",
    id: "stop-verify.sh",
    source: "project",
    roles: ["reviewer", "qa", "verifier"],
    keywords: ["stop", "verify", "quality", "gate", "test", "검증", "테스트"],
    readOnly: true,
    writeRisk: "none",
    contextCost: 1,
    capabilities: ["stop-verification", "quality-gate"],
  },
  {
    kind: "hook",
    id: "protect-secrets.sh",
    source: "project",
    roles: ["security", "reviewer", "coder"],
    keywords: ["secret", "token", "credential", "env", "write", "edit", "보안", "시크릿"],
    readOnly: false,
    writeRisk: "low",
    contextCost: 1,
    capabilities: ["secret-protection"],
  },
  {
    kind: "hook",
    id: "pre-shell-guard.sh",
    source: "project",
    roles: ["security", "coder", "qa"],
    keywords: ["shell", "command", "guard", "danger", "rm", "security", "명령", "보안"],
    readOnly: false,
    writeRisk: "low",
    contextCost: 1,
    capabilities: ["shell-guard"],
  },
  {
    kind: "hook",
    id: "post-format.sh",
    source: "project",
    roles: ["coder", "qa", "reviewer"],
    keywords: ["format", "write", "edit", "typescript", "lint", "코드", "수정"],
    readOnly: false,
    writeRisk: "low",
    contextCost: 1,
    capabilities: ["post-format"],
  },
];

export function selectTaskRouting(input: RoutingInput): DagNodeRouting {
  const inventory = discoverRoutingInventory();
  const text = normalizeText([
    input.id,
    input.name,
    input.role,
    ...(input.inputs ?? []).flatMap((item) => [item.name, item.ref, item.from ?? ""]),
    ...(input.outputs ?? []).flatMap((item) => [item.name, item.ref ?? "", item.gate ?? ""]),
  ].join(" "));
  const readOnly = inferReadOnly(input, text);
  const evidenceRequired = inferEvidenceRequired(input, text);
  const contextBudget = inferContextBudget(input, readOnly, evidenceRequired);
  const rejected: Array<{ id: string; reason: string }> = inventory.diagnostics
    .slice(0, 3)
    .map((diagnostic) => ({
      id: `mcp-config:${diagnostic.source}`,
      reason: `${diagnostic.path}: ${diagnostic.message}`,
    }));

  const scored = routeCandidates(inventory).flatMap((candidate): ScoredRoute[] => {
    const rejectReason = rejectionReason(candidate, readOnly, contextBudget, inventory);
    if (rejectReason) {
      rejected.push({ id: candidate.id, reason: rejectReason });
      return [];
    }
    const scoredRoute = scoreRoute(candidate, input.role, text, evidenceRequired, contextBudget);
    return scoredRoute.score > 0 ? [scoredRoute] : [];
  }).sort((a, b) => b.score - a.score || a.candidate.contextCost - b.candidate.contextCost || a.candidate.id.localeCompare(b.candidate.id));

  const skills = unique(scored.filter((item) => item.candidate.kind === "skill").map((item) => item.candidate.id)).slice(0, limitForBudget(contextBudget, MAX_SKILLS));
  const mcpServers = unique(scored.filter((item) => item.candidate.kind === "mcp").map((item) => item.candidate.id)).slice(0, limitForBudget(contextBudget, MAX_MCP_SERVERS));
  const tools = unique(scored.filter((item) => item.candidate.kind === "tool").map((item) => item.candidate.id)).slice(0, limitForBudget(contextBudget, MAX_TOOLS));
  const hooks = unique(scored.filter((item) => item.candidate.kind === "hook").map((item) => item.candidate.id)).slice(0, limitForBudget(contextBudget, MAX_HOOKS));

  if (skills.length === 0 && readOnly && inventory.skills.has("omk-repo-explorer")) skills.push("omk-repo-explorer");
  if (skills.length === 0 && inventory.skills.has("omk-context-broker")) skills.push("omk-context-broker");
  addDefaultHookHints(hooks, inventory, input.role, readOnly, evidenceRequired);

  // Merge with skill-assigner auto-assignment
  const autoAssignment = assignSkills(input);
  const scopedAutoSkills = autoAssignment.skills.filter((skill) => inventory.skills.has(skill));
  const scopedAutoMcpServers = autoAssignment.mcpServers.filter((server) => inventory.mcpServers.has(server));
  const scopedAutoHooks = autoAssignment.hooks.filter((hook) => inventory.hooks.has(hook));
  const releaseGuardRequired = autoAssignment.rationale.includes(`[${OMK_RELEASE_GUARD_PRESET.id}]`);
  const tsProductRequired = autoAssignment.rationale.includes(`[${OMK_TS_PRODUCT_PRESET.id}]`);
  const worktreeRequired = autoAssignment.rationale.includes(`[${OMK_WORKTREE_TEAM_PRESET.id}]`);

  function requiredPresetSkills(): readonly string[] | undefined {
    const presets: string[][] = [];
    if (releaseGuardRequired) presets.push(OMK_RELEASE_GUARD_PRESET.skills);
    if (tsProductRequired) presets.push(OMK_TS_PRODUCT_PRESET.skills);
    if (worktreeRequired) presets.push(OMK_WORKTREE_TEAM_PRESET.skills);
    const active = unique(presets.flat()).filter((skill) => inventory.skills.has(skill));
    return active.length > 0 ? active : undefined;
  }
  function requiredPresetMcpServers(): readonly string[] | undefined {
    const presets: string[][] = [];
    if (releaseGuardRequired) presets.push(OMK_RELEASE_GUARD_PRESET.mcpServers);
    if (tsProductRequired) presets.push(OMK_TS_PRODUCT_PRESET.mcpServers);
    if (worktreeRequired) presets.push(OMK_WORKTREE_TEAM_PRESET.mcpServers);
    const active = unique(presets.flat()).filter((server) => inventory.mcpServers.has(server));
    return active.length > 0 ? active : undefined;
  }
  function requiredPresetHooks(): readonly string[] | undefined {
    const presets: string[][] = [];
    if (releaseGuardRequired) presets.push(OMK_RELEASE_GUARD_PRESET.hooks);
    if (tsProductRequired) presets.push(OMK_TS_PRODUCT_PRESET.hooks);
    if (worktreeRequired) presets.push(OMK_WORKTREE_TEAM_PRESET.hooks);
    const active = unique(presets.flat()).filter((hook) => inventory.hooks.has(hook));
    return active.length > 0 ? active : undefined;
  }

  const mergedSkills = mergeBoundedRoutes(
    skills,
    scopedAutoSkills,
    contextBudget,
    MAX_SKILLS,
    requiredPresetSkills()
  );
  const mergedMcpServers = mergeBoundedRoutes(
    mcpServers,
    scopedAutoMcpServers,
    contextBudget,
    MAX_MCP_SERVERS,
    requiredPresetMcpServers()
  );
  const mergedTools = unique([...tools, ...autoAssignment.tools]).slice(0, limitForBudget(contextBudget, MAX_TOOLS));
  const mergedHooks = mergeBoundedRoutes(
    hooks,
    scopedAutoHooks,
    contextBudget,
    MAX_HOOKS,
    requiredPresetHooks()
  );

  const mergedRationale = autoAssignment.skills.length > 0
    ? `${renderRationale(scored.slice(0, 3), contextBudget)} | Auto: ${autoAssignment.rationale}`
    : renderRationale(scored.slice(0, 3), contextBudget);
  const routeTrace = scored.slice(0, 8).map(toRouteScoreTrace);

  return {
    provider: "auto",
    providerReason: "Provider-neutral runtime router decides at node execution time",
    skills: mergedSkills,
    mcpServers: mergedMcpServers,
    tools: mergedTools,
    hooks: mergedHooks,
    contextBudget,
    readOnly,
    evidenceRequired,
    rationale: mergedRationale,
    routeTrace,
    rejected: rejected.slice(0, 6),
    requiresMcp: input.routing?.requiresMcp ?? false,
    requiresToolCalling: input.routing?.requiresToolCalling ?? false,
  };
}

export function resetRoutingInventoryCache(): void {
  inventoryCache = undefined;
}

export function mergeDagNodeRouting(auto: DagNodeRouting, override: DagNodeRouting | undefined): DagNodeRouting {
  if (!override) return auto;
  const skills = override.skills ? unique(override.skills) : auto.skills;
  const mcpServers = override.mcpServers ? unique(override.mcpServers) : auto.mcpServers;
  const tools = override.tools ? unique(override.tools) : auto.tools;
  const hooks = override.hooks ? unique(override.hooks) : auto.hooks;
  return {
    ...auto,
    ...override,
    provider: override.provider ?? auto.provider,
    fallbackProvider: override.fallbackProvider ?? auto.fallbackProvider,
    providerReason: override.providerReason ?? auto.providerReason,
    providerModelTier: override.providerModelTier ?? auto.providerModelTier,
    autoSpawned: override.autoSpawned ?? auto.autoSpawned,
    spawnReason: override.spawnReason ?? auto.spawnReason,
    routeSource: override.routeSource ?? auto.routeSource,
    skills,
    mcpServers,
    tools,
    hooks,
    assignedCapabilities: {
      skills,
      mcpServers,
      tools,
      hooks,
      ...(auto.assignedCapabilities ?? {}),
      ...(override.assignedCapabilities ?? {}),
    },
    rejected: override.rejected ?? auto.rejected,
  };
}

export function dagNodeRoutingEnv(node: DagNode, dag?: import("./dag.js").Dag): Record<string, string> {
  const routing = node.routing;
  if (!routing) return {};

  const skillHints = new Set<string>(routing.skills ?? []);
  const mcpHints = new Set<string>(routing.mcpServers ?? []);
  const toolHints = new Set<string>(routing.tools ?? []);
  const hookHints = new Set<string>(routing.hooks ?? []);
  const parentMcpHints = new Set<string>();

  if (dag) {
    for (const parentId of node.dependsOn) {
      const parent = dag.nodes.find((n) => n.id === parentId);
      if (!parent?.routing) continue;
      for (const s of parent.routing.skills ?? []) skillHints.add(s);
      for (const m of parent.routing.mcpServers ?? []) parentMcpHints.add(m);
      for (const t of parent.routing.tools ?? []) toolHints.add(t);
      for (const h of parent.routing.hooks ?? []) hookHints.add(h);
    }
  }

  return {
    OMK_SKILL_HINTS: Array.from(skillHints).join(","),
    OMK_MCP_HINTS: Array.from(mcpHints).join(","),
    OMK_PARENT_MCP_HINTS: Array.from(parentMcpHints).join(","),
    OMK_TOOL_HINTS: Array.from(toolHints).join(","),
    OMK_HOOK_HINTS: Array.from(hookHints).join(","),
    OMK_ROUTE_SOURCE: routing.routeSource ?? "",
    OMK_ROUTE_AUTO_SPAWNED: String(routing.autoSpawned ?? false),
    OMK_ROUTE_SPAWN_REASON: routing.spawnReason ?? "",
    OMK_CONTEXT_BUDGET: routing.contextBudget ?? "small",
    OMK_ROUTE_READ_ONLY: String(routing.readOnly ?? false),
    OMK_ROUTE_EVIDENCE_REQUIRED: String(routing.evidenceRequired ?? false),
    OMK_ROUTE_RATIONALE: routing.rationale ?? "",
    OMK_PROVIDER_HINT: routing.provider ?? "auto",
    OMK_NODE_PROVIDER: routing.provider ?? "auto",
    OMK_NODE_PROVIDER_AUTHORITY: routing.assignedProviderAuthority ?? "",
    OMK_NODE_PROVIDER_CAPABILITIES: (routing.assignedProviderCapabilities ?? []).join(","),
    OMK_NODE_CANDIDATE_PROVIDERS: (routing.candidateProviders ?? []).join(","),
    OMK_NODE_SKILLS: Array.from(skillHints).join(","),
    OMK_NODE_MCP_SERVERS: Array.from(mcpHints).join(","),
    OMK_NODE_TOOLS: Array.from(toolHints).join(","),
    OMK_NODE_HOOKS: Array.from(hookHints).join(","),
    OMK_PROVIDER_MODEL_TIER: routing.providerModelTier ?? "",
    OMK_PROVIDER_FALLBACK: routing.fallbackProvider ?? "",
    OMK_PROVIDER_REASON: routing.providerReason ?? "",
    OMK_ROUTE_REQUIRES_MCP: String(routing.requiresMcp ?? false),
    OMK_ROUTE_REQUIRES_TOOL_CALLING: String(routing.requiresToolCalling ?? false),
  };
}

export function computeRouteBaseScoreMinGap(weights: typeof ROUTE_SCORE_WEIGHTS): number {
  const increments = [
    weights.role,
    weights.keyword / 2,
    weights.keyword,
    weights.evidence,
    weights.context / 2,
    weights.context,
    weights.safety / 2,
    weights.safety,
  ].map((value) => Math.round(value * ROUTE_SCORE_SCALE)).filter((value) => value > 0);
  const gap = increments.reduce((acc, value) => gcd(acc, value));
  return gap / ROUTE_SCORE_SCALE;
}

export function routeSourcePrior(source: RouteSource): number {
  return ROUTE_SOURCE_PRIOR[source] ?? 0;
}

export function assertRoutePriorTiebreakInvariant(
  priors: Record<RouteSource, number>,
  minBaseGap = ROUTE_BASE_SCORE_MIN_GAP
): void {
  const values = Object.values(priors);
  const spread = Math.max(...values) - Math.min(...values);
  if (!(spread < minBaseGap)) {
    throw new Error(`Route source prior spread ${spread.toFixed(6)} must be < base-score min gap ${minBaseGap.toFixed(6)}`);
  }
}

function gcd(a: number, b: number): number {
  let x = Math.abs(a);
  let y = Math.abs(b);
  while (y !== 0) {
    const next = x % y;
    x = y;
    y = next;
  }
  return x;
}

function roundScore(value: number): number {
  return Math.round(value * ROUTE_SCORE_SCALE) / ROUTE_SCORE_SCALE;
}

function toRouteScoreTrace(route: ScoredRoute): RouteScoreTrace {
  return {
    id: route.candidate.id,
    kind: route.candidate.kind,
    source: route.candidate.source,
    baseScore: route.baseScore,
    sourcePrior: route.sourcePrior,
    score: route.score,
    features: route.features,
    reason: route.reason,
  };
}

function scoreRoute(
  candidate: RouteCandidate,
  role: string,
  text: string,
  evidenceRequired: boolean,
  contextBudget: DagContextBudget
): ScoredRoute {
  const roleMatch = candidate.roles.includes(role) ? 1 : 0;
  const keywordMatches = candidate.keywords.filter((keyword) => textMatchesKeyword(text, keyword)).length;
  const keywordScore = Math.min(1, keywordMatches / 2);
  const evidenceFit = evidenceRequired && candidate.capabilities.some((capability) => /quality|verification|review|official|citation/.test(capability)) ? 1 : 0;
  const smallContextFit = contextBudget === "tiny"
    ? candidate.contextCost === 1 ? 1 : 0
    : candidate.contextCost <= 2 ? 1 : 0.5;
  const safetyFit = candidate.writeRisk === "none" ? 1 : candidate.writeRisk === "low" ? 0.5 : 0;
  const sourcePrior = routeSourcePrior(candidate.source);
  const features: RouteScoreFeatures = {
    roleMatch,
    keywordScore,
    evidenceFit,
    contextFit: smallContextFit,
    safetyFit,
    keywordMatches,
  };
  const baseScore = roundScore(
    (ROUTE_SCORE_WEIGHTS.role * roleMatch)
    + (ROUTE_SCORE_WEIGHTS.keyword * keywordScore)
    + (ROUTE_SCORE_WEIGHTS.evidence * evidenceFit)
    + (ROUTE_SCORE_WEIGHTS.context * smallContextFit)
    + (ROUTE_SCORE_WEIGHTS.safety * safetyFit)
  );
  const score = roundScore(baseScore + sourcePrior);

  return {
    candidate,
    score,
    baseScore,
    sourcePrior,
    features,
    reason: [
      roleMatch ? "role" : "",
      keywordMatches > 0 ? `${keywordMatches} keyword` : "",
      evidenceFit ? "evidence" : "",
      smallContextFit ? "small-context" : "",
    ].filter(Boolean).join(", ") || "fallback",
  };
}

export function discoverRoutingInventory(projectRoot = getRoutingProjectRoot()): RoutingInventory {
  const root = resolve(projectRoot);
  const config = readFlatConfig(root);
  const skillsScope = normalizeScope(config["runtime.skills_scope"] ?? process.env.OMK_SKILLS_SCOPE, "project");
  const mcpScope = normalizeScope(config["runtime.mcp_scope"] ?? process.env.OMK_MCP_SCOPE, "project");
  const hooksScope = normalizeScope(config["runtime.hooks_scope"] ?? process.env.OMK_HOOKS_SCOPE, "project");
  const key = [root, skillsScope, mcpScope, hooksScope].join("|");
  if (inventoryCache?.key === key) return inventoryCache.value;

  const skills = new Map<string, RouteSource>();
  for (const dir of skillDirs(root, skillsScope)) {
    for (const skill of readSkillNames(dir.path)) {
      if (!skills.has(skill)) skills.set(skill, dir.source);
    }
  }

  const mcpServers = new Map<string, RouteSource>();
  const mergedMcp = loadMergedMcpConfigSync(root, mcpScope);
  for (const server of readMcpServerNames({ mcpServers: mergedMcp.servers })) {
    const source = mergedMcp.sources.get(server) ?? "project";
    if (!mcpServers.has(server)) mcpServers.set(server, source);
  }

  const tools = new Set<string>(["SearchWeb", "FetchURL"]);
  if (mcpServers.has("omk-project")) {
    for (const tool of OMK_PROJECT_TOOLS) tools.add(tool);
  }

  const hooks = new Map<string, RouteSource>();
  for (const hook of readActiveHookNames(root, hooksScope)) {
    if (!hooks.has(hook.name)) hooks.set(hook.name, hook.source);
  }

  const value = { skills, mcpServers, hooks, tools, diagnostics: mergedMcp.diagnostics, skillsScope, mcpScope, hooksScope };
  inventoryCache = { key, value };
  return value;
}

function routeCandidates(inventory: RoutingInventory): RouteCandidate[] {
  const staticIds = new Set(ROUTE_CANDIDATES.map((candidate) => `${candidate.kind}:${candidate.id}`));
  const dynamicSkills: RouteCandidate[] = [...inventory.skills.entries()]
    .filter(([id]) => !staticIds.has(`skill:${id}`))
    .map(([id, source]) => dynamicSkillCandidate(id, source));
  const dynamicMcps: RouteCandidate[] = [...inventory.mcpServers.entries()]
    .filter(([id]) => !staticIds.has(`mcp:${id}`))
    .map(([id, source]) => dynamicMcpCandidate(id, source));
  const dynamicHooks: RouteCandidate[] = [...inventory.hooks.entries()]
    .filter(([id]) => !staticIds.has(`hook:${id}`))
    .map(([id, source]) => dynamicHookCandidate(id, source));
  return [...ROUTE_CANDIDATES, ...dynamicSkills, ...dynamicMcps, ...dynamicHooks];
}

function dynamicSkillCandidate(id: string, source: RouteSource): RouteCandidate {
  const keywords = keywordsFromId(id);
  const readOnly = !keywords.some((keyword) => ["write", "delete", "commit", "git", "fix", "implementation"].includes(keyword));
  return {
    kind: "skill",
    id,
    source,
    roles: rolesFromKeywords(keywords),
    keywords,
    readOnly,
    writeRisk: readOnly ? "none" : "low",
    contextCost: id.includes("flow") ? 2 : 1,
    capabilities: keywords,
  };
}

function dynamicMcpCandidate(id: string, source: RouteSource): RouteCandidate {
  const keywords = keywordsFromId(id);
  return {
    kind: "mcp",
    id,
    source,
    roles: rolesFromKeywords(keywords),
    keywords,
    readOnly: true,
    writeRisk: "none",
    contextCost: 1,
    capabilities: keywords,
  };
}

function dynamicHookCandidate(id: string, source: RouteSource): RouteCandidate {
  const keywords = keywordsFromId(id.replace(/\.(sh|js|mjs|ts)$/i, ""));
  return {
    kind: "hook",
    id,
    source,
    roles: rolesFromKeywords(keywords),
    keywords: [...keywords, "hook"],
    readOnly: !keywords.some((keyword) => ["write", "format", "shell", "guard", "secret"].includes(keyword)),
    writeRisk: keywords.some((keyword) => ["write", "format", "shell", "guard", "secret"].includes(keyword)) ? "low" : "none",
    contextCost: 1,
    capabilities: keywords,
  };
}

function rejectionReason(candidate: RouteCandidate, readOnly: boolean, budget: DagContextBudget, inventory: RoutingInventory): string | undefined {
  if (readOnly && candidate.writeRisk === "high") return "read-only node excludes high-risk write route";
  if (budget === "tiny" && candidate.contextCost === 3) return "tiny context budget excludes high-context route";
  if (candidate.kind === "skill" && !inventory.skills.has(candidate.id)) return `skill not installed in ${inventory.skillsScope} scope`;
  if (candidate.kind === "mcp" && !inventory.mcpServers.has(candidate.id)) return `MCP server not installed in ${inventory.mcpScope} scope`;
  if (candidate.kind === "hook" && !inventory.hooks.has(candidate.id)) return `hook not active in ${inventory.hooksScope} scope`;
  if (candidate.kind === "tool" && !inventory.tools.has(candidate.id)) return "tool unavailable from discovered MCP/builtin inventory";
  return undefined;
}

function inferReadOnly(input: RoutingInput, text: string): boolean {
  if ((input.outputs ?? []).some((output) => output.gate && output.gate !== "none")) return false;
  if (["explorer", "researcher", "reviewer", "architect", "planner", "router", "aggregator", "interviewer", "ontology", "vision-debugger"].includes(input.role)) return true;
  return !/\b(write|edit|implement|fix|create|delete|modify|코드작성|수정|구현)\b/.test(text);
}

function inferEvidenceRequired(input: RoutingInput, text: string): boolean {
  return (input.outputs ?? []).some((output) => output.gate && output.gate !== "none")
    || ["qa", "tester", "reviewer", "verifier", "aggregator"].includes(input.role)
    || /\b(test|verify|evidence|quality|gate|citation|검증|테스트|근거)\b/.test(text);
}

function inferContextBudget(input: RoutingInput, readOnly: boolean, evidenceRequired: boolean): DagContextBudget {
  if (input.cost === 1 || (readOnly && !evidenceRequired)) return "tiny";
  if (input.cost === 3) return "normal";
  return "small";
}

function limitForBudget(budget: DagContextBudget, max: number): number {
  if (budget === "tiny") return Math.max(1, Math.min(max, 2));
  if (budget === "small") return Math.max(1, max);
  return max;
}

function mergeBoundedRoutes(
  discovered: readonly string[],
  autoAssigned: readonly string[],
  budget: DagContextBudget,
  defaultMax: number,
  requiredPreset?: readonly string[]
): string[] {
  const merged = unique([...discovered, ...autoAssigned]);
  if (!requiredPreset || requiredPreset.length === 0) {
    return merged.slice(0, limitForBudget(budget, defaultMax));
  }
  const budgetLimit = limitForBudget(budget, defaultMax);
  const missing = requiredPreset.filter((id) => !merged.includes(id));
  // Preserve top discovered items within their budget limit, then append all
  // required preset items (both those already present in merged and any missing).
  const discoveredKept = merged.filter(
    (id, idx) => idx < budgetLimit && !requiredPreset.includes(id)
  );
  const presetInMerged = merged.filter((id) => requiredPreset.includes(id));
  return unique([...discoveredKept, ...presetInMerged, ...missing]);
}

function renderRationale(scored: ScoredRoute[], budget: DagContextBudget): string {
  const summary = scored.map((item) => `${item.candidate.id}(${item.reason})`).join("; ");
  return `budget=${budget}; selected=${summary || "direct"}`;
}

function addDefaultHookHints(
  hooks: string[],
  inventory: RoutingInventory,
  role: string,
  readOnly: boolean,
  evidenceRequired: boolean
): void {
  const maybePush = (id: string): void => {
    if (hooks.length >= MAX_HOOKS) return;
    if (inventory.hooks.has(id) && !hooks.includes(id)) hooks.push(id);
  };
  if (["router", "orchestrator", "planner"].includes(role)) {
    maybePush("awesome-agent-skills-router.sh");
    maybePush("session-context.sh");
  }
  if (evidenceRequired || ["reviewer", "qa", "tester", "verifier", "aggregator"].includes(role)) {
    maybePush("subagent-stop-audit.sh");
    maybePush("stop-verify.sh");
  }
  if (!readOnly) {
    maybePush("protect-secrets.sh");
    maybePush("post-format.sh");
    maybePush("pre-shell-guard.sh");
  }
}

function normalizeText(text: string): string {
  return text.toLowerCase().replace(/\s+/g, " ").trim();
}

// Module-level cache of compiled keyword matchers. `textMatchesKeyword` is invoked
// ~150x per `selectTaskRouting` (per DAG node), so recompiling `new RegExp(...)` on
// every call is pure overhead. The keys are normalized keywords drawn from a fixed,
// bounded route-candidate keyword set, so this Map cannot grow without bound; no
// eviction policy is required. The compiled RegExps carry no flags (no /g, /y), so
// they are stateless across `.test()` calls and safe to share between callers.
const keywordRegExpCache = new Map<string, RegExp>();

function getKeywordRegExp(normalized: string): RegExp {
  const cached = keywordRegExpCache.get(normalized);
  if (cached !== undefined) return cached;
  const escaped = normalized.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  const regex = new RegExp(`(^|[^a-z0-9])${escaped}($|[^a-z0-9])`);
  keywordRegExpCache.set(normalized, regex);
  return regex;
}

/** Exported for focused cache-equivalence tests. Returns the cached matcher for an
 * already-normalized alphanumeric keyword (or null when no regex path applies). */
export function getKeywordMatcherRegExp(keyword: string): RegExp | null {
  const normalized = normalizeText(keyword);
  if (normalized.length < 2) return null;
  if (!/^[a-z0-9]+$/.test(normalized)) return null;
  return getKeywordRegExp(normalized);
}

export function textMatchesKeyword(text: string, keyword: string): boolean {
  const normalized = normalizeText(keyword);
  if (normalized.length < 2) return false;
  if (/^[a-z0-9]+$/.test(normalized)) {
    return getKeywordRegExp(normalized).test(text);
  }
  return text.includes(normalized);
}

function unique(values: string[]): string[] {
  return [...new Set(values.filter((value) => value.trim().length > 0))];
}

function getRoutingProjectRoot(): string {
  return process.env.OMK_PROJECT_ROOT ? resolve(process.env.OMK_PROJECT_ROOT) : process.cwd();
}

function getRoutingUserHome(): string {
  return (
    normalizeUserHomePath(process.env.OMK_ORIGINAL_HOME)
    ?? normalizeUserHomePath(process.env.HOME)
    ?? normalizeUserHomePath(homedir())
    ?? homedir()
  );
}

function skillDirs(root: string, scope: RoutingInventory["skillsScope"]): Array<{ path: string; source: RouteSource }> {
  if (scope === "none") return [];
  const dirs: Array<{ path: string; source: RouteSource }> = [
    { path: join(root, ".agents", "skills"), source: "project" },
    { path: join(root, ".kimi", "skills"), source: "project" },
    { path: join(root, ".omk", "skills"), source: "project" },
  ];
  if (scope === "all") {
    const userHome = getRoutingUserHome();
    dirs.push(
      { path: join(userHome, ".codex", "skills"), source: "global" },
      { path: join(userHome, ".agents", "skills"), source: "global" },
      { path: join(userHome, ".kimi", "skills"), source: "global" },
    );
  }
  return dirs;
}

function readActiveHookNames(root: string, scope: RoutingInventory["hooksScope"]): Array<{ name: string; source: RouteSource }> {
  if (scope === "none") return [];
  const files: Array<{ path: string; source: RouteSource }> = [
    { path: join(root, ".omk", "kimi.config.toml"), source: "project" },
    { path: join(root, ".kimi", "kimi.config.toml"), source: "project" },
  ];
  if (scope === "all") {
    const userHome = getRoutingUserHome();
    files.unshift(
      { path: join(userHome, ".kimi", "kimi.config.toml"), source: "global" },
      { path: join(userHome, ".codex", "kimi.config.toml"), source: "global" },
    );
  }
  const result: Array<{ name: string; source: RouteSource }> = [];
  for (const file of files) {
    try {
      const content = readFileSync(file.path, "utf-8");
      for (const match of content.matchAll(/^\s*command\s*=\s*["']([^"']*hooks\/([^/"']+))["']/gm)) {
        const name = match[2]?.trim();
        if (name) result.push({ name, source: file.source });
      }
    } catch {
      // ignore missing or invalid hook config
    }
  }
  return result;
}

const SECRET_PATTERNS = ["apikey", "token", "password", "secret", "authorization"];

function isSecretKey(key: string): boolean {
  const normalized = key.toLowerCase().replace(/[_-]/g, "");
  return SECRET_PATTERNS.some((pattern) => normalized === pattern || normalized.endsWith(pattern));
}

function isPlainObject(value: unknown): value is Record<string, unknown> {
  return value !== null && typeof value === "object" && !Array.isArray(value);
}

function deepMerge(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown> {
  const result: Record<string, unknown> = { ...target };
  for (const key of Object.keys(source)) {
    const sVal = source[key];
    const tVal = result[key];
    if (isPlainObject(sVal) && isPlainObject(tVal)) {
      result[key] = deepMerge(tVal, sVal);
    } else {
      result[key] = sVal;
    }
  }
  return result;
}

export function redactMcpConfig(cfg: unknown): unknown {
  if (isPlainObject(cfg)) {
    const result: Record<string, unknown> = {};
    for (const [key, value] of Object.entries(cfg)) {
      if (isSecretKey(key)) {
        result[key] = "***";
      } else if (isPlainObject(value)) {
        result[key] = redactMcpConfig(value);
      } else if (Array.isArray(value)) {
        result[key] = value.map((item) => redactMcpConfig(item));
      } else {
        result[key] = value;
      }
    }
    return result;
  }
  if (Array.isArray(cfg)) {
    return cfg.map((item) => redactMcpConfig(item));
  }
  return cfg;
}

function loadMergedMcpConfigSync(
  projectRoot: string,
  scope: "project" | "all" | "none"
): { servers: Record<string, unknown>; sources: Map<string, RouteSource>; diagnostics: RoutingDiagnostic[] } {
  const root = resolve(projectRoot);
  const servers: Record<string, unknown> = {};
  const sources = new Map<string, RouteSource>();
  const diagnostics: RoutingDiagnostic[] = [];

  if (scope === "none") {
    return { servers, sources, diagnostics };
  }

  const globalFiles = scope === "all" ? [
    join(getRoutingUserHome(), ".omk", "agent", "mcp.json"),
    join(getRoutingUserHome(), ".omk", "mcp.json"),
    join(getRoutingUserHome(), ".kimi", "mcp.json"),
  ] : [];

  for (const path of globalFiles) {
    if (!existsSync(path)) continue;
    try {
      const parsed = JSON.parse(readFileSync(path, "utf-8")) as { mcpServers?: Record<string, unknown> };
      for (const [name, cfg] of Object.entries(parsed.mcpServers ?? {})) {
        if (!sources.has(name)) {
          servers[name] = cfg;
          sources.set(name, "global");
        }
      }
    } catch (err) {
      diagnostics.push(createMcpConfigDiagnostic(root, "global", path, err));
    }
  }

  const projectFiles = [
    join(root, ".omk", "mcp.json"),
    join(root, ".kimi", "mcp.json"),
  ];

  for (const path of projectFiles) {
    if (!existsSync(path)) continue;
    try {
      const parsed = JSON.parse(readFileSync(path, "utf-8")) as { mcpServers?: Record<string, unknown> };
      for (const [name, cfg] of Object.entries(parsed.mcpServers ?? {})) {
        if (!sources.has(name) || sources.get(name) === "global") {
          if (sources.has(name) && isPlainObject(servers[name]) && isPlainObject(cfg)) {
            servers[name] = deepMerge(servers[name] as Record<string, unknown>, cfg as Record<string, unknown>);
          } else {
            servers[name] = cfg;
          }
          sources.set(name, "project");
        }
      }
    } catch (err) {
      diagnostics.push(createMcpConfigDiagnostic(root, "project", path, err));
    }
  }

  if (!sources.has("omk-project")) {
    servers["omk-project"] = { type: "builtin", command: "omk", args: ["mcp", "serve", "omk-project"] };
    sources.set("omk-project", "builtin");
  }

  return { servers, sources, diagnostics };
}

export function loadMergedMcpConfig(
  projectRoot: string,
  scope: "project" | "all" | "none"
): Promise<{ servers: Record<string, unknown>; sources: Map<string, RouteSource>; diagnostics: RoutingDiagnostic[] }> {
  return Promise.resolve(loadMergedMcpConfigSync(projectRoot, scope));
}

function createMcpConfigDiagnostic(
  root: string,
  source: "project" | "global",
  path: string,
  err: unknown
): RoutingDiagnostic {
  return {
    kind: "mcp-config",
    source,
    path: formatRoutingPath(root, path),
    message: mcpConfigErrorMessage(err),
  };
}

function formatRoutingPath(root: string, path: string): string {
  const resolvedPath = resolve(path);
  const resolvedRoot = resolve(root);
  const rootRelative = relative(resolvedRoot, resolvedPath);
  if (rootRelative === "") return ".";
  if (isRelativeChildPath(rootRelative)) return rootRelative;
  const home = resolve(getRoutingUserHome());
  const homeRelative = relative(home, resolvedPath);
  if (homeRelative === "") return "~";
  if (isRelativeChildPath(homeRelative)) return `~/${homeRelative.split(sep).join("/")}`;
  return resolvedPath;
}

function isRelativeChildPath(path: string): boolean {
  return path !== "" && path !== ".." && !path.startsWith(`..${sep}`) && !path.startsWith("../") && !isAbsolute(path);
}

function mcpConfigErrorMessage(err: unknown): string {
  if (err instanceof SyntaxError) return "invalid JSON";
  if (err && typeof err === "object" && "code" in err && typeof (err as { code?: unknown }).code === "string") {
    return `unreadable MCP config (${(err as { code: string }).code})`;
  }
  return "invalid MCP config";
}

function readSkillNames(dir: string): string[] {
  try {
    return readdirSync(dir, { withFileTypes: true })
      .filter((entry) => entry.isDirectory() && existsSync(join(dir, entry.name, "SKILL.md")))
      .map((entry) => entry.name);
  } catch {
    return [];
  }
}

function readMcpServerNames(config: { mcpServers?: Record<string, unknown> }): string[] {
  return Object.keys(config.mcpServers ?? {});
}

function readFlatConfig(root: string): Record<string, string> {
  try {
    return parseSimpleToml(readFileSync(join(root, ".omk", "config.toml"), "utf-8"));
  } catch {
    return {};
  }
}

function parseSimpleToml(content: string): Record<string, string> {
  const result: Record<string, string> = {};
  let section = "";
  for (const rawLine of content.split(/\r?\n/)) {
    const line = stripComment(rawLine).trim();
    if (!line) continue;
    const sectionMatch = line.match(/^\[([^\]]+)]$/);
    if (sectionMatch) {
      section = sectionMatch[1].trim();
      continue;
    }
    const kv = line.match(/^([A-Za-z0-9_.-]+)\s*=\s*(.+)$/);
    if (!kv) continue;
    const key = section ? `${section}.${kv[1].trim()}` : kv[1].trim();
    result[key] = normalizeConfigValue(kv[2].trim());
  }
  return result;
}

function stripComment(line: string): string {
  let inString = false;
  let quote = "";
  for (let i = 0; i < line.length; i += 1) {
    const char = line[i];
    if ((char === "\"" || char === "'") && line[i - 1] !== "\\") {
      if (!inString) {
        inString = true;
        quote = char;
      } else if (quote === char) {
        inString = false;
      }
    }
    if (char === "#" && !inString) return line.slice(0, i);
  }
  return line;
}

function normalizeConfigValue(value: string): string {
  if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) {
    return value.slice(1, -1);
  }
  return value;
}

function normalizeScope(value: string | undefined, fallback: RoutingInventory["skillsScope"]): RoutingInventory["skillsScope"] {
  const normalized = value?.trim().toLowerCase();
  if (normalized === "none" || normalized === "off" || normalized === "disabled") return "none";
  if (normalized === "all" || normalized === "global" || normalized === "local-user" || normalized === "local_user" || normalized === "personal" || normalized === "user") return "all";
  if (normalized === "project" || normalized === "local") return "project";
  return fallback;
}

function keywordsFromId(id: string): string[] {
  return unique(id.replace(/^omk-/, "").split(/[-_]/).filter((keyword) => keyword.length >= 2));
}

function rolesFromKeywords(keywords: string[]): string[] {
  if (keywords.some((keyword) => ["review", "security", "quality"].includes(keyword))) return ["reviewer", "qa"];
  if (keywords.some((keyword) => ["test", "debug", "fix"].includes(keyword))) return ["debugger", "tester", "coder"];
  if (keywords.some((keyword) => ["frontend", "implementation", "typescript", "python"].includes(keyword))) return ["coder"];
  if (keywords.some((keyword) => ["repo", "research", "docs"].includes(keyword))) return ["researcher", "explorer"];
  if (keywords.some((keyword) => ["plan", "flow", "dag", "context", "router"].includes(keyword))) return ["planner", "router"];
  return ["planner", "router", "reviewer"];
}
