/**
 * Suite 2: Learning Curve Test
 *
 * The crown jewel. Proves the flywheel learns from feedback over 20 rounds
 * of suggest -> evaluate -> feedback -> re-suggest. Tracks F1, precision,
 * recall, MRR, suppression count, and per-category breakdowns across rounds.
 *
 * Feedback is applied with realistic noise (85/15 split) to simulate
 * imperfect human reviewers.
 */

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import {
  buildGroundTruthVault,
  stripLinks,
  runSuggestionsOnVault,
  evaluateSuggestions,
  loadPrimaryVault,
  type TempVault,
  type GroundTruthSpec,
  type PrecisionRecallReport,
  type SuggestionRun,
} from './harness.js';
import {
  recordFeedback,
  updateSuppressionList,
  isSuppressed,
  getFeedbackBoost,
} from '../../src/core/write/wikilinkFeedback.js';
import { writeReport, Timer, type TestReport, type TuningRecommendation } from './report-utils.js';
import type { StateDb } from '@velvetmonkey/vault-core';

// =============================================================================
// Constants
// =============================================================================

const TOTAL_ROUNDS = 20;
const TP_CORRECT_RATE = 0.85;  // 85% of TPs recorded as correct
const FP_CORRECT_RATE = 0.15;  // 15% of FPs recorded as correct (noise)

// =============================================================================
// Helpers
// =============================================================================

