'use strict';
const router = require('express').Router();
const db = require('../db/db');
const { authMiddleware } = require('../middleware/auth');

router.use(authMiddleware);

/* ── Statements (prepared once) ────────────────────────────────── */
const SQL = {
  listTracks: db.prepare(`
    SELECT exam_key, title, subject_slug, grade, duration_min,
           tasks_per_variant, variants_count, intro_html, sort_order
      FROM exam_tracks
     WHERE enabled = 1
     ORDER BY sort_order, exam_key
  `),
  getTrack: db.prepare(`
    SELECT exam_key, title, subject_slug, grade, duration_min,
           tasks_per_variant, variants_count, scoring_json, intro_html
      FROM exam_tracks
     WHERE exam_key = ? AND enabled = 1
  `),
  countTasks: db.prepare(`
    SELECT
      COUNT(*) AS total,
      COALESCE(SUM(CASE WHEN task_type = 'mc'   THEN 1 ELSE 0 END), 0) AS mc,
      COALESCE(SUM(CASE WHEN task_type = 'open' THEN 1 ELSE 0 END), 0) AS open,
      COALESCE(SUM(CASE WHEN task_type = 'long' THEN 1 ELSE 0 END), 0) AS long
      FROM exam_tasks
     WHERE exam_key = ?
  `),
  /* For each (user, task) — was the most-recent attempt correct?
     A task counts as «solved» iff at least one historical attempt is_correct = 1. */
  userProgress: db.prepare(`
    SELECT
      COUNT(DISTINCT exam_task_id) AS tasks_attempted,
      COUNT(DISTINCT CASE WHEN is_correct = 1 THEN exam_task_id END) AS tasks_solved,
      COUNT(*) AS total_attempts,
      COALESCE(SUM(CASE WHEN is_correct = 1 THEN 1 ELSE 0 END), 0) AS correct_attempts
      FROM exam_attempts a
      JOIN exam_tasks t ON t.id = a.exam_task_id
     WHERE a.user_id = ? AND t.exam_key = ?
  `),

  /* Variants list + per-user counts. One row per variant.
     - total:        tasks in variant
     - solved:       distinct tasks where user has ≥1 correct attempt
     - viewed_sol:   distinct tasks where user opened the solution */
  listVariants: db.prepare(`
    SELECT
      t.variant,
      COUNT(*) AS total,
      COUNT(DISTINCT CASE WHEN a.is_correct = 1 THEN t.id END) AS solved,
      COUNT(DISTINCT CASE WHEN a.solution_viewed = 1 THEN t.id END) AS viewed_sol
      FROM exam_tasks t
      LEFT JOIN exam_attempts a
        ON a.exam_task_id = t.id AND a.user_id = ?
     WHERE t.exam_key = ?
     GROUP BY t.variant
     ORDER BY t.variant
  `),

  getVariantTasks: db.prepare(`
    SELECT
      id, task_idx, task_type, text_html, figure_html, opts_json,
      answer, solution_html, topic, subtopic
      FROM exam_tasks
     WHERE exam_key = ? AND variant = ?
     ORDER BY task_idx
  `),

  /* Practice batch — RANDOM strategy.
     Excludes long tasks (no auto-check) by default to keep UX consistent.
     NOTE: kept for backwards compatibility / fallback. The primary random
     path goes through pickRandomByDifficulty() below (difficulty-ordered
     with optional topic exclusions). */
  practiceRandom: db.prepare(`
    SELECT
      id, task_idx, variant, task_type, text_html, figure_html, opts_json,
      answer, solution_html, topic, subtopic, difficulty
      FROM exam_tasks
     WHERE exam_key = ?
       AND task_type IN ('mc','open')
     ORDER BY RANDOM()
     LIMIT ?
  `),

  /* All subtopic slugs known for an exam — used to validate ?exclude=
     so a bogus slug doesn't silently drop the filter. */
  listSubtopicSlugs: db.prepare(`
    SELECT slug FROM exam_topics
     WHERE exam_key = ? AND parent_slug IS NOT NULL
  `),

  /* Topic reference map: subtopic_slug → textbook chapter / paragraph.
     Loaded once and cached so we can stamp `topic_ref` onto every task
     shape without an extra JOIN per query. */
  listTopicRefs: db.prepare(`
    SELECT slug, title, textbook_slug, textbook_paragraph
      FROM exam_topics
     WHERE exam_key = ? AND textbook_slug IS NOT NULL
  `),

  /* Practice batch — UNSOLVED strategy.
     Excludes tasks the user has already solved correctly at least once. */
  practiceUnsolved: db.prepare(`
    SELECT
      t.id, t.task_idx, t.variant, t.task_type, t.text_html, t.figure_html,
      t.opts_json, t.answer, t.solution_html, t.topic
      FROM exam_tasks t
     WHERE t.exam_key = ?
       AND t.task_type IN ('mc','open')
       AND NOT EXISTS (
         SELECT 1 FROM exam_attempts a
          WHERE a.exam_task_id = t.id
            AND a.user_id = ?
            AND a.is_correct = 1
       )
     ORDER BY RANDOM()
     LIMIT ?
  `),

  /* For attempt save: confirm the task belongs to a known track + user */
  getTaskExamKey: db.prepare(`SELECT exam_key FROM exam_tasks WHERE id = ?`),

  insertAttempt: db.prepare(`
    INSERT INTO exam_attempts
      (user_id, exam_task_id, user_answer, is_correct, time_ms,
       mode, session_id, hint_used, solution_viewed, created_at)
    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  `),

  /* ── Mock sessions ──────────────────────────────────────────── */
  getTasksByVariant: db.prepare(`
    SELECT id FROM exam_tasks WHERE exam_key = ? AND variant = ? ORDER BY task_idx
  `),
  getRandomTaskIds: db.prepare(`
    SELECT id FROM exam_tasks
     WHERE exam_key = ? AND task_type IN ('mc','open')
     ORDER BY RANDOM() LIMIT ?
  `),
  insertMockSession: db.prepare(`
    INSERT INTO exam_mock_sessions
      (user_id, exam_key, variant, source, task_ids_json,
       started_at, duration_planned_min, total_tasks, status)
    VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active')
  `),
  getMockSession: db.prepare(`
    SELECT id, user_id, exam_key, variant, source, task_ids_json,
           started_at, finished_at, duration_planned_min,
           score, total_correct, total_tasks, status
      FROM exam_mock_sessions
     WHERE id = ?
  `),
  getTracksScoring: db.prepare(`SELECT duration_min, scoring_json FROM exam_tracks WHERE exam_key = ?`),
  getTasksByIds: (ids) => {
    if (!ids.length) return [];
    const ph = ids.map(() => '?').join(',');
    return db.prepare(`
      SELECT id, task_idx, variant, task_type, text_html, figure_html, opts_json,
             answer, solution_html, topic
        FROM exam_tasks
       WHERE id IN (${ph})
    `).all(...ids);
  },
  /* Latest user-answer per task in a mock session (one row per task).
     We upsert by deleting prior rows for (user, task, session, mode='mock'). */
  deleteMockAnswer: db.prepare(`
    DELETE FROM exam_attempts
     WHERE user_id = ? AND exam_task_id = ? AND session_id = ? AND mode = 'mock'
  `),
  getMockAnswers: db.prepare(`
    SELECT exam_task_id, user_answer, is_correct
      FROM exam_attempts
     WHERE user_id = ? AND session_id = ? AND mode = 'mock'
  `),
  updateMockAttemptCorrectness: db.prepare(`
    UPDATE exam_attempts SET is_correct = ?
     WHERE user_id = ? AND exam_task_id = ? AND session_id = ? AND mode = 'mock'
  `),
  finalizeMockSession: db.prepare(`
    UPDATE exam_mock_sessions
       SET status = 'finished', finished_at = ?, score = ?, total_correct = ?
     WHERE id = ?
  `),

  /* ── Dashboard aggregates (F4) ──────────────────────────────── */

  /* Distinct calendar dates (UTC) on which the user had ≥1 correct attempt
     for tasks of a specific track, last 60 days. We compute streak from this in JS. */
  streakDays: db.prepare(`
    SELECT DISTINCT DATE(a.created_at / 1000, 'unixepoch') AS day
      FROM exam_attempts a
      JOIN exam_tasks t ON t.id = a.exam_task_id
     WHERE a.user_id = ? AND t.exam_key = ?
       AND a.is_correct = 1
       AND a.created_at >= ?
     ORDER BY day DESC
  `),

  /* Accuracy over a recent time window. */
  accuracyWindow: db.prepare(`
    SELECT
      COUNT(*) AS attempts,
      COALESCE(SUM(CASE WHEN a.is_correct = 1 THEN 1 ELSE 0 END), 0) AS correct
      FROM exam_attempts a
      JOIN exam_tasks t ON t.id = a.exam_task_id
     WHERE a.user_id = ? AND t.exam_key = ?
       AND a.is_correct IS NOT NULL
       AND a.created_at >= ?
  `),

  /* Recent attempts (last N) with task metadata. */
  recentAttempts: db.prepare(`
    SELECT
      a.id, a.exam_task_id, a.is_correct, a.solution_viewed, a.mode,
      a.user_answer, a.created_at,
      t.variant, t.task_idx, t.task_type, t.text_html
      FROM exam_attempts a
      JOIN exam_tasks t ON t.id = a.exam_task_id
     WHERE a.user_id = ? AND t.exam_key = ?
     ORDER BY a.created_at DESC
     LIMIT ?
  `),

  /* Activity heatmap — attempts per calendar day, last 28 days. */
  activityHeatmap: db.prepare(`
    SELECT
      DATE(a.created_at / 1000, 'unixepoch') AS day,
      COUNT(*) AS attempts,
      COALESCE(SUM(CASE WHEN a.is_correct = 1 THEN 1 ELSE 0 END), 0) AS correct
      FROM exam_attempts a
      JOIN exam_tasks t ON t.id = a.exam_task_id
     WHERE a.user_id = ? AND t.exam_key = ?
       AND a.created_at >= ?
     GROUP BY day
     ORDER BY day
  `),

  /* Recent mock sessions for "your last attempts" card on dashboard. */
  recentMocks: db.prepare(`
    SELECT id, variant, source, started_at, finished_at, status,
           score, total_correct, total_tasks
      FROM exam_mock_sessions
     WHERE user_id = ? AND exam_key = ?
     ORDER BY started_at DESC
     LIMIT ?
  `),

  /* ── Study plan (F10) ───────────────────────────────────────── */
  getPlan: db.prepare(`
    SELECT exam_date, daily_target, weak_focus, created_at, updated_at
      FROM exam_user_plan
     WHERE user_id = ? AND exam_key = ?
  `),
  upsertPlan: db.prepare(`
    INSERT INTO exam_user_plan
      (user_id, exam_key, exam_date, daily_target, weak_focus, created_at, updated_at)
    VALUES (?, ?, ?, ?, ?, ?, ?)
    ON CONFLICT(user_id, exam_key) DO UPDATE SET
      exam_date    = excluded.exam_date,
      daily_target = excluded.daily_target,
      weak_focus   = excluded.weak_focus,
      updated_at   = excluded.updated_at
  `),
  deletePlan: db.prepare(`DELETE FROM exam_user_plan WHERE user_id = ? AND exam_key = ?`),

  /* Tasks solved today (distinct, mc+open+long); used by "today" progress. */
  tasksSolvedToday: db.prepare(`
    SELECT COUNT(DISTINCT a.exam_task_id) AS solved
      FROM exam_attempts a
      JOIN exam_tasks t ON t.id = a.exam_task_id
     WHERE a.user_id = ? AND t.exam_key = ?
       AND a.is_correct = 1
       AND DATE(a.created_at / 1000, 'unixepoch') = DATE('now')
  `),

  /* ── Topics (F7) ────────────────────────────────────────────── */

  listTopicsWithCounts: db.prepare(`
    SELECT
      tp.slug, tp.parent_slug, tp.title, tp.sort_order,
      tp.textbook_slug, tp.textbook_paragraph,
      COALESCE(stat.total, 0)        AS total,
      COALESCE(stat.attempted, 0)    AS attempted,
      COALESCE(stat.solved, 0)       AS solved,
      COALESCE(stat.attempts, 0)     AS attempts,
      COALESCE(stat.correct, 0)      AS correct
      FROM exam_topics tp
      LEFT JOIN (
        SELECT
          t.subtopic,
          COUNT(DISTINCT t.id) AS total,
          COUNT(DISTINCT a.exam_task_id)                                 AS attempted,
          COUNT(DISTINCT CASE WHEN a.is_correct = 1 THEN a.exam_task_id END) AS solved,
          COUNT(a.id)                                                    AS attempts,
          COALESCE(SUM(CASE WHEN a.is_correct = 1 THEN 1 ELSE 0 END), 0) AS correct
          FROM exam_tasks t
          LEFT JOIN exam_attempts a
            ON a.exam_task_id = t.id AND a.user_id = ?
         WHERE t.exam_key = ? AND t.subtopic IS NOT NULL
         GROUP BY t.subtopic
      ) stat ON stat.subtopic = tp.slug
     WHERE tp.exam_key = ?
     ORDER BY tp.sort_order
  `),

  /* Pick tasks for a topic practice session.
     Excludes long tasks (no auto-check) and tasks the user has already solved
     correctly, when ?exclude_solved=1. */
  topicTasksUnsolved: db.prepare(`
    SELECT id, task_idx, variant, task_type, text_html, figure_html, opts_json,
           answer, solution_html, topic, subtopic, difficulty
      FROM exam_tasks t
     WHERE t.exam_key = ? AND t.subtopic = ?
       AND t.task_type IN ('mc','open')
       AND NOT EXISTS (
         SELECT 1 FROM exam_attempts a
          WHERE a.exam_task_id = t.id AND a.user_id = ? AND a.is_correct = 1
       )
     ORDER BY RANDOM()
     LIMIT ?
  `),

  topicTasksAny: db.prepare(`
    SELECT id, task_idx, variant, task_type, text_html, figure_html, opts_json,
           answer, solution_html, topic, subtopic, difficulty
      FROM exam_tasks t
     WHERE t.exam_key = ? AND t.subtopic = ?
       AND t.task_type IN ('mc','open')
     ORDER BY RANDOM()
     LIMIT ?
  `),

  getTopicMeta: db.prepare(`
    SELECT slug, parent_slug, title, description
      FROM exam_topics
     WHERE exam_key = ? AND slug = ?
  `),

  /* ── Weak topics (F8) ────────────────────────────────────────
     Subtopics where the user has ≥3 attempts and accuracy < 60%.
     Sorted by (accuracy ASC, attempts DESC). */
  weakTopics: db.prepare(`
    SELECT
      tp.slug, tp.title, tp.parent_slug,
      COUNT(a.id) AS attempts,
      COALESCE(SUM(CASE WHEN a.is_correct = 1 THEN 1 ELSE 0 END), 0) AS correct,
      COUNT(DISTINCT t.id) AS total_tasks,
      COUNT(DISTINCT CASE WHEN a.is_correct = 1 THEN a.exam_task_id END) AS solved_tasks
      FROM exam_topics tp
      JOIN exam_tasks t ON t.subtopic = tp.slug AND t.exam_key = tp.exam_key
      JOIN exam_attempts a ON a.exam_task_id = t.id
                          AND a.user_id = ?
                          AND a.is_correct IS NOT NULL
     WHERE tp.exam_key = ? AND tp.parent_slug IS NOT NULL
     GROUP BY tp.slug
    HAVING attempts >= 3
       AND (CAST(correct AS REAL) / attempts) < 0.6
     ORDER BY (CAST(correct AS REAL) / attempts) ASC, attempts DESC
     LIMIT 3
  `),

  weakTopicSlugs: db.prepare(`
    SELECT tp.slug
      FROM exam_topics tp
      JOIN exam_tasks t ON t.subtopic = tp.slug AND t.exam_key = tp.exam_key
      JOIN exam_attempts a ON a.exam_task_id = t.id AND a.user_id = ? AND a.is_correct IS NOT NULL
     WHERE tp.exam_key = ? AND tp.parent_slug IS NOT NULL
     GROUP BY tp.slug
    HAVING COUNT(a.id) >= 3
       AND (CAST(SUM(CASE WHEN a.is_correct = 1 THEN 1 ELSE 0 END) AS REAL) / COUNT(a.id)) < 0.6
     ORDER BY (CAST(SUM(CASE WHEN a.is_correct = 1 THEN 1 ELSE 0 END) AS REAL) / COUNT(a.id)) ASC,
              COUNT(a.id) DESC
     LIMIT 3
  `),

  /* Practice batch — WEAK strategy: pick random tasks from top-3 weak subtopics,
     skipping tasks the user has already solved correctly. */
  weakBatchTasks: (examKey, slugs, userId, count) => {
    if (!slugs.length) return [];
    const ph = slugs.map(() => '?').join(',');
    return db.prepare(`
      SELECT id, task_idx, variant, task_type, text_html, figure_html, opts_json,
             answer, solution_html, topic, subtopic, difficulty
        FROM exam_tasks t
       WHERE t.exam_key = ?
         AND t.subtopic IN (${ph})
         AND t.task_type IN ('mc','open')
         AND NOT EXISTS (
           SELECT 1 FROM exam_attempts a
            WHERE a.exam_task_id = t.id AND a.user_id = ? AND a.is_correct = 1
         )
       ORDER BY RANDOM()
       LIMIT ?
    `).all(examKey, ...slugs, userId, count);
  },
};

