/**
 * Discovery agent for scan-now.
 *
 * Single Claude Agent SDK run that asks the model to surface candidate
 * threads (work items needing Pavel's attention) from the active sources
 * configured in Settings — Linear / Notion / claude-output / recent chats /
 * (PostHog, M365 if enabled). Returns NowItem-shape items with synthetic
 * file paths so the existing itemKey() / verdict cache / resolve-postpone
 * machinery applies unchanged.
 *
 * Failure mode: if the agent fails or returns malformed JSON, we return
 * an empty array. The caller (check-scheduler tick) keeps the local
 * MEMORY scan + the persistent discovered-threads cache, so Activity is
 * never wiped by a flaky run.
 *
 * ═══════════════════════════════════════════════════════════════════
 *  EXPERIMENT(weighting) — knitor's "secret sauce" candidate
 * ═══════════════════════════════════════════════════════════════════
 *
 *  Research: ~/Library/Mobile Documents/com~apple~CloudDocs/claude-output/
 *            personal/research/2026-05-11_activity-ranking-prioritization-
 *            patterns.md
 *
 *  Hypothesis: Activity surfaces the right items if we apply Gmail- /
 *  PagerDuty-style suppression aggressively and rank intra-kind by a
 *  small additive signal score. Pure-ML ranking loses to the hybrid at
 *  sub-Meta scale. (See research §Finding 1.)
 *
 *  Split into two sub-TODOs because they have different blast radius:
 *
 *  ── TODO(gating) — suppression rules, applied BEFORE Activity render
 *  Status:
 *    • Phase 0 SHIPPED: CLOSE-verdict TTL suppression (now.ts ~line 290,
 *      `closeSuppressKeys`). 7-day TTL; items resurface after expiry.
 *    • Phase 1a SHIPPED: snooze-fatigue (now.ts `fatiguedSnoozeKeys`).
 *      Schema change to postpones.json from overwrite-per-key to
 *      append-per-event. Default K=2, D=30 — items snoozed twice in
 *      30 days are hidden until both events fall out of the window.
 *    • Phase 1b SHIPPED: discovery-prompt CLOSE-exclude (see
 *      `recentCloseExcludeList` below). Recently-CLOSE'd items are
 *      passed to the agent as a "do not include" block so it doesn't
 *      spend tool calls re-surfacing what /api/now would suppress
 *      post-hoc anyway.
 *  Gating done. Next:
 *
 *  ── TODO(ordering) — intra-kind ranking by computed weight
 *  Status:
 *    • Phase 2 SHIPPED: weight wired end-to-end with uniform
 *      coefficients (all = 1.0). Per-item compute in now.ts
 *      `computeWeight()`; intra-kind sort by weight desc applied in
 *      /api/now before per-kind cap. Frontend renders the weight as
 *      a faded "w<N>" badge next to the source chip on each row.
 *      Signals fed: days_overdue, pavel_mention, status_keywords,
 *      verdict=ESCALATE. Deferred: recency_factor (needs unified
 *      daysSinceTouch), similar_resolved penalty (quadratic).
 *  Remaining:
 *    • Phase 3: 2-week observation window. Capture per-item outcomes
 *      (resolve / postpone / "Work on this" / ignore) so calibration
 *      has ground truth.
 *    • Phase 4: One-shot calibration. For each Group 3 signal compute
 *      lift in P(resolve | signal=on) vs P(resolve | signal=off);
 *      bump or reduce coefficient accordingly. Freeze afterwards.
 *      Move coefficients into settings.json as user-tunable.
 *
 *  Success metric: of items in top 5 across all kinds on a given day,
 *  % resolved or worked-on within 7 days. Baseline is current insertion-
 *  order ≈ random within a kind. Hypothesis: suppression alone hits
 *  ~50%+, suppression + weighting hits ~60%+.
 *
 *  Don't build: continuous learning / bandit / RL — daily action volume
 *  is too low. Don't build LLM rerank until the deterministic baseline
 *  is shipped and used as the comparison.
 * ═══════════════════════════════════════════════════════════════════
 */