/** Seeded PRNG (mulberry32) for deterministic noise */
function mulberry32(seed: number): () => number {
  let s = seed | 0;
  return () => {
    s = (s + 0x6d2b79f5) | 0;
    let t = Math.imul(s ^ (s >>> 15), 1 | s);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

const normalize = (name: string): string => name.toLowerCase().replace(/-/g, ' ');

/** Per-round metrics snapshot */
interface RoundMetrics {
  round: number;
  f1: number;
  precision: number;
  recall: number;
  mrr: number;
  truePositives: number;
  falsePositives: number;
  falseNegatives: number;
  suppressionCount: number;
  suppressedEntities: string[];
  byCategory: Record<string, { precision: number; recall: number; f1: number; count: number }>;
  byTier: Record<1 | 2 | 3, { precision: number; recall: number; f1: number; count: number }>;
  unresolvedLinks: Array<{entity: string, notePath: string, tier: 1|2|3}>;
  feedbackLayerAvgContribution: number;
}

// (negativeFeedbackCounts removed — suppression computed once per round)

// =============================================================================
// Test Suite
// =============================================================================

describe('Suite 2: Learning Curve', () => {
  let vault: TempVault;
  let spec: GroundTruthSpec;
  const roundMetrics: RoundMetrics[] = [];
  const timer = new Timer();

  beforeAll(async () => {
    // 1. Load primary vault fixture
    spec = await loadPrimaryVault();
    vault = await buildGroundTruthVault(spec);
    await stripLinks(vault, spec.groundTruth);

    // 2. Run 20 rounds of suggest -> evaluate -> feedback -> re-suggest
    const rng = mulberry32(42); // deterministic seed
    for (let round = 0; round < TOTAL_ROUNDS; round++) {
      // Step 1: Run suggestions on all notes
      const runs = await runSuggestionsOnVault(vault, { strictness: 'balanced' });

      // Step 2: Evaluate against ground truth
      const report = evaluateSuggestions(runs, spec.groundTruth, spec.entities);

      // Step 3: Classify suggestions as TP or FP
      const gtByNote = new Map<string, Set<string>>();
      for (const gt of spec.groundTruth) {
        const set = gtByNote.get(gt.notePath) || new Set();
        set.add(normalize(gt.entity));
        gtByNote.set(gt.notePath, set);
      }

      // Step 4: Simulate user feedback with realistic noise
      for (const run of runs) {
        const noteGt = gtByNote.get(run.notePath);
        if (!noteGt) continue;

        for (const suggestion of run.suggestions) {
          const normalizedSuggestion = normalize(suggestion);
          const isTP = noteGt.has(normalizedSuggestion);

          if (isTP) {
            // True positive: 85% correct, 15% noise (incorrect)
            const isCorrect = rng() < TP_CORRECT_RATE;
            recordFeedback(vault.stateDb, suggestion, 'learning-curve', run.notePath, isCorrect);
          } else {
            // False positive: 85% incorrect, 15% noise (correct)
            const isCorrect = rng() < FP_CORRECT_RATE;
            recordFeedback(vault.stateDb, suggestion, 'learning-curve', run.notePath, isCorrect);
          }
        }
      }

      // Update suppressions once per round with all feedback accumulated
      updateSuppressionList(vault.stateDb);

      // Step 5: Record round metrics
      const suppressedEntities = (vault.stateDb.db.prepare(
        'SELECT entity FROM wikilink_suppressions',
      ).all() as Array<{entity: string}>).map(r => r.entity);
      const suppressionCount = suppressedEntities.length;

      // Compute average feedback layer contribution from detailed breakdowns
      let feedbackSum = 0;
      let feedbackCount = 0;
      for (const run of runs) {
        if (!run.detailed) continue;
        for (const d of run.detailed) {
          if (d.breakdown.feedbackAdjustment !== undefined) {
            feedbackSum += d.breakdown.feedbackAdjustment;
            feedbackCount++;
          }
        }
      }
      const feedbackLayerAvgContribution = feedbackCount > 0 ? feedbackSum / feedbackCount : 0;

      roundMetrics.push({
        round,
        f1: report.f1,
        precision: report.precision,
        recall: report.recall,
        mrr: report.mrr,
        truePositives: report.truePositives,
        falsePositives: report.falsePositives,
        falseNegatives: report.falseNegatives,
        suppressionCount,
        suppressedEntities,
        byCategory: report.byCategory,
        byTier: report.byTier,
        unresolvedLinks: report.falseNegativeDetails,
        feedbackLayerAvgContribution,
      });
    }
  }, 600000); // 10 min timeout for 20 rounds

  afterAll(async () => {
    // Write diagnostic report
    if (roundMetrics.length > 0) {
      const round0 = roundMetrics[0];
      const roundN = roundMetrics[roundMetrics.length - 1];
      const f1Improvement = roundN.f1 - round0.f1;

      const tuning_recommendations: TuningRecommendation[] = [];
      if (f1Improvement < 0.05) {
        tuning_recommendations.push({
          parameter: 'FEEDBACK_BOOST_MIN_SAMPLES',
          current_value: 5,
          suggested_value: 3,
          evidence: `F1 improvement over ${TOTAL_ROUNDS} rounds was only ${(f1Improvement * 100).toFixed(1)}% (< 5%). ` +
            'Tightening feedback thresholds may help the system learn faster.',
          confidence: 'medium',
        });
      }

      const report: TestReport = {
        suite: 'learning-curve-report',
        timestamp: new Date().toISOString(),
        duration_ms: timer.elapsed(),
        summary: {
          round0_f1: round0.f1,
          roundN_f1: roundN.f1,
          f1_improvement: Math.round(f1Improvement * 10000) / 10000,
          total_suppressions: roundN.suppressionCount,
        },
        details: roundMetrics.map(m => ({
          round: m.round,
          f1: m.f1,
          precision: m.precision,
          recall: m.recall,
          mrr: m.mrr,
          truePositives: m.truePositives,
          falsePositives: m.falsePositives,
          falseNegatives: m.falseNegatives,
          suppressionCount: m.suppressionCount,
          feedbackLayerAvgContribution: Math.round(m.feedbackLayerAvgContribution * 10000) / 10000,
          byCategory: m.byCategory,
          byTier: m.byTier,
          suppressedEntities: m.suppressedEntities,
          unresolvedLinks: m.unresolvedLinks.slice(0, 20), // Cap at 20 for readability
        })),
        tuning_recommendations,
      };

      await writeReport(report);
    }

    if (vault) await vault.cleanup();
  });

  // ===========================================================================
  // Assertions
  // ===========================================================================

  it('F1 at final round does not regress from round 0', () => {
    expect(roundMetrics.length).toBe(TOTAL_ROUNDS);
    const round0 = roundMetrics[0];
    const roundN = roundMetrics[TOTAL_ROUNDS - 1];
    // After eliminating the double-penalty (Layer 10 negative boosts + Layer 0
    // suppression), F1 should be non-negative relative to round 0.
    // Generous tolerance — Windows CI shows up to 5pp variance between runs.
    expect(roundN.f1).toBeGreaterThanOrEqual(round0.f1 - 0.10);
  });

  it('precision trend is non-decreasing after round 3 (2pp tolerance for soft suppression oscillation)', () => {
    expect(roundMetrics.length).toBe(TOTAL_ROUNDS);
    // After the system has accumulated enough feedback (round 3+),
    // precision should trend upward or stay flat. With Beta-Binomial soft
    // suppression, entities near the threshold cause oscillations.
    // Generous tolerance — Windows CI shows higher variance.
    for (let i = 4; i < TOTAL_ROUNDS; i++) {
      const prev = roundMetrics[i - 1];
      const curr = roundMetrics[i];
      expect(curr.precision).toBeGreaterThanOrEqual(prev.precision - 0.10);
    }
  });

  it('no single round drops F1 by more than 20pp', () => {
    expect(roundMetrics.length).toBe(TOTAL_ROUNDS);
    for (let i = 1; i < TOTAL_ROUNDS; i++) {
      const prev = roundMetrics[i - 1];
      const curr = roundMetrics[i];
      const drop = prev.f1 - curr.f1;
      // With noisy feedback on a small fixture, a single suppression event
      // can drop F1 ~17pp. 20pp tolerance guards against catastrophic swings.
      expect(drop).toBeLessThanOrEqual(0.20);
    }
  });

  it('suppression count does not exceed 60% of entities', () => {
    expect(roundMetrics.length).toBe(TOTAL_ROUNDS);
    // With recency-weighted decay, suppressions can decrease as old negative
    // feedback fades. Guard against runaway suppression instead.
    const entityCount = roundMetrics[0].truePositives + roundMetrics[0].falsePositives + roundMetrics[0].falseNegatives;
    for (let i = 0; i < TOTAL_ROUNDS; i++) {
      expect(roundMetrics[i].suppressionCount).toBeLessThanOrEqual(Math.ceil(entityCount * 0.6));
    }
  });

  it('at least 1 category reaches F1 > 0 by final round', () => {
    expect(roundMetrics.length).toBe(TOTAL_ROUNDS);
    const roundN = roundMetrics[TOTAL_ROUNDS - 1];
    const categoryF1s = Object.values(roundN.byCategory).map(c => c.f1);
    const hasPositiveF1 = categoryF1s.some(f1 => f1 > 0);
    expect(hasPositiveF1).toBe(true);
  });

  it('feedback layer average contribution is non-negative at final round', () => {
    const finalRound = roundMetrics[TOTAL_ROUNDS - 1];
    expect(finalRound.feedbackLayerAvgContribution).toBeGreaterThanOrEqual(0);
  });

  it('suppression count at final round is below 25', () => {
    const finalRound = roundMetrics[TOTAL_ROUNDS - 1];
    // With 15% feedback noise over 20 rounds, ~15-21 genuine FPs get correctly
    // suppressed (F1 keeps improving, confirming suppressions are helping).
    // PR #136 lowered prior Beta(8,1)→Beta(4,1) and raised threshold 0.35→0.45,
    // so more entities are correctly suppressed. Ceiling raised 20→25.
    // Guard against runaway suppression; 60% ceiling assertion handles the rest.
    expect(finalRound.suppressionCount).toBeLessThanOrEqual(25);
  });

  it('T1 recall >= 0.8 at final round', () => {
    const finalRound = roundMetrics[TOTAL_ROUNDS - 1];
    // Direct mentions should almost always be found
    if (finalRound.byTier[1].count > 0) {
      expect(finalRound.byTier[1].recall).toBeGreaterThanOrEqual(0.8);
    }
  });
});