/* ── GET /api/exam-prep/tracks ──
   Public list of enabled exam tracks (for a future landing page). */
router.get('/tracks', (_req, res) => {
  const tracks = SQL.listTracks.all();
  res.json({ tracks });
});

/* ── GET /api/exam-prep/:examKey/info ──
   Track metadata + global counts + this user's aggregate progress. */
// @public-by-design: router-level authMiddleware (line 6) covers this route
router.get('/:examKey/info', (req, res) => {
  const { examKey } = req.params;
  const track = SQL.getTrack.get(examKey);
  if (!track) return res.status(404).json({ error: 'Unknown exam track' });

  const counts   = SQL.countTasks.get(examKey);
  const progress = SQL.userProgress.get(req.user.id, examKey);

  // Parse scoring grid (JSON in DB). Tolerant of malformed values.
  let scoring = null;
  if (track.scoring_json) {
    try { scoring = JSON.parse(track.scoring_json); } catch { scoring = null; }
  }

  res.json({
    track: {
      exam_key: track.exam_key,
      title:    track.title,
      subject:  track.subject_slug,
      grade:    track.grade,
      duration_min:      track.duration_min,
      tasks_per_variant: track.tasks_per_variant,
      variants_count:    track.variants_count,
      intro_html:        track.intro_html,
      scoring,
    },
    counts,
    progress,
  });
});

