// Deterministic dedup for request intake — S2 of docs/plans/2026-08-14-request-intake.md.
// NO model in this path. Matches within the same project, across ALL states (a `shipped`
// match is the most valuable one — it answers "already built" instead of rebuilding).
import type { RequestRow } from "./requests-store";

export type MatchConfidence = "confident" | "weak";
export interface RequestMatch {
  row: RequestRow;
  confidence: MatchConfidence;
  score: number;
}

const STOPWORDS = new Set([
  "a", "an", "the", "to", "for", "of", "in", "on", "at", "and", "or", "is", "are",
  "please", "can", "you", "could", "would", "will", "i", "we", "need", "want", "let's", "lets",
]);

function normalizeTokens(title: string): string[] {
  return title
    .toLowerCase()
    .replace(/[^a-z0-9\s]+/g, " ")
    .split(/\s+/)
    .filter((t) => t && !STOPWORDS.has(t));
}

function jaccard(a: Set<string>, b: Set<string>): number {
  if (a.size === 0 && b.size === 0) return 0;
  let intersection = 0;
  for (const t of a) if (b.has(t)) intersection++;
  const union = a.size + b.size - intersection;
  return union === 0 ? 0 : intersection / union;
}

const CONFIDENT_THRESHOLD = 0.82;
const WEAK_THRESHOLD = 0.6;

/**
 * Best match for `candidate` among `existing`, or null. Same project only.
 *
 * `plan_ref` doubles as the stable "work key" writers with a deterministic identity (plan
 * slug, worktree slug, ticket, adw_id) pass in — see docs/plans/2026-08-14-request-intake.md's
 * seventh pass. Exact key match is checked FIRST and always wins (same lesson `/fire`'s
 * exact-signature claim already recorded: a deterministic key beats prose similarity).
 *
 * A KEYED candidate (one that passed a plan_ref) never falls back to fuzzy title matching: the
 * key IS that writer's whole dedup contract. Falling through to title-jaccard for a keyed
 * candidate risks associating/claiming the wrong row purely on shared wording (e.g. every
 * agent-judgement worktree row starts "Agent started work on: ..." — two unrelated worktree
 * slugs can jaccard-overlap on that shared prefix alone). Fuzzy title matching remains the
 * fallback ONLY for keyless candidates, matched against keyless existing rows or rows whose key
 * simply didn't match (a keyed existing row falling through here still competes on title terms
 * like any other row — only the CANDIDATE's own key presence gates the fallback).
 */
export function findMatch(
  existing: readonly RequestRow[],
  candidate: { title: string; project: string; plan_ref?: string | null }
): RequestMatch | null {
  const candidateTokens = new Set(normalizeTokens(candidate.title));

  for (const row of existing) {
    if (row.project !== candidate.project) continue;
    if (candidate.plan_ref && row.plan_ref && candidate.plan_ref === row.plan_ref) {
      return { row, confidence: "confident", score: 1 };
    }
  }

  if (candidate.plan_ref) return null;

  let best: RequestMatch | null = null;
  for (const row of existing) {
    if (row.project !== candidate.project) continue;

    const rowTokens = new Set(normalizeTokens(row.title));
    const rowNormalized = [...rowTokens].sort().join(" ");
    const candidateNormalized = [...candidateTokens].sort().join(" ");
    if (rowNormalized && rowNormalized === candidateNormalized) {
      return { row, confidence: "confident", score: 1 };
    }

    const score = jaccard(candidateTokens, rowTokens);
    if (score < WEAK_THRESHOLD) continue;
    const confidence: MatchConfidence = score >= CONFIDENT_THRESHOLD ? "confident" : "weak";
    if (confidence === "confident") return { row, confidence, score };
    if (!best || score > best.score) best = { row, confidence, score };
  }

  return best;
}