import { query } from "@anthropic-ai/claude-agent-sdk";
import { defaultSdkOptions } from "./agent";
import { ALL_SOURCES, type SourceId } from "./check-sources";
import { loadSettings } from "./settings";
import { addSystemSession } from "./system-sessions";
import { KNITOR_SYSTEM_SENTINEL } from "./chats";
import { pushScanTool } from "./scan-state";
import { upsertDiscovered, listDiscovered, type DiscoveredThread } from "./discovered-threads";
import { loadCheckHistory, itemKey } from "./check-cache";

const DISCOVERY_CAP = 30;
const CLOSE_EXCLUDE_TTL_DAYS = 7;
const CLOSE_EXCLUDE_MAX_ENTRIES = 25;
const DAY_MS = 86_400_000;

/** EXPERIMENT(weighting) Phase 1b — give the discovery agent a list of
 *  recently-CLOSE'd items so it doesn't waste tool calls re-surfacing
 *  them. The /api/now filter (Phase 0) suppresses them post-hoc anyway;
 *  this just stops the agent from spending the budget re-discovering
 *  what we'd suppress.
 *
 *  Identifies items by visible text + location, not threadKey, since
 *  threadKey is an internal hash the agent has no way to recognise.
 *  Cap entries so the prompt doesn't bloat. */
function recentCloseExcludeList(): string[] {
  const cutoffMs = Date.now() - CLOSE_EXCLUDE_TTL_DAYS * DAY_MS;
  const history = loadCheckHistory();
  const closeKeys = new Set<string>();
  for (const [tk, checks] of Object.entries(history)) {
    const latest = checks[0];
    if (latest?.verdict === "CLOSE" && Date.parse(latest.checkedAt) >= cutoffMs) {
      closeKeys.add(tk);
    }
  }
  if (closeKeys.size === 0) return [];
  const out: string[] = [];
  for (const d of listDiscovered()) {
    if (out.length >= CLOSE_EXCLUDE_MAX_ENTRIES) break;
    const k = itemKey(d.file, d.line, d.text);
    if (!closeKeys.has(k)) continue;
    const textPrefix = d.text.slice(0, 100).replace(/\s+/g, " ").trim();
    const loc = d.location ? ` (${d.location})` : "";
    out.push(`${textPrefix}${loc}`);
  }
  return out;
}

type RawDiscoveredItem = {
  kind?: string;
  source?: string;
  text?: string;
  location?: string;
  daysOff?: number;
  date?: string;
};

const VALID_KINDS = new Set(["overdue", "today", "upcoming", "open", "stalled"]);

function buildPrompt(active: SourceId[]): string {
  const today = new Date().toISOString().slice(0, 10);
  const sourceBlock = active.map((id) => ALL_SOURCES[id].promptHint).join("\n");
  const excludeList = recentCloseExcludeList();
  const excludeBlock = excludeList.length
    ? `\nDO NOT INCLUDE — these items were auto-CLOSE'd in the last ${CLOSE_EXCLUDE_TTL_DAYS} days. They've been judged not actionable; surfacing them again wastes tool calls and clutters Activity. Skip anything matching this list by topic, ticket id, or URL:\n${excludeList.map((s) => `- ${s}`).join("\n")}\n`
    : "";

  return `${KNITOR_SYSTEM_SENTINEL}
You are scanning Pavel's open threads — work items needing his attention — from the active sources below.

ACTIVE SOURCES (only consult these; ignore others):
${sourceBlock}
${excludeBlock}
WHAT COUNTS AS A THREAD:
- A decision is pending or someone is awaiting Pavel's input
- Pavel was @-mentioned in a Linear comment (last 14 days). This is the strongest signal — someone is explicitly waiting on his reply.
- A date or deadline is approaching, today, or overdue
- A project shows signs of being stalled or blocked
- Pavel was asked a question and hasn't responded
- A status keyword (awaiting, blocked, decision needed, follow-up, must, owe, due, deadline, ships, launch) appears alongside a recent timestamp or owner

INSTRUCTIONS:
- Today is ${today}.
- Be efficient — 6-15 tool calls TOTAL across all sources.
- Don't fabricate. If a source returns nothing, skip it.
- Don't include items already obviously closed/done/shipped.
- Don't include items from the DO NOT INCLUDE list above (if present).
- Prefer recency — items touched in the last 30 days.
- Cap output at ${DISCOVERY_CAP} items. Pick the most actionable.

OUTPUT — strict JSON array, nothing else, no preamble, no code fences:
[
  {
    "kind": "overdue" | "today" | "upcoming" | "open" | "stalled",
    "source": "Linear" | "Notion" | "claude-output" | "Recent chats" | "PostHog" | "Outlook + Teams",
    "text": "<one-sentence summary, max 240 chars>",
    "location": "<URL, file path, or stable id>",
    "daysOff": <integer days until/since deadline, optional, negative for past>
  },
  ...
]

If you find nothing, output exactly: []`;
}

