/**
 * PR Nanny — host-side daemon that watches all worlds' open PRs and
 * dispatches fixes via `olam dispatch` when CI/reviews block them.
 *
 * Extends the pr-merge-poller loop pattern. Runs at 60s cadence.
 * Opt-out: OLAM_PR_NANNY=0 (default: enabled).
 *
 * State machine per PR (stored in world-pr-state.json nanny_* fields):
 *   watching → dispatching → (paused | escalated | halted)
 *
 * Halt conditions (stop dispatching but keep watching):
 *   1. dispatch_count >= MAX_DISPATCHES (configurable, default 5)
 *   2. wall-clock since first dispatch >= MAX_WALL_CLOCK_MIN (default 60)
 *   3. same-root-cause loop detected (last 2 dispatch summaries identical)
 *   4. operator manual pause
 *
 * Tier escalation (PR #N tier-escalation):
 *   On each retry, the nanny advances to the next tier in `escalationTiers`
 *   (stored per-world in nanny_current_tier) instead of repeating the same
 *   model. When the chain is exhausted, emits `dispatch.tier-exhausted` on
 *   the host-stream and falls back to existing operator escalation.
 */

import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { pickNextTier } from './dispatch/tier-escalator.mjs';
import { safePersistLastDispatch } from './dispatch-persister.mjs';

const execFileAsync = promisify(execFile);

const GH_API_BASE = 'https://api.github.com';

// Known external-blocker CI check name patterns.
// When ALL failing checks match these patterns, the PR is not actionable
// (the root cause is infrastructure/release-pipeline, not the world's code).
const EXTERNAL_BLOCKER_PATTERNS = [
  /detect-image-scopes/i,
  /publish-mcp-auth/i,
  /retag-mcp-auth/i,
  /bootstrap.*publish/i,
  /release.*pipeline/i,
  /ghcr.*push/i,
];

/**
 * @param {string} checkName
 * @returns {boolean}
 */
function isExternalBlockerCheck(checkName) {
  return EXTERNAL_BLOCKER_PATTERNS.some((re) => re.test(checkName));
}

/**
 * Returns true when ALL failing CI checks are external-blocker patterns.
 * @param {Array<{name: string, conclusion: string|null}>} checks
 */
export function isExternalBlocker(checks) {
  const failing = checks.filter(
    (c) => c.conclusion === 'failure' || c.conclusion === 'action_required',
  );
  if (failing.length === 0) return false;
  return failing.every((c) => isExternalBlockerCheck(c.name));
}

/**
 * @param {string} prUrl  e.g. https://github.com/org/repo/pull/123
 * @returns {{ owner: string, repo: string, number: number } | null}
 */
function parsePrUrl(prUrl) {
  const m = /github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/.exec(prUrl);
  if (!m) return null;
  return { owner: m[1], repo: m[2], number: parseInt(m[3], 10) };
}

/**
 * @param {{
 *   prStateStore: ReturnType<import('./world-pr-state.mjs').createWorldPrStateStore>,
 *   getGhToken: () => Promise<string|null>,
 *   dispatchToWorld: (worldId: string, prompt: string, opts?: { tier?: string }) => Promise<void>,
 *   consultCodex: (ctx: string) => Promise<string>,
 *   broadcastTierEvent?: (eventType: string, payload: unknown) => void,
 *   pollIntervalMs?: number,
 *   maxDispatches?: number,
 *   maxWallClockMin?: number,
 * }} opts
 */
