/**
 * Background scheduler that runs /api/now/check periodically on the
 * dashboard items most in need of a check, then caches verdicts so the
 * dashboard can render them inline.
 *
 * Strategy:
 *   - Every TICK_MS, scan workspace items
 *   - Pick the N items whose cached check is stale (or missing)
 *     ranked by: (a) overdue first, (b) oldest cache, (c) stalled longest
 *   - Run the agent for each, sequentially (parallel would 5x quota use)
 *   - Cache verdicts, writeback to disk
 *
 * Soft-touch knobs:
 *   - Skip cycle if more than CONCURRENT_CAP cache writes in last hour
 *     (don't burn quota if the user is already actively checking)
 *   - Hold the lock for a single tick at a time so server reload doesn't
 *     fire two checks against the same item
 */
import { query } from "@anthropic-ai/claude-agent-sdk";
import { join } from "node:path";
import { MEMORY_DIR } from "./paths";
import { defaultSdkOptions } from "./agent";
import { scanWorkspaceForCheck } from "./now-scan-export";
import { itemKey, loadCheckHistory, saveCheckHistory, appendCheck, latestCheck, parseVerdict, isStale } from "./check-cache";
import type { CachedCheck } from "./check-cache";
import { activeSourcesPromptBlock, sourcesUsed } from "./check-sources";
import { loadSettings } from "./settings";
import { addSystemSession } from "./system-sessions";
import { KNITOR_SYSTEM_SENTINEL } from "./chats";
import { setScanRunning, setScanCurrent, setScanIdle, setScanPhase, setScanDiscovered, pushScanTool, setBackgroundComplete } from "./scan-state";
import { runDiscovery } from "./scan-discovery";
import { listDiscovered, isDiscoveredStale } from "./discovered-threads";
import { mergeThreads } from "./thread-merge";
import type { NowItem } from "./now";

const TICK_MS = 4 * 60 * 60 * 1000;   // 4h — background auto-check cadence
const ITEMS_PER_TICK = 4;              // how many items get checked per tick (background OR manual "Scan now")
const STALE_AFTER_MS = 6 * 3600 * 1000; // 6h — how old a cached verdict can be before we re-check
const FIRST_RUN_DELAY_MS = 60 * 1000;  // wait 60s after server start before the first auto-tick
const SCHEDULER_MODEL = "claude-haiku-4-5-20251001"; // cheap model for background checks

let running = false;
let activeAbort: AbortController | null = null;


function ageHint(it: ReturnType<typeof scanWorkspaceForCheck>[number]): string {
  if (it.kind === "stalled") return `Stalled — project file untouched ~${Math.abs(it.daysOff || 0)}d.`;
  if (it.kind === "overdue") return `Overdue ~${Math.abs(it.daysOff || 0)}d (date in source).`;
  if (it.kind === "today") return `Date target is today.`;
  return `Open loop (no firm date; awaiting/blocked/decision).`;
}

function buildBatchPrompt(items: ReturnType<typeof scanWorkspaceForCheck>): string {
  const today = new Date().toISOString().slice(0, 10);
  const itemBlock = items
    .map((it, i) => {
      const sourcePath = join(MEMORY_DIR, it.file);
      return `### ITEM ${i + 1}
- Source: ${it.source} · ${ageHint(it)}
- File: ${sourcePath}:${it.line}
- Text: """${it.text.slice(0, 400)}"""`;
    })
    .join("\n\n");

  return `${KNITOR_SYSTEM_SENTINEL}
You are evaluating ${items.length} flagged item${items.length === 1 ? "" : "s"} from Pavel Racu's "what needs your attention" dashboard.

${itemBlock}

Today is ${today}. Pavel is Head of Product at AWSsome.

YOUR JOB — BE EFFICIENT (4–10 tool calls total across ALL items):
For each item, check 1–2 relevant sources. Reuse tool results across items where possible — do NOT re-read the same file multiple times.
${activeSourcesPromptBlock()}

OUTPUT — one section per item, separated by ---. No preamble, no trailing text after the last section.

---
## ITEM 1 VERDICT: CLOSE | KEEP | ESCALATE

[1-2 sentences why.]

**Evidence:**
- [date] [source] — finding
- …(2–4 bullets max)

**Suggested resolution:**
[CLOSE: paste-ready resolution. KEEP/ESCALATE: clear next action + owner.]
---
## ITEM 2 VERDICT: …
(continue for all ${items.length} items)
`;
}