/* ── GET /api/exam-prep/:examKey/variants ──
   List of variants with per-user progress aggregates. */
// @public-by-design: router-level authMiddleware (line 6) covers this route
router.get('/:examKey/variants', (req, res) => {
  const { examKey } = req.params;
  if (!SQL.getTrack.get(examKey)) return res.status(404).json({ error: 'Unknown exam track' });
  const rows = SQL.listVariants.all(req.user.id, examKey);
  const variants = rows.map(r => ({
    n: r.variant,
    label: `Вариант ${r.variant}`,
    total: r.total,
    solved: r.solved,
    viewed_sol: r.viewed_sol,
  }));
  res.json({ variants });
});

/* ── GET /api/exam-prep/:examKey/variants/:n/tasks ──
   All tasks of a specific variant. Includes answer + solution_html
   (variants view = same UX as old /exam9: read condition + reveal solution).
   In F3, the task-card will also use `answer` to auto-check user input. */
// @public-by-design: router-level authMiddleware (line 6) covers this route
router.get('/:examKey/variants/:n/tasks', (req, res) => {
  const { examKey } = req.params;
  const n = parseInt(req.params.n, 10);
  if (!Number.isFinite(n) || n < 1) return res.status(400).json({ error: 'Bad variant number' });

  const rows = SQL.getVariantTasks.all(examKey, n);
  if (!rows.length) return res.status(404).json({ error: 'Variant not found or empty' });

  const refMap = getTopicRefMap(examKey);
  const tasks = rows.map(r => ({
    id:        r.id,
    idx:       r.task_idx,
    type:      r.task_type,
    text:      r.text_html,
    figure:    r.figure_html,
    opts:      r.opts_json ? safeJson(r.opts_json) : null,
    answer:    r.answer,
    solution:  r.solution_html,
    topic:     r.topic,
    subtopic:  r.subtopic,
    topic_ref: r.subtopic ? (refMap.get(r.subtopic) || null) : null,
  }));
  res.json({ variant: n, tasks });
});