export function createPrNanny({
  prStateStore,
  getGhToken,
  dispatchToWorld,
  consultCodex,
  broadcastTierEvent = () => {},
  pollIntervalMs = 60_000,
  maxDispatches = parseInt(process.env.OLAM_PR_NANNY_MAX_DISPATCHES ?? '5', 10),
  maxWallClockMin = parseInt(process.env.OLAM_PR_NANNY_MAX_WALL_CLOCK_MIN ?? '60', 10),
}) {
  const enabled = (process.env.OLAM_PR_NANNY ?? '1') !== '0';
  if (!enabled) return { start() {}, stop() {} };

  let intervalId = null;
  let warnedOnce = false;

  /**
   * Fetch CI check runs for the PR's head SHA.
   * @param {string} owner @param {string} repo @param {number} prNumber @param {string} ghToken
   * @returns {Promise<Array<{name: string, conclusion: string|null}>>}
   */
  async function fetchChecks(owner, repo, prNumber, ghToken) {
    try {
      // First get the PR head SHA
      const prRes = await fetch(
        `${GH_API_BASE}/repos/${owner}/${repo}/pulls/${prNumber}`,
        { headers: { Authorization: `token ${ghToken}`, Accept: 'application/vnd.github+json' } },
      );
      if (!prRes.ok) return [];
      const prData = await prRes.json();
      const sha = prData.head?.sha;
      if (!sha) return [];

      const checkRes = await fetch(
        `${GH_API_BASE}/repos/${owner}/${repo}/commits/${sha}/check-runs?per_page=100`,
        { headers: { Authorization: `token ${ghToken}`, Accept: 'application/vnd.github+json' } },
      );
      if (!checkRes.ok) return [];
      const checkData = await checkRes.json();
      return (checkData.check_runs ?? []).map((r) => ({
        name: r.name,
        conclusion: r.conclusion,
        status: r.status,
      }));
    } catch {
      return [];
    }
  }

  /**
   * @param {string} worldId
   * @param {object} entry  current pr-state entry
   * @param {string} ghToken
   */
  async function processWorld(worldId, entry, ghToken) {
    if (entry.nanny_paused || entry.nanny_escalated) return;
    if (entry.pr_state !== 'open') return;

    const parsed = parsePrUrl(entry.pr_url);
    if (!parsed) return;

    // Halt: dispatch cap
    const dispatchCount = entry.nanny_dispatch_count ?? 0;
    if (dispatchCount >= maxDispatches) return;

    // Halt: wall-clock ceiling
    if (entry.nanny_first_dispatch_at) {
      const elapsedMin = (Date.now() - new Date(entry.nanny_first_dispatch_at).getTime()) / 60_000;
      if (elapsedMin >= maxWallClockMin) return;
    }

    const checks = await fetchChecks(parsed.owner, parsed.repo, parsed.number, ghToken);
    const hasCiFailure = checks.some(
      (c) => c.conclusion === 'failure' || c.conclusion === 'action_required',
    );
    const allPassing = checks.length > 0 && checks.every(
      (c) => c.conclusion === 'success' || c.conclusion === 'skipped' || c.conclusion === 'neutral',
    );

    if (allPassing || checks.length === 0) return;
    if (!hasCiFailure) return;

    // External blocker — do not dispatch
    if (isExternalBlocker(checks)) {
      prStateStore.set(worldId, { nanny_external_blocker: true });
      return;
    }

    prStateStore.set(worldId, { nanny_external_blocker: false });

    const failingNames = checks
      .filter((c) => c.conclusion === 'failure' || c.conclusion === 'action_required')
      .map((c) => c.name)
      .join(', ');

    const prompt = `CI is failing on PR ${entry.pr_url}. Failing checks: ${failingNames}. Investigate the root cause, fix the code, and push.`;

    // Halt: same-root-cause loop detection
    if (entry.nanny_last_dispatch_prompt && entry.nanny_last_dispatch_prompt === prompt) {
      console.log(`[pr-nanny] loop detected for ${worldId} — same prompt as last dispatch, halting`);
      prStateStore.set(worldId, { nanny_loop_halted: true });
      return;
    }

    // Consult Codex before dispatching
    const codexCtx = `World ${worldId} has a failing PR: ${entry.pr_url}. Failing CI checks: ${failingNames}. Should we dispatch a fix? Answer: agree, push-back, or rethink.`;
    let verdict = 'agree';
    try {
      verdict = await consultCodex(codexCtx);
    } catch (err) {
      console.warn(`[pr-nanny] codex consult failed for ${worldId}: ${err.message} — defaulting to agree`);
    }

    if (verdict === 'push-back') {
      prStateStore.set(worldId, { nanny_paused: true, nanny_pause_reason: 'codex_pushback' });
      console.log(`[pr-nanny] Codex push-back for ${worldId} — pausing nanny`);
      return;
    }
    if (verdict === 'rethink') {
      prStateStore.set(worldId, { nanny_escalated: true, nanny_escalate_reason: 'codex_rethink' });
      console.log(`[pr-nanny] Codex rethink for ${worldId} — escalating`);
      return;
    }

    // ── Tier escalation (PR #938) ───────────────────────────────────────────
    //
    // `nanny_escalation_tiers` is set by the olam_dispatch caller via the
    // escalationTiers schema field and persisted here by server.mjs when the
    // world is registered for nanny tracking. Defaults to ['sonnet'] when
    // absent (no escalation, no cost surprise).
    //
    // `nanny_current_tier` tracks the model tier used by the LAST dispatch for
    // this PR. On first dispatch (dispatchCount === 0) it is undefined, and we
    // use escalationTiers[0] as the starting tier. On retries we advance the
    // chain via pickNextTier. This is the pr-state store (option c from the
    // design doc) — it persists across polls and matches the nanny_* field
    // pattern already established by nanny_dispatch_count et al.
    const escalationTiers = entry.nanny_escalation_tiers ?? ['sonnet'];
    const currentTier = entry.nanny_current_tier ?? escalationTiers[0] ?? 'sonnet';
    let tierForThisDispatch = currentTier;

    if (dispatchCount > 0) {
      // This is a retry — try to escalate the tier.
      const nextTier = pickNextTier(currentTier, escalationTiers);
      if (nextTier !== null) {
        tierForThisDispatch = nextTier;
        broadcastTierEvent('dispatch.escalated', {
          worldId,
          fromTier: currentTier,
          toTier: nextTier,
          reason: 'retry-after-failure',
        });
        console.log(`[pr-nanny] tier escalated for ${worldId}: ${currentTier} → ${nextTier}`);
      } else {
        // Chain exhausted — emit tier-exhausted and fall back to operator escalation.
        broadcastTierEvent('dispatch.tier-exhausted', {
          worldId,
          exhaustedTier: currentTier,
          escalationTiers,
        });
        console.log(`[pr-nanny] tier chain exhausted for ${worldId} (last tier: ${currentTier}) — escalating to operator`);
        prStateStore.set(worldId, { nanny_escalated: true, nanny_escalate_reason: 'tier_exhausted' });
        return;
      }
    }

    // Dispatch fix
    try {
      safePersistLastDispatch({
        worldId,
        messageId: `nanny-${worldId}-${Date.now()}`,
        prompt,
        source: 'pr-nanny',
      });
      await dispatchToWorld(worldId, prompt, { tier: tierForThisDispatch });
      const now = new Date().toISOString();
      prStateStore.set(worldId, {
        nanny_dispatch_count: dispatchCount + 1,
        nanny_first_dispatch_at: entry.nanny_first_dispatch_at ?? now,
        nanny_last_dispatch_at: now,
        nanny_last_dispatch_prompt: prompt,
        nanny_current_tier: tierForThisDispatch,
      });
      console.log(`[pr-nanny] dispatched fix to ${worldId} (dispatch ${dispatchCount + 1}/${maxDispatches}, tier: ${tierForThisDispatch})`);
    } catch (err) {
      console.error(`[pr-nanny] dispatch failed for ${worldId}: ${err.message}`);
    }
  }

  async function pollOnce() {
    const ghToken = await getGhToken();
    if (!ghToken) {
      if (!warnedOnce) {
        console.warn('[pr-nanny] no GH token — CI polling disabled');
        warnedOnce = true;
      }
      return;
    }

    const worlds = prStateStore.getWorldsToWatch();
    for (const { worldId, ...entry } of worlds) {
      try {
        await processWorld(worldId, entry, ghToken);
      } catch (err) {
        console.error(`[pr-nanny] processWorld error for ${worldId}: ${err.message}`);
      }
    }
  }

  function start() {
    if (intervalId !== null) return;
    // Immediate first poll
    pollOnce().catch((err) => console.error('[pr-nanny] pollOnce error:', err.message));
    intervalId = setInterval(() => {
      pollOnce().catch((err) => console.error('[pr-nanny] pollOnce error:', err.message));
    }, pollIntervalMs);
  }

  function stop() {
    if (intervalId !== null) {
      clearInterval(intervalId);
      intervalId = null;
    }
  }

  return { start, stop };
}

/**
 * Default Codex consultation via the host-side `codex` CLI.
 * @param {string} ctx
 * @returns {Promise<'agree'|'push-back'|'rethink'>}
 */
export async function defaultConsultCodex(ctx) {
  try {
    const { stdout } = await execFileAsync('codex', [
      '--quiet',
      '--model', 'codex-mini-latest',
      `Adversarial review — is this a good idea? ${ctx} Reply with exactly one word: agree, push-back, or rethink.`,
    ], { timeout: 30_000 });
    const text = stdout.trim().toLowerCase();
    if (text.startsWith('push')) return 'push-back';
    if (text.startsWith('rethink')) return 'rethink';
    return 'agree';
  } catch {
    return 'agree'; // fail-open: if codex unavailable, dispatch anyway
  }
}

/**
 * Default dispatch: shell out to `olam dispatch <worldId> <prompt>`.
 * @param {string} worldId
 * @param {string} prompt
 */
export async function defaultDispatchToWorld(worldId, prompt) {
  await execFileAsync('olam', ['dispatch', worldId, prompt], { timeout: 60_000 });
}