/** Map a source label + location to a stable synthetic file path so the
 *  itemKey is stable across runs and aligns with the resolve cache. */
function syntheticFilePath(source: string, location: string): string {
  const slug = (s: string) => s.replace(/[^a-zA-Z0-9._/-]+/g, "-").slice(0, 80);
  const src = (source || "unknown").toLowerCase().replace(/[^a-z0-9]+/g, "-");
  const loc = location ? slug(location) : "item";
  return `external/${src}/${loc}`;
}

/** Try increasingly aggressive JSON extraction. Models sometimes wrap
 *  the array in code fences or add a trailing newline. */
function extractJsonArray(text: string): unknown[] | null {
  const trimmed = text.trim();
  // Direct parse
  try {
    const parsed = JSON.parse(trimmed);
    if (Array.isArray(parsed)) return parsed;
  } catch {}
  // Strip ```json fences
  const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
  if (fenced) {
    try {
      const parsed = JSON.parse(fenced[1]);
      if (Array.isArray(parsed)) return parsed;
    } catch {}
  }
  // Find the first [...]-shaped substring
  const start = trimmed.indexOf("[");
  const end = trimmed.lastIndexOf("]");
  if (start >= 0 && end > start) {
    try {
      const parsed = JSON.parse(trimmed.slice(start, end + 1));
      if (Array.isArray(parsed)) return parsed;
    } catch {}
  }
  return null;
}

function normalize(raw: RawDiscoveredItem): DiscoveredThread | null {
  const text = (raw.text || "").trim();
  if (!text) return null;
  const kind = raw.kind && VALID_KINDS.has(raw.kind) ? (raw.kind as DiscoveredThread["kind"]) : "open";
  const source = (raw.source || "Discovery").trim();
  const location = (raw.location || "").trim() || undefined;
  const file = syntheticFilePath(source, location || text.slice(0, 40));
  return {
    kind,
    text: text.slice(0, 280),
    source,
    file,
    line: 1,
    date: raw.date,
    daysOff: typeof raw.daysOff === "number" ? raw.daysOff : undefined,
    location,
    firstSeenAt: "",  // upsertDiscovered fills these
    lastSeenAt: "",
  };
}

/**
 * Run the discovery agent and persist results. Returns the upsert summary.
 * Throws on hard agent failure so the caller can decide whether to log +
 * proceed or surface the error.
 */
export async function runDiscovery(): Promise<{ added: number; refreshed: number; total: number }> {
  const active = loadSettings().activeSources;
  if (!active.length) {
    return { added: 0, refreshed: 0, total: 0 };
  }

  const prompt = buildPrompt(active);
  let raw = "";

  for await (const msg of query({
    prompt,
    options: defaultSdkOptions({ maxTurns: 20 }) as any,
  })) {
    const m = msg as any;
    if (m.type === "system" && m.subtype === "init" && typeof m.session_id === "string") {
      addSystemSession(m.session_id);
    }
    if (m.type === "assistant" && m.message?.content) {
      for (const block of m.message.content) {
        if (block.type === "text" && typeof block.text === "string") {
          raw += block.text;
        } else if (block.type === "tool_use" && typeof block.name === "string") {
          pushScanTool(block.name);
        }
      }
    }
  }

  const arr = extractJsonArray(raw) ?? [];
  const items = arr
    .map((r) => normalize(r as RawDiscoveredItem))
    .filter((it): it is DiscoveredThread => it !== null)
    .slice(0, DISCOVERY_CAP);

  if (!items.length) {
    return { added: 0, refreshed: 0, total: 0 };
  }

  const summary = upsertDiscovered(items);
  return { ...summary, total: items.length };
}