function safeJson(s) { try { return JSON.parse(s); } catch { return null; } }

/* ── Topic-ref cache ─────────────────────────────────────────────
   subtopic_slug → { title, textbook_slug, textbook_paragraph }.
   Loaded lazily per examKey; invalidated never (migrations are the
   only writer, and a server restart picks up new mappings). */
const _topicRefCache = new Map();
function getTopicRefMap(examKey) {
  let map = _topicRefCache.get(examKey);
  if (map) return map;
  const rows = SQL.listTopicRefs.all(examKey);
  map = new Map();
  for (const r of rows) {
    map.set(r.slug, {
      title:     r.title,
      slug:      r.textbook_slug,
      paragraph: r.textbook_paragraph ?? null,
    });
  }
  _topicRefCache.set(examKey, map);
  return map;
}

function shapeTask(r, refMap) {
  const ref = (refMap && r.subtopic) ? refMap.get(r.subtopic) : null;
  return {
    id:       r.id,
    idx:      r.task_idx,
    variant:  r.variant ?? null,
    type:     r.task_type,
    text:     r.text_html,
    figure:   r.figure_html,
    opts:     r.opts_json ? safeJson(r.opts_json) : null,
    answer:   r.answer,
    solution: r.solution_html,
    topic:    r.topic ?? null,
    subtopic: r.subtopic ?? null,
    difficulty: r.difficulty ?? null,
    topic_ref: ref || null,
  };
}

/* ── Difficulty distribution for "Random" practice ───────────────
   Returns an array [n1,n2,n3,n4,n5] of how many tasks of each
   difficulty level (1..5) to pick, summing to N. Position 1 of the
   batch is always difficulty 1 (so n1 >= 1 whenever N >= 1). */
function distributeByDifficulty(N) {
  if (N <= 0) return [0, 0, 0, 0, 0];
  if (N <= 5) {
    // 1 task per level, then drop hardest first if N<5.
    // Order [4,3,1,2] keeps slot 0 (difficulty 1) until last so the first
    // task is always easiest.
    const c = [1, 1, 1, 1, 1];
    let drop = 5 - N;
    const off = [4, 3, 1, 2];
    for (let i = 0; drop > 0 && i < off.length; i++) { c[off[i]] = 0; drop--; }
    return c;
  }
  // Weighted: 10% / 20% / 30% / 20% / 20%, min 1 per level so first slot = d1.
  const w = [0.10, 0.20, 0.30, 0.20, 0.20];
  const c = w.map(p => Math.max(1, Math.round(N * p)));
  let total = c.reduce((a, b) => a + b, 0);
  // adjust: trim from level 5 then 4, top-up at level 3
  while (total > N) {
    const idx = c[4] > 1 ? 4 : (c[3] > 1 ? 3 : c.indexOf(Math.max(...c)));
    c[idx]--; total--;
  }
  while (total < N) { c[2]++; total++; }
  return c;
}

/* Pick a difficulty-ordered batch with optional subtopic exclusions.
   Excludes long tasks (no auto-check). Tasks come back already sorted
   by difficulty ascending — the response order is the play order. */
function pickRandomByDifficulty(examKey, count, excludeSlugs) {
  const dist = distributeByDifficulty(count);
  const exParams = excludeSlugs.length ? excludeSlugs : null;
  const exClause = exParams
    ? `AND (subtopic IS NULL OR subtopic NOT IN (${exParams.map(() => '?').join(',')}))`
    : '';

  const out = [];
  for (let d = 1; d <= 5; d++) {
    const limit = dist[d - 1];
    if (limit === 0) continue;
    const sql = `
      SELECT id, task_idx, variant, task_type, text_html, figure_html, opts_json,
             answer, solution_html, topic, subtopic, difficulty
        FROM exam_tasks
       WHERE exam_key = ? AND task_type IN ('mc','open')
         AND difficulty = ?
         ${exClause}
       ORDER BY RANDOM()
       LIMIT ?`;
    const args = exParams
      ? [examKey, d, ...exParams, limit]
      : [examKey, d, limit];
    out.push(...db.prepare(sql).all(...args));
  }
  return out;
}

/* Parse + validate ?exclude=slug1,slug2 against known subtopics for examKey. */
function parseExcludeParam(examKey, raw) {
  if (!raw) return [];
  const requested = String(raw).split(',').map(s => s.trim()).filter(Boolean);
  if (!requested.length) return [];
  const valid = new Set(SQL.listSubtopicSlugs.all(examKey).map(r => r.slug));
  return requested.filter(s => valid.has(s));
}

/* ── GET /api/exam-prep/:examKey/practice/next ──
   Returns up to ?count=N tasks for the practice trainer.
   ?strategy=random   — any mc/open task, fresh random sample
            =unsolved — only tasks the user has not yet solved correctly
   Long tasks are excluded (no auto-check available). */
// @public-by-design: router-level authMiddleware (line 6) covers this route
router.get('/:examKey/practice/next', (req, res) => {
  const { examKey } = req.params;
  if (!SQL.getTrack.get(examKey)) return res.status(404).json({ error: 'Unknown exam track' });

  let count = Number(req.query.count) || 10;
  count = Math.max(1, Math.min(count, 30));

  const strategyRaw = req.query.strategy;
  let strategy = ['unsolved', 'weak', 'random'].includes(strategyRaw) ? strategyRaw : 'random';
  let rows;
  let weakSlugs = null;

  const excludeSlugs = parseExcludeParam(examKey, req.query.exclude);

  if (strategy === 'weak') {
    weakSlugs = SQL.weakTopicSlugs.all(req.user.id, examKey).map(r => r.slug);
    if (weakSlugs.length === 0) {
      // No weak topics yet → fall back to unsolved so the button still works
      rows = SQL.practiceUnsolved.all(examKey, req.user.id, count);
      strategy = 'unsolved-fallback';
    } else {
      rows = SQL.weakBatchTasks(examKey, weakSlugs, req.user.id, count);
      if (!rows.length) {
        // weak topics exist but all unsolved tasks exhausted → fallback to any from those topics
        rows = SQL.weakBatchTasks(examKey, weakSlugs, -1 /* never matches */, count);
      }
    }
  } else if (strategy === 'unsolved') {
    rows = SQL.practiceUnsolved.all(examKey, req.user.id, count);
    if (!rows.length) rows = SQL.practiceRandom.all(examKey, count);
  } else {
    // Random: difficulty-ordered batch, position 1 = difficulty 1.
    rows = pickRandomByDifficulty(examKey, count, excludeSlugs);
    // Fallback if exclusions wiped the pool — drop them and retry.
    if (!rows.length && excludeSlugs.length) {
      rows = pickRandomByDifficulty(examKey, count, []);
    }
    if (!rows.length) rows = SQL.practiceRandom.all(examKey, count);
  }

  const refMap = getTopicRefMap(examKey);
  res.json({
    strategy,
    weak_slugs: weakSlugs,
    excluded:   excludeSlugs,
    session_id: Date.now(),
    tasks: rows.map(r => shapeTask(r, refMap)),
  });
});