/** Parse ITEM N VERDICT lines from a batched response. Returns one entry per item index. */
function parseBatchVerdicts(raw: string, count: number): Array<ReturnType<typeof parseVerdict> | null> {
  const results: Array<ReturnType<typeof parseVerdict> | null> = Array(count).fill(null);
  // Split on --- lines (used as section separators in the prompt)
  const sections = raw.split(/\n---\n|\n---$|^---\n/m);
  for (const section of sections) {
    const headerMatch = section.match(/##\s+ITEM\s+(\d+)\s+VERDICT:/i);
    if (!headerMatch) continue;
    const idx = parseInt(headerMatch[1], 10) - 1;
    if (idx < 0 || idx >= count) continue;
    // Re-use parseVerdict but the section uses "## ITEM N VERDICT:" instead of "## VERDICT:"
    // Normalise to what parseVerdict expects
    const normalised = section.replace(/##\s+ITEM\s+\d+\s+VERDICT:/i, "## VERDICT:");
    results[idx] = parseVerdict(normalised);
  }
  return results;
}

async function runBatchCheck(
  items: ReturnType<typeof scanWorkspaceForCheck>,
  abortController: AbortController,
): Promise<Array<CachedCheck | null>> {
  if (!items.length) return [];
  const prompt = buildBatchPrompt(items);
  let raw = "";
  const toolNames: string[] = [];
  try {
    for await (const msg of query({
      prompt,
      options: defaultSdkOptions({ maxTurns: 12, model: SCHEDULER_MODEL, abortController }) 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") {
            toolNames.push(block.name);
            pushScanTool(block.name);
          }
        }
      }
    }
  } catch (err) {
    console.error(`[check-scheduler] batch check failed:`, err);
    return Array(items.length).fill(null);
  }
  if (!raw.trim()) return Array(items.length).fill(null);
  const parsedList = parseBatchVerdicts(raw, items.length);
  const used = sourcesUsed(toolNames);
  const checkedAt = new Date().toISOString();
  return parsedList.map((p) =>
    p ? { checkedAt, ...p, sourcesUsed: used } : null,
  );
}

/** True if we ran too many checks recently and should sit out a cycle.
 *  Cap is read from user settings on each call so changes apply live. */
function tooManyRecentChecks(history: Record<string, CachedCheck[]>): boolean {
  const cap = loadSettings().concurrentCapPerHour;
  const oneHourAgo = Date.now() - 3600 * 1000;
  let n = 0;
  for (const arr of Object.values(history)) {
    for (const v of arr) {
      const t = Date.parse(v.checkedAt);
      if (!isNaN(t) && t > oneHourAgo) n++;
    }
  }
  return n >= cap;
}

type DiscoverySummary = { added: number; refreshed: number; total: number };

type TickResult =
  | { reason: "no-items"; ran: 0; total: 0; freshCount: 0; discovered?: DiscoverySummary }
  | { reason: "all-fresh"; ran: 0; total: number; freshCount: number; discovered?: DiscoverySummary }
  | { reason: "hourly-cap"; ran: 0; total: number; freshCount: number; discovered?: DiscoverySummary }
  | { reason: "ran"; ran: number; total: number; freshCount: number; checked: { source: string; kind: string; verdict: string | null }[]; discovered?: DiscoverySummary };

/**
 * Two policies:
 *   - "background" (default): only check stale items. If everything is
 *     fresh, do nothing. This is what setInterval runs.
 *   - "force": ignore staleness — always pick ITEMS_PER_TICK candidates.
 *     This is what the "Scan now" button uses; user explicitly wants
 *     work to happen.
 */
async function tick(policy: "background" | "force" = "background"): Promise<TickResult> {
  if (running) return { reason: "hourly-cap", ran: 0, total: 0, freshCount: 0 };
  running = true;
  activeAbort = new AbortController();
  try {
    const isUserScan = policy === "force";
    let discoverySummary: DiscoverySummary | undefined;

    // ─── Phase 1 — discovery (user-triggered scans only). ─────────────
    // Background ticks skip discovery to keep the every-30-min loop cheap;
    // the user-pressed "Scan now" is what refreshes the discovered cache.
    if (isUserScan) {
      setScanRunning(0);
      setScanPhase("discovering", "Looking across your active sources");
      try {
        discoverySummary = await runDiscovery();
        setScanDiscovered(discoverySummary);
        console.log(`[check-scheduler] discovery: +${discoverySummary.added} new, ${discoverySummary.refreshed} refreshed (total ${discoverySummary.total})`);
      } catch (err) {
        console.error("[check-scheduler] discovery failed; proceeding with cached + MEMORY items:", err);
      }
    }

    // ─── Build the item pool: MEMORY scan + persisted discovered cache. ───
    // Discovery cache is additive — surviving items from prior runs stay in
    // Activity even if today's discovery returned zero.
    const history = loadCheckHistory();
    const local = scanWorkspaceForCheck();
    const allDiscovered = listDiscovered();
    // Stale discovered items still display in /api/now (dimmed) but are
    // skipped from the per-item check pool — no point burning quota on
    // items the agent didn't surface in 14 days.
    const freshDiscovered = allDiscovered.filter((d) => !isDiscoveredStale(d)) as NowItem[];
    const merged = mergeThreads([...local, ...freshDiscovered]);
    if (!merged.length) return { reason: "no-items", ran: 0, total: 0, freshCount: 0, discovered: discoverySummary };
    const items = merged;

    const kindOrder: Record<string, number> = { overdue: 0, today: 1, stalled: 2, upcoming: 3, open: 4 };
    const ranked = items
      .map((it) => {
        const key = itemKey(it.file, it.line, it.text);
        const cached = latestCheck(history, key);
        const stale = isStale(cached, STALE_AFTER_MS);
        return { it, key, cached, stale };
      })
      .sort((a, b) => {
        if (a.stale !== b.stale) return a.stale ? -1 : 1;
        const ko = (kindOrder[a.it.kind] ?? 9) - (kindOrder[b.it.kind] ?? 9);
        if (ko !== 0) return ko;
        const ta = a.cached ? Date.parse(a.cached.checkedAt) : 0;
        const tb = b.cached ? Date.parse(b.cached.checkedAt) : 0;
        return ta - tb;
      });

    const freshCount = ranked.filter((r) => !r.stale).length;

    const pool = policy === "force" ? ranked : ranked.filter((r) => r.stale);
    const candidates = pool.slice(0, ITEMS_PER_TICK);

    if (!candidates.length) {
      console.log(`[check-scheduler] all ${items.length} items have fresh verdicts`);
      return { reason: "all-fresh", ran: 0, total: items.length, freshCount, discovered: discoverySummary };
    }

    if (tooManyRecentChecks(history)) {
      console.log("[check-scheduler] skip — recent checks over cap");
      return { reason: "hourly-cap", ran: 0, total: items.length, freshCount, discovered: discoverySummary };
    }

    console.log(`[check-scheduler] running batch check for ${candidates.length} item(s) (policy=${policy}, model=${SCHEDULER_MODEL})…`);
    if (isUserScan) {
      setScanPhase("checking");
      setScanCurrent(candidates[0].it.source, candidates[0].it.kind, 0, candidates.length);
    }
    const batchResults = await runBatchCheck(candidates.map((c) => c.it), activeAbort);
    const checked: { source: string; kind: string; verdict: string | null }[] = [];
    for (let i = 0; i < candidates.length; i++) {
      const c = candidates[i];
      const result = batchResults[i];
      if (result) {
        appendCheck(history, c.key, result);
        console.log(`[check-scheduler]  ✓ ${c.it.kind} ${c.it.source.slice(0, 30)} → ${result.verdict}`);
        checked.push({ source: c.it.source, kind: c.it.kind, verdict: result.verdict });
      }
    }
    saveCheckHistory(history);
    if (!isUserScan && checked.length > 0) setBackgroundComplete();
    return { reason: "ran", ran: checked.length, total: items.length, freshCount, checked, discovered: discoverySummary };
  } finally {
    running = false;
    activeAbort = null;
    // Only flip scan-state to idle for user-triggered scans. Background
    // ticks never set running=true on scan-state, and calling setScanIdle
    // here would silently wipe the dashboard's view of the last manual
    // scan's phase + tool log + discovery summary.
    if (policy === "force") setScanIdle("done");
  }
}

/** Abort an in-flight scan (user pressed Stop). No-op if nothing is running. */
export function abortScan(): boolean {
  if (!activeAbort) return false;
  activeAbort.abort();
  return true;
}

export function startCheckScheduler() {
  if (process.env.HOP_DISABLE_SCHEDULER === "1") {
    console.log("[check-scheduler] disabled via env");
    return;
  }
  console.log(`[check-scheduler] starting — first run in ${FIRST_RUN_DELAY_MS / 1000}s, then every ${TICK_MS / 3600000}h`);
  setTimeout(() => {
    tick().catch((e) => console.error("[check-scheduler] tick error:", e));
    setInterval(() => tick().catch((e) => console.error("[check-scheduler] tick error:", e)), TICK_MS);
  }, FIRST_RUN_DELAY_MS);
}

/** Public entry for the "Scan now" button. Runs a single tick on demand
 *  with FORCE policy — re-checks even fresh items so the user always
 *  gets work done when they click. Background loop continues to use
 *  the conservative "stale-only" policy. */
export async function runCheckSchedulerTick() {
  if (running) return { ok: false as const, reason: "already-running" };
  const result = await tick("force");
  return { ok: true as const, ...result };
}