/* ── POST /api/exam-prep/attempts ──
   Save a single attempt. Used by the variants view (F2) for "solution viewed"
   markers, and by the practice / mock views (F3+) for actual answer checking.

   Body: {
     exam_task_id   (required, integer)
     user_answer    (string|null)        — what the user typed/selected
     is_correct     (0|1|null)           — null for solution-viewed-only events
     time_ms        (integer|null)
     mode           ('practice'|'variant'|'topic'|'mock')
     session_id     (integer|null)
     hint_used      (0|1)
     solution_viewed (0|1)
   }
   The server does NOT recompute is_correct from `user_answer` here — that's
   the client's responsibility (it has the `answer` from /tasks). Server only
   validates ownership and records the event. */
router.post('/attempts', (req, res) => {
  const b = req.body || {};
  const taskId = Number(b.exam_task_id);
  if (!Number.isFinite(taskId)) return res.status(400).json({ error: 'exam_task_id required' });

  const task = SQL.getTaskExamKey.get(taskId);
  if (!task) return res.status(404).json({ error: 'Task not found' });

  const mode = String(b.mode || '');
  if (!['practice', 'variant', 'topic', 'mock'].includes(mode)) {
    return res.status(400).json({ error: 'invalid mode' });
  }

  const userAnswer     = b.user_answer != null ? String(b.user_answer).slice(0, 500) : null;
  const isCorrect      = b.is_correct === 1 || b.is_correct === 0 ? b.is_correct : null;
  const timeMs         = Number.isFinite(b.time_ms) ? Math.max(0, Math.min(b.time_ms, 24 * 60 * 60 * 1000)) : null;
  const sessionId      = Number.isFinite(b.session_id) ? b.session_id : null;
  const hintUsed       = b.hint_used ? 1 : 0;
  const solutionViewed = b.solution_viewed ? 1 : 0;

  const result = SQL.insertAttempt.run(
    req.user.id, taskId, userAnswer, isCorrect, timeMs,
    mode, sessionId, hintUsed, solutionViewed, Date.now()
  );

  // Variant-clear coin bonus: when the user finishes a variant in
  // 'variant' mode with every task correctly answered, give them a
  // one-shot 30-coin tip. Dedup key includes the variant number, so a
  // user who later re-solves a different variant gets paid again.
  if (mode === 'variant' && isCorrect === 1) {
    try {
      const taskMeta = db.prepare('SELECT variant FROM exam_tasks WHERE id = ?').get(taskId);
      if (taskMeta?.variant) {
        const stats = db.prepare(`
          SELECT
            (SELECT COUNT(*) FROM exam_tasks WHERE exam_key='math9' AND variant=?) AS total,
            (SELECT COUNT(DISTINCT t.id)
               FROM exam_attempts a
               JOIN exam_tasks t ON t.id = a.exam_task_id
              WHERE a.user_id = ? AND t.variant = ? AND a.is_correct = 1) AS solved
        `).get(taskMeta.variant, req.user.id, taskMeta.variant);
        if (stats.total > 0 && stats.solved >= stats.total) {
          const { awardCoinsOnce } = require('../controllers/gamification/service');
          awardCoinsOnce(req.user.id, 30, `variant_clear:${taskMeta.variant}`, 'forever');
        }
      }
    } catch (e) { /* don't break the response on bonus failure */ }
  }

  res.json({ id: Number(result.lastInsertRowid) });
});

/* ──────────────────────────────────────────────────────────────────
   Dashboard (F4)
   ────────────────────────────────────────────────────────────────── */

/* Compute streak: consecutive calendar days ending today (or yesterday — a
   one-day grace prevents the streak from being lost overnight) on which the
   user had ≥1 correct attempt. Returns an integer >= 0.
   `days` — array of date strings 'YYYY-MM-DD' sorted descending. */
function computeStreak(days) {
  if (!days.length) return 0;
  const set = new Set(days);
  const dayMs = 86400000;
  const today = new Date();
  const todayStr = toIsoDate(today);
  const yesterdayStr = toIsoDate(new Date(today.getTime() - dayMs));

  // Anchor: start from today if today's correct exists, else yesterday (grace).
  // If neither — streak is 0.
  let cursor;
  if (set.has(todayStr))         cursor = new Date(today);
  else if (set.has(yesterdayStr)) cursor = new Date(today.getTime() - dayMs);
  else return 0;

  let streak = 0;
  while (set.has(toIsoDate(cursor))) {
    streak++;
    cursor = new Date(cursor.getTime() - dayMs);
  }
  return streak;
}

function toIsoDate(d) {
  const y = d.getUTCFullYear();
  const m = String(d.getUTCMonth() + 1).padStart(2, '0');
  const day = String(d.getUTCDate()).padStart(2, '0');
  return `${y}-${m}-${day}`;
}

/* ── GET /api/exam-prep/:examKey/dashboard ──
   Live aggregates for the dashboard view: streak, recent attempts,
   accuracy over the last 7 days, 28-day activity heatmap, recent mocks. */
// @public-by-design: router-level authMiddleware (line 6) covers this route
router.get('/:examKey/dashboard', (req, res) => {
  const { examKey } = req.params;
  if (!SQL.getTrack.get(examKey)) return res.status(404).json({ error: 'Unknown exam track' });

  const now = Date.now();
  const dayMs = 86400000;
  const sixtyDaysAgo  = now - 60 * dayMs;
  const sevenDaysAgo  = now -  7 * dayMs;
  const twentyEightDaysAgo = now - 28 * dayMs;

  const dayRows = SQL.streakDays.all(req.user.id, examKey, sixtyDaysAgo);
  const streak  = computeStreak(dayRows.map(r => r.day));

  const acc7   = SQL.accuracyWindow.get(req.user.id, examKey, sevenDaysAgo);
  const recent = SQL.recentAttempts.all(req.user.id, examKey, 8);
  const heat   = SQL.activityHeatmap.all(req.user.id, examKey, twentyEightDaysAgo);
  const mocks  = SQL.recentMocks.all(req.user.id, examKey, 3);
  const weak   = SQL.weakTopics.all(req.user.id, examKey);

  res.json({
    streak,
    accuracy_7d: {
      attempts: acc7.attempts,
      correct:  acc7.correct,
      pct:      acc7.attempts ? Math.round((acc7.correct / acc7.attempts) * 100) : null,
    },
    weak_topics: weak.map(w => ({
      slug:     w.slug,
      title:    w.title,
      parent:   w.parent_slug,
      attempts: w.attempts,
      correct:  w.correct,
      accuracy: Math.round((w.correct / w.attempts) * 100),
      total_tasks:  w.total_tasks,
      solved_tasks: w.solved_tasks,
    })),
    recent_attempts: recent.map(r => ({
      task_id:   r.exam_task_id,
      variant:   r.variant,
      task_idx:  r.task_idx,
      task_type: r.task_type,
      is_correct: r.is_correct,
      solution_viewed: r.solution_viewed,
      mode:      r.mode,
      user_answer: r.user_answer,
      preview:   stripPreview(r.text_html),
      created_at: r.created_at,
    })),
    heatmap: heat.map(h => ({ day: h.day, attempts: h.attempts, correct: h.correct })),
    recent_mocks: mocks.map(m => ({
      id: m.id, variant: m.variant, source: m.source,
      started_at: m.started_at, finished_at: m.finished_at, status: m.status,
      score: m.score, total_correct: m.total_correct, total_tasks: m.total_tasks,
    })),
  });
});

/* Lightweight preview: strip HTML, normalize LaTeX to readable unicode,
   collapse whitespace, truncate. Used in dashboard "recent attempts" rows
   where rendering KaTeX would be overkill but raw `$\dfrac{7}{9}$` is
   ugly. Matches the spirit of tag-exam-tasks.js#stripText. */
function stripPreview(html) {
  let text = String(html || '')
    .replace(/<svg[\s\S]*?<\/svg>/gi, '')
    .replace(/<[^>]+>/g, ' ');

  // Common LaTeX → unicode/plain. Order matters: handle fractions/roots
  // before stripping bare backslash commands.
  text = text
    .replace(/\\d?frac\s*\{([^{}]+)\}\s*\{([^{}]+)\}/g, '$1/$2')
    .replace(/\\sqrt\s*\{([^{}]+)\}/g, '√($1)')
    .replace(/\\cdot/g, '·')
    .replace(/\\times/g, '×')
    .replace(/\\pm/g, '±')
    .replace(/\\mp/g, '∓')
    .replace(/\\leq?/g, '≤')
    .replace(/\\geq?/g, '≥')
    .replace(/\\neq/g, '≠')
    .replace(/\\approx/g, '≈')
    .replace(/\\infty/g, '∞')
    .replace(/\\pi/g, 'π')
    .replace(/\\alpha/g, 'α').replace(/\\beta/g, 'β').replace(/\\gamma/g, 'γ')
    .replace(/\\(?:left|right)/g, '')
    .replace(/\\[a-zA-Z]+\s*/g, ' ')   // any remaining LaTeX command
    .replace(/[{}]/g, '')               // leftover braces
    .replace(/\$+/g, '')                // math delimiters
    .replace(/&[a-z]+;/gi, ' ')         // html entities
    .replace(/\s+/g, ' ')
    .trim();

  return text.length > 100 ? text.slice(0, 100) + '…' : text;
}

/* ──────────────────────────────────────────────────────────────────
   Topics (F7) — list + per-topic practice batch
   ────────────────────────────────────────────────────────────────── */

/* ── GET /api/exam-prep/:examKey/topics ──
   Returns sections (parents) with children + counts and user accuracy. */
// @public-by-design: router-level authMiddleware (line 6) covers this route
router.get('/:examKey/topics', (req, res) => {
  const { examKey } = req.params;
  if (!SQL.getTrack.get(examKey)) return res.status(404).json({ error: 'Unknown exam track' });

  const rows = SQL.listTopicsWithCounts.all(req.user.id, examKey, examKey);

  // Build {sections: [{slug,title,subtopics:[...]}]}
  const byParent = new Map();
  const sections = [];
  for (const r of rows) {
    if (r.parent_slug == null) {
      const section = { slug: r.slug, title: r.title, subtopics: [], total: 0, solved: 0 };
      sections.push(section);
      byParent.set(r.slug, section);
    }
  }
  for (const r of rows) {
    if (r.parent_slug != null) {
      const sec = byParent.get(r.parent_slug);
      if (!sec) continue;
      const accuracy = r.attempts > 0 ? Math.round((r.correct / r.attempts) * 100) : null;
      const solvedPct = r.total > 0 ? Math.round((r.solved / r.total) * 100) : 0;
      sec.subtopics.push({
        slug:      r.slug,
        title:     r.title,
        total:     r.total,
        solved:    r.solved,
        attempted: r.attempted,
        attempts:  r.attempts,
        correct:   r.correct,
        textbook_slug:      r.textbook_slug ?? null,
        textbook_paragraph: r.textbook_paragraph ?? null,
        accuracy,
        solved_pct: solvedPct,
      });
      sec.total  += r.total;
      sec.solved += r.solved;
    }
  }

  res.json({ sections });
});

/* ── GET /api/exam-prep/:examKey/topics/:slug/tasks ──
   Returns a batch of tasks for topic practice.
   Query: ?count=10 (5-30)  ?exclude_solved=1 (default 1) */
// @public-by-design: router-level authMiddleware (line 6) covers this route
router.get('/:examKey/topics/:slug/tasks', (req, res) => {
  const { examKey, slug } = req.params;
  const meta = SQL.getTopicMeta.get(examKey, slug);
  if (!meta) return res.status(404).json({ error: 'Topic not found' });
  if (meta.parent_slug == null) {
    return res.status(400).json({ error: 'Cannot fetch tasks for a section — pick a specific subtopic' });
  }

  let count = Number(req.query.count) || 10;
  count = Math.max(5, Math.min(count, 30));
  const excludeSolved = req.query.exclude_solved !== '0';

  let rows;
  if (excludeSolved) {
    rows = SQL.topicTasksUnsolved.all(examKey, slug, req.user.id, count);
    if (!rows.length) rows = SQL.topicTasksAny.all(examKey, slug, count);
  } else {
    rows = SQL.topicTasksAny.all(examKey, slug, count);
  }

  const refMap = getTopicRefMap(examKey);
  res.json({
    topic: { slug: meta.slug, title: meta.title, parent: meta.parent_slug },
    session_id: Date.now(),
    tasks: rows.map(r => shapeTask(r, refMap)),
  });
});

/* ──────────────────────────────────────────────────────────────────
   Study plan (F10) — by exam date
   ────────────────────────────────────────────────────────────────── */

function isIsoDate(s) {
  return typeof s === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(s);
}

function daysBetween(yyyymmdd) {
  // Calendar days from today to yyyymmdd (UTC). Negative if in past.
  const [y, m, d] = yyyymmdd.split('-').map(Number);
  const target = Date.UTC(y, m - 1, d);
  const today  = new Date();
  const todayStart = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate());
  return Math.round((target - todayStart) / 86400000);
}

function buildPlanPayload(row, userId, examKey) {
  if (!row) return { plan: null };

  const counts = SQL.countTasks.get(examKey);
  const progress = SQL.userProgress.get(userId, examKey);
  const todayRow = SQL.tasksSolvedToday.get(userId, examKey);

  const daysLeft  = daysBetween(row.exam_date);
  const tasksLeft = Math.max(0, counts.total - progress.tasks_solved);

  // Auto daily target if user hasn't customized: ceil(left / days)
  let dailyTarget = row.daily_target;
  if (!dailyTarget && daysLeft > 0) {
    dailyTarget = Math.max(5, Math.min(50, Math.ceil(tasksLeft / daysLeft)));
  }

  return {
    plan: {
      exam_date:    row.exam_date,
      daily_target: dailyTarget,
      weak_focus:   !!row.weak_focus,
      created_at:   row.created_at,
      updated_at:   row.updated_at,
      days_left:    daysLeft,
      tasks_left:   tasksLeft,
      today: {
        solved: todayRow.solved,
        target: dailyTarget,
      },
    },
  };
}

/* ── GET /api/exam-prep/:examKey/plan ──
   Returns { plan: null } if user has no plan for this track. */
// @public-by-design: router-level authMiddleware (line 6) covers this route
router.get('/:examKey/plan', (req, res) => {
  const { examKey } = req.params;
  if (!SQL.getTrack.get(examKey)) return res.status(404).json({ error: 'Unknown exam track' });
  const row = SQL.getPlan.get(req.user.id, examKey);
  res.json(buildPlanPayload(row, req.user.id, examKey));
});

/* ── PUT /api/exam-prep/:examKey/plan ──
   Body: { exam_date: 'YYYY-MM-DD', daily_target?: int 5-50, weak_focus?: 0|1 }
   Upserts the plan. exam_date is required; the other fields are optional. */
// @public-by-design: router-level authMiddleware (line 6) covers this route
router.put('/:examKey/plan', (req, res) => {
  const { examKey } = req.params;
  if (!SQL.getTrack.get(examKey)) return res.status(404).json({ error: 'Unknown exam track' });

  const examDate = req.body?.exam_date;
  if (!isIsoDate(examDate)) return res.status(400).json({ error: 'exam_date must be YYYY-MM-DD' });

  let dailyTarget = req.body?.daily_target;
  if (dailyTarget != null) {
    dailyTarget = Number(dailyTarget);
    if (!Number.isInteger(dailyTarget) || dailyTarget < 5 || dailyTarget > 50) {
      return res.status(400).json({ error: 'daily_target must be integer 5-50' });
    }
  } else {
    dailyTarget = 10;       // sensible default; client can override
  }

  const weakFocus = req.body?.weak_focus ? 1 : 0;

  const existing = SQL.getPlan.get(req.user.id, examKey);
  const now = Date.now();
  SQL.upsertPlan.run(
    req.user.id, examKey, examDate, dailyTarget, weakFocus,
    existing ? existing.created_at : now, now
  );

  const row = SQL.getPlan.get(req.user.id, examKey);
  res.json(buildPlanPayload(row, req.user.id, examKey));
});

/* ── DELETE /api/exam-prep/:examKey/plan ──
   Remove the user's plan for this track. */
// @public-by-design: router-level authMiddleware (line 6) covers this route
router.delete('/:examKey/plan', (req, res) => {
  const { examKey } = req.params;
  if (!SQL.getTrack.get(examKey)) return res.status(404).json({ error: 'Unknown exam track' });
  SQL.deletePlan.run(req.user.id, examKey);
  res.json({ ok: true });
});

/* ──────────────────────────────────────────────────────────────────
   Mock exam (F9)
   ────────────────────────────────────────────────────────────────── */

/* Convert {correct} count into a final score per the track's scoring grid.
   Grid is JSON like [{correct:30,score:10},{correct:27,score:9},...].
   Sorted descending; we return the score for the largest threshold met. */
function scoreFromGrid(correctCount, scoringJson) {
  if (!scoringJson) return null;
  let grid;
  try { grid = JSON.parse(scoringJson); } catch { return null; }
  if (!Array.isArray(grid) || !grid.length) return null;
  grid = grid.slice().sort((a, b) => b.correct - a.correct);
  for (const entry of grid) {
    if (correctCount >= entry.correct) return entry.score;
  }
  return 0;
}

/* ── POST /api/exam-prep/:examKey/mock/start ──
   Body: { source: 'variant'|'random', variant?: number, count?: number }
   Creates an active mock session and returns its id. */
router.post('/:examKey/mock/start', (req, res) => {
  const { examKey } = req.params;
  const track = SQL.getTrack.get(examKey);
  if (!track) return res.status(404).json({ error: 'Unknown exam track' });

  const source = req.body?.source;
  let taskIds = [];
  let variant = null;

  if (source === 'variant') {
    variant = Number(req.body?.variant);
    if (!Number.isInteger(variant) || variant < 1) {
      return res.status(400).json({ error: 'Variant number required' });
    }
    const rows = SQL.getTasksByVariant.all(examKey, variant);
    if (!rows.length) return res.status(404).json({ error: 'Variant not found or empty' });
    taskIds = rows.map(r => r.id);
  } else if (source === 'random') {
    let count = Number(req.body?.count) || track.tasks_per_variant;
    count = Math.max(5, Math.min(count, 30));
    const rows = SQL.getRandomTaskIds.all(examKey, count);
    taskIds = rows.map(r => r.id);
  } else {
    return res.status(400).json({ error: 'source must be variant|random' });
  }

  const r = SQL.insertMockSession.run(
    req.user.id, examKey, variant, source, JSON.stringify(taskIds),
    Date.now(), track.duration_min, taskIds.length
  );
  res.json({
    id: Number(r.lastInsertRowid),
    task_count: taskIds.length,
    duration_min: track.duration_min,
  });
});

/* ── GET /api/exam-prep/mock/:id ──
   Returns mock-session state + tasks.
   - status='active'   : tasks WITHOUT answer/solution; includes user_answers map
   - status='finished' : tasks WITH answer/solution; includes is_correct per task */
// @public-by-design: router-level authMiddleware (line 6) covers this route
router.get('/mock/:id', (req, res) => {
  const id = Number(req.params.id);
  const sess = SQL.getMockSession.get(id);
  if (!sess) return res.status(404).json({ error: 'Mock session not found' });
  if (sess.user_id !== req.user.id) return res.status(403).json({ error: 'Forbidden' });

  let ids = [];
  try { ids = JSON.parse(sess.task_ids_json) || []; } catch {}
  const tasksRaw = SQL.getTasksByIds(ids);
  const taskMap  = new Map(tasksRaw.map(t => [t.id, t]));
  const tasks    = ids.map(i => taskMap.get(i)).filter(Boolean);

  const answers = SQL.getMockAnswers.all(req.user.id, id);
  const answerByTask = new Map(answers.map(a => [a.exam_task_id, a]));

  const isActive = sess.status === 'active';
  const out = tasks.map((t, idx) => {
    const ua = answerByTask.get(t.id);
    return {
      id:        t.id,
      idx:       idx + 1,
      variant:   t.variant,
      type:      t.task_type,
      text:      t.text_html,
      figure:    t.figure_html,
      opts:      t.opts_json ? safeJson(t.opts_json) : null,
      // Hide answer/solution while active
      answer:    isActive ? null : t.answer,
      solution:  isActive ? null : t.solution_html,
      // User's stored answer + correctness (post-finish only)
      user_answer: ua?.user_answer ?? null,
      is_correct:  !isActive ? (ua?.is_correct ?? null) : null,
    };
  });

  res.json({
    session: {
      id:                   sess.id,
      exam_key:             sess.exam_key,
      variant:              sess.variant,
      source:               sess.source,
      status:               sess.status,
      started_at:           sess.started_at,
      finished_at:          sess.finished_at,
      duration_planned_min: sess.duration_planned_min,
      score:                sess.score,
      total_correct:        sess.total_correct,
      total_tasks:          sess.total_tasks,
    },
    tasks: out,
  });
});

/* ── POST /api/exam-prep/mock/:id/answer ──
   Body: { exam_task_id, user_answer }
   Upserts the user's draft answer (no correctness check while active). */
// @public-by-design: router-level authMiddleware (line 6) covers this route
router.post('/mock/:id/answer', (req, res) => {
  const id = Number(req.params.id);
  const sess = SQL.getMockSession.get(id);
  if (!sess) return res.status(404).json({ error: 'Mock session not found' });
  if (sess.user_id !== req.user.id) return res.status(403).json({ error: 'Forbidden' });
  if (sess.status !== 'active') return res.status(409).json({ error: 'Session finished' });

  const taskId = Number(req.body?.exam_task_id);
  if (!Number.isFinite(taskId)) return res.status(400).json({ error: 'exam_task_id required' });

  let ids = [];
  try { ids = JSON.parse(sess.task_ids_json) || []; } catch {}
  if (!ids.includes(taskId)) return res.status(400).json({ error: 'Task not in this session' });

  const userAnswer = req.body?.user_answer != null ? String(req.body.user_answer).slice(0, 500) : null;
  const timeMs = Number.isFinite(req.body?.time_ms) ? Math.max(0, Math.min(req.body.time_ms, 24 * 3600 * 1000)) : null;

  /* Upsert: delete any prior mock-attempt for this task, then insert fresh */
  db.transaction(() => {
    SQL.deleteMockAnswer.run(req.user.id, taskId, id);
    SQL.insertAttempt.run(
      req.user.id, taskId, userAnswer, null /* is_correct unknown */,
      timeMs, 'mock', id, 0, 0, Date.now()
    );
  })();

  res.json({ ok: true });
});

/* ── POST /api/exam-prep/mock/:id/finish ──
   Computes correctness for every stored answer + final score. */
// @public-by-design: router-level authMiddleware (line 6) covers this route
router.post('/mock/:id/finish', (req, res) => {
  const id = Number(req.params.id);
  const sess = SQL.getMockSession.get(id);
  if (!sess) return res.status(404).json({ error: 'Mock session not found' });
  if (sess.user_id !== req.user.id) return res.status(403).json({ error: 'Forbidden' });
  if (sess.status === 'finished') return res.json({ ok: true, already: true });

  // Pull tasks + answers, compute correctness per task using same logic as client.
  let ids = [];
  try { ids = JSON.parse(sess.task_ids_json) || []; } catch {}
  const tasks   = SQL.getTasksByIds(ids);
  const taskMap = new Map(tasks.map(t => [t.id, t]));
  const answers = SQL.getMockAnswers.all(req.user.id, id);

  let totalCorrect = 0;
  db.transaction(() => {
    for (const a of answers) {
      const t = taskMap.get(a.exam_task_id);
      if (!t || !t.answer) continue;
      const correct = checkAnswerServer(a.user_answer, t.answer) ? 1 : 0;
      if (correct) totalCorrect++;
      SQL.updateMockAttemptCorrectness.run(correct, req.user.id, a.exam_task_id, id);
    }
  })();

  const track = SQL.getTracksScoring.get(sess.exam_key);
  const score = track ? scoreFromGrid(totalCorrect, track.scoring_json) : null;

  SQL.finalizeMockSession.run(Date.now(), score, totalCorrect, id);

  res.json({
    ok: true,
    total_correct: totalCorrect,
    total_tasks:   sess.total_tasks,
    score,
  });
});

/* ──────────────────────────────────────────────────────────────────
   Server-side answer checker — mirror of frontend EP.answer.check.
   Kept here (intentional code duplication) so the server is the source
   of truth for mock-exam scoring and can never be bypassed by a
   tampered client.
   ────────────────────────────────────────────────────────────────── */
const EPS = 1e-6;

function srvToNumber(s) {
  if (s == null) return NaN;
  let t = String(s).trim().replace(/\$/g, '').replace(/\s+/g, '').replace(',', '.');
  const f = t.match(/^(-?\d+(?:\.\d+)?)\s*\/\s*(-?\d+(?:\.\d+)?)$/);
  if (f) {
    const num = Number(f[1]);
    const den = Number(f[2]);
    if (!Number.isFinite(num) || !Number.isFinite(den) || den === 0) return NaN;
    return num / den;
  }
  const n = Number(t);
  return Number.isFinite(n) ? n : NaN;
}

function srvToPair(s) {
  if (s == null) return null;
  const t = String(s).trim().replace(/\$/g, '').replace(/\s+и\s+/g, ';');
  const parts = t.split(/[;,]/).map(p => p.trim()).filter(Boolean);
  if (parts.length !== 2) return null;
  const a = srvToNumber(parts[0]);
  const b = srvToNumber(parts[1]);
  if (Number.isNaN(a) || Number.isNaN(b)) return null;
  return a <= b ? [a, b] : [b, a];
}

function checkAnswerServer(userInput, canonical) {
  if (userInput == null || canonical == null) return false;
  const c = String(canonical).trim();
  if (/^[а-д]$/.test(c)) {
    return String(userInput).trim().toLowerCase() === c.toLowerCase();
  }
  if (/^[^;]+;[^;]+$/.test(c)) {
    const cp = srvToPair(c);
    const up = srvToPair(userInput);
    if (!cp || !up) return false;
    return Math.abs(cp[0] - up[0]) < EPS && Math.abs(cp[1] - up[1]) < EPS;
  }
  const cn = srvToNumber(c);
  const un = srvToNumber(userInput);
  if (Number.isNaN(cn) || Number.isNaN(un)) return false;
  return Math.abs(cn - un) < EPS;
}

module.exports = router;
