use std::{
    collections::HashMap,
    fs,
    path::{Path, PathBuf},
    process::Command,
    time::{SystemTime, UNIX_EPOCH},
};

use anyhow::Result;
use bookforge_core::{RunConfigSnapshot, segment::build_segments};
use bookforge_epub::read_epub;
use bookforge_store::{JobRecord, JobStore};
use clap::Args;
use serde::Serialize;
#[cfg(test)]
use serde_json::json;

use crate::cost::estimate_cost_usd;

#[derive(Debug, Args)]
pub struct ReviewArgs {
    pub job_id: String,

    #[arg(long)]
    pub open: bool,

    #[arg(long)]
    pub out: Option<PathBuf>,
}

#[derive(Debug, Serialize)]
struct ReviewDocument {
    schema_version: u32,
    job_id: String,
    source_language: Option<String>,
    target_language: String,
    provider: String,
    model: String,
    generated_at: String,
    source_book_title: Option<String>,
    source_book_author: Option<String>,
    source_input_snapshot: Option<String>,
    totals: ReviewTotals,
    segments: Vec<ReviewSegment>,
}

#[derive(Debug, Serialize)]
struct ReviewTotals {
    segments: usize,
    tokens_input: u64,
    tokens_input_cached: u64,
    tokens_output: u64,
    estimated_cost_usd: Option<f64>,
}

#[derive(Debug, Serialize)]
struct ReviewSegment {
    segment_id: String,
    chapter_id: String,
    chapter_title: Option<String>,
    ordinal: usize,
    source_text: String,
    target_text: String,
    soft_warnings: Vec<ReviewWarning>,
    tokens: ReviewTokens,
    status: String,
}

#[derive(Debug, Serialize)]
struct ReviewWarning {
    kind: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    value: Option<f64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    threshold: Option<f64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    from: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    to: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    message: Option<String>,
}

#[derive(Debug, Serialize)]
struct ReviewTokens {
    input: u64,
    input_cached: u64,
    output: u64,
    estimated: bool,
}

pub async fn run(args: ReviewArgs) -> Result<()> {
    let store = JobStore::open_default()?;
    let Some(job) = store.get_job(&args.job_id)? else {
        anyhow::bail!("job '{}' was not found", args.job_id);
    };
    let Some(snapshot) = store.load_job_config_snapshot(&args.job_id)? else {
        anyhow::bail!(
            "job '{}' does not have a run configuration snapshot; review cannot be regenerated",
            args.job_id
        );
    };

    let input = resolve_review_input(&job, &snapshot)?;
    let book = read_epub(&input)?;
    let settings = snapshot.settings.to_settings();
    let segments = build_segments(&book, &settings.segmentation)?;
    let summary = store
        .summary(&job.id)?
        .ok_or_else(|| anyhow::anyhow!("job '{}' summary unavailable", job.id))?;
    let records = store.segment_records(&job.id)?;
    let translations = store.load_terminal_segment_translations(&job.id)?;

    let document = build_review_document(
        &job,
        &snapshot,
        &book,
        &segments,
        &records,
        &translations,
        &summary,
    );

    let out_dir = args.out.unwrap_or_else(|| {
        PathBuf::from(".bookforge/runs")
            .join(&job.id)
            .join("review")
    });
    fs::create_dir_all(&out_dir)?;
    let json_path = out_dir.join("review.json");
    let html_path = out_dir.join("index.html");
    let css_path = out_dir.join("style.css");
    let json_text = serde_json::to_string_pretty(&document)?;
    fs::write(&json_path, &json_text)?;
    fs::write(&css_path, REVIEW_CSS)?;
    fs::write(&html_path, render_html(&json_text))?;

    println!("Review JSON: {}", json_path.display());
    println!("Review HTML: {}", html_path.display());

    if args.open {
        open_in_browser(&html_path)?;
    }

    Ok(())
}

fn resolve_review_input(job: &JobRecord, snapshot: &RunConfigSnapshot) -> Result<PathBuf> {
    if let Some(path) = snapshot
        .input_snapshot_path
        .as_ref()
        .or(job.input_snapshot_path.as_ref())
        && path.exists()
    {
        return Ok(path.clone());
    }

    if snapshot.input_snapshot_path.is_none() && job.input_snapshot_path.is_none() {
        tracing::warn!(
            "job '{}' predates input EPUB snapshots; falling back to original input path",
            job.id
        );
        if snapshot.input_path.exists() {
            return Ok(snapshot.input_path.clone());
        }
        anyhow::bail!(
            "job '{}' does not have an input snapshot and the original input path no longer exists: {}",
            job.id,
            snapshot.input_path.display()
        );
    }

    let snapshot_path = snapshot
        .input_snapshot_path
        .as_ref()
        .or(job.input_snapshot_path.as_ref())
        .map(|path| path.display().to_string())
        .unwrap_or_else(|| "<missing>".to_string());
    anyhow::bail!(
        "job '{}' input snapshot is missing: {}",
        job.id,
        snapshot_path
    )
}

fn build_review_document(
    job: &JobRecord,
    snapshot: &RunConfigSnapshot,
    book: &bookforge_core::ir::Book,
    segments: &[bookforge_core::segment::Segment],
    records: &[bookforge_store::SegmentRecord],
    translations: &[bookforge_store::StoredSegmentTranslation],
    summary: &bookforge_store::JobSummary,
) -> ReviewDocument {
    let records_by_id = records
        .iter()
        .map(|record| (record.id.as_str(), record))
        .collect::<HashMap<_, _>>();
    let translations_by_id = translations
        .iter()
        .map(|translation| (translation.segment_id.as_str(), translation))
        .collect::<HashMap<_, _>>();
    let chapter_titles = book
        .sections
        .iter()
        .map(|section| (section.id.0.as_str(), section.title.clone()))
        .collect::<HashMap<_, _>>();

    let review_segments = segments
        .iter()
        .filter_map(|segment| {
            let record = records_by_id.get(segment.id.0.as_str())?;
            let target_text = translations_by_id
                .get(segment.id.0.as_str())
                .map(|translation| translation.translated_text.clone())
                .unwrap_or_default();
            Some(ReviewSegment {
                segment_id: segment.id.0.clone(),
                chapter_id: segment.section_id.0.clone(),
                chapter_title: chapter_titles
                    .get(segment.section_id.0.as_str())
                    .cloned()
                    .flatten(),
                ordinal: segment.ordinal + 1,
                source_text: segment.source.text.clone(),
                soft_warnings: soft_warnings(record, &segment.source.text, &target_text),
                target_text,
                tokens: ReviewTokens {
                    input: record.input_tokens.unwrap_or(0),
                    input_cached: record.input_cached_tokens.unwrap_or(0),
                    output: record.output_tokens.unwrap_or(0),
                    estimated: record.tokens_estimated,
                },
                status: record.status.clone(),
            })
        })
        .collect::<Vec<_>>();

    ReviewDocument {
        schema_version: 1,
        job_id: job.id.clone(),
        source_language: job.source_lang.clone(),
        target_language: job.target_lang.clone(),
        provider: job.provider.clone(),
        model: job.model.clone(),
        generated_at: iso_timestamp(SystemTime::now()),
        source_book_title: book.metadata.title.clone(),
        source_book_author: book.metadata.creators.first().cloned(),
        source_input_snapshot: snapshot
            .input_snapshot_path
            .as_ref()
            .or(job.input_snapshot_path.as_ref())
            .map(|path| path.display().to_string()),
        totals: ReviewTotals {
            segments: review_segments.len(),
            tokens_input: summary.input_tokens,
            tokens_input_cached: summary.input_cached_tokens,
            tokens_output: summary.output_tokens,
            estimated_cost_usd: estimate_cost_usd(
                &job.provider,
                &job.model,
                summary.input_tokens,
                summary.output_tokens,
            ),
        },
        segments: review_segments,
    }
}

fn soft_warnings(
    record: &bookforge_store::SegmentRecord,
    source: &str,
    target: &str,
) -> Vec<ReviewWarning> {
    let mut warnings = Vec::new();
    match record.status.as_str() {
        "failed" => warnings.push(warning_message(
            "failed_segment",
            record
                .error
                .as_deref()
                .unwrap_or("segment failed without a stored error"),
        )),
        "needs_review" => warnings.push(warning_message(
            "needs_review",
            record.error.as_deref().unwrap_or("segment requires review"),
        )),
        "retry_pending" => warnings.push(warning_message(
            "retry_pending",
            "segment is still pending retry",
        )),
        _ => {}
    }

    let source_len = source.chars().count().max(1);
    let target_len = target.chars().count();
    if source_len >= 40 {
        let ratio = target_len as f64 / source_len as f64;
        if !(0.33..=3.0).contains(&ratio) {
            warnings.push(ReviewWarning {
                kind: "length_ratio".to_string(),
                value: Some((ratio * 100.0).round() / 100.0),
                threshold: Some(3.0),
                from: None,
                to: None,
                message: Some(format!(
                    "{source_len} source chars, {target_len} target chars"
                )),
            });
        }
    }

    for missing in missing_tokens("url_changed", &urls(source), &urls(target)) {
        warnings.push(missing);
    }
    for missing in missing_tokens("number_changed", &numbers(source), &numbers(target)) {
        warnings.push(missing);
    }
    if source_len >= 40 && source.trim() == target.trim() {
        warnings.push(warning_message(
            "untranslated",
            "translation matches source",
        ));
    }
    if looks_like_model_commentary(target) {
        warnings.push(warning_message(
            "model_commentary",
            "translation appears to include model commentary",
        ));
    }
    warnings
}

fn warning_message(kind: &str, message: &str) -> ReviewWarning {
    ReviewWarning {
        kind: kind.to_string(),
        value: None,
        threshold: None,
        from: None,
        to: None,
        message: Some(message.to_string()),
    }
}

fn missing_tokens(kind: &str, source: &[String], target: &[String]) -> Vec<ReviewWarning> {
    source
        .iter()
        .filter(|token| !target.contains(token))
        .map(|token| ReviewWarning {
            kind: kind.to_string(),
            value: None,
            threshold: None,
            from: Some(token.clone()),
            to: None,
            message: Some(format!("preserved token missing: {token}")),
        })
        .collect()
}

fn urls(text: &str) -> Vec<String> {
    text.split_whitespace()
        .filter_map(|token| {
            let value = token.trim_matches(|ch: char| {
                matches!(
                    ch,
                    ',' | ';' | ':' | '.' | '!' | '?' | ')' | ']' | '"' | '\''
                )
            });
            (value.starts_with("http://") || value.starts_with("https://"))
                .then(|| value.to_string())
        })
        .collect()
}

fn numbers(text: &str) -> Vec<String> {
    text.split_whitespace()
        .filter_map(|token| {
            let value = token.trim_matches(|ch: char| {
                matches!(
                    ch,
                    ',' | ';' | ':' | '.' | '!' | '?' | '(' | ')' | '[' | ']' | '"' | '\''
                )
            });
            let digits = value.chars().filter(|ch| ch.is_ascii_digit()).count();
            (digits >= 2
                && value.chars().all(|ch| {
                    ch.is_ascii_digit()
                        || matches!(ch, '.' | ',' | ':' | '/' | '-' | '+' | '%' | '$')
                }))
            .then(|| value.to_string())
        })
        .collect()
}

fn looks_like_model_commentary(text: &str) -> bool {
    let lower = text.trim_start().to_ascii_lowercase();
    lower.starts_with("here is ")
        || lower.starts_with("here's ")
        || lower.starts_with("certainly")
        || lower.starts_with("translation:")
        || lower.contains("as an ai")
}

fn render_html(review_json: &str) -> String {
    let embedded = review_json
        .replace('<', "\\u003c")
        .replace('>', "\\u003e")
        .replace('&', "\\u0026");
    let mut html = String::new();
    html.push_str(REVIEW_HTML_PREFIX);
    html.push_str(REVIEW_CSS);
    html.push_str(REVIEW_HTML_MIDDLE);
    html.push_str(&embedded);
    html.push_str(REVIEW_HTML_SUFFIX);
    html
}

fn open_in_browser(path: &Path) -> Result<()> {
    #[cfg(target_os = "macos")]
    let status = Command::new("open").arg(path).status()?;

    #[cfg(target_os = "windows")]
    let status = Command::new("cmd")
        .args(["/C", "start", "", &path.display().to_string()])
        .status()?;

    #[cfg(all(unix, not(target_os = "macos")))]
    let status = Command::new("xdg-open").arg(path).status()?;

    if !status.success() {
        anyhow::bail!("failed to open review HTML in default browser");
    }
    Ok(())
}

fn iso_timestamp(time: SystemTime) -> String {
    let seconds = time
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs() as i64;
    let days = seconds.div_euclid(86_400);
    let seconds_of_day = seconds.rem_euclid(86_400);
    let (year, month, day) = civil_from_days(days);
    let hour = seconds_of_day / 3_600;
    let minute = (seconds_of_day % 3_600) / 60;
    let second = seconds_of_day % 60;
    format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
}

fn civil_from_days(days_since_epoch: i64) -> (i64, i64, i64) {
    let z = days_since_epoch + 719_468;
    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
    let doe = z - era * 146_097;
    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
    let mut year = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let day = doy - (153 * mp + 2) / 5 + 1;
    let month = mp + if mp < 10 { 3 } else { -9 };
    year += if month <= 2 { 1 } else { 0 };
    (year, month, day)
}

const REVIEW_CSS: &str = r#"
:root {
  color-scheme: light;
  --bg: #f7f8fa;
  --panel: #ffffff;
  --text: #1d2329;
  --muted: #66717d;
  --line: #d9dee5;
  --accent: #0f766e;
  --warn: #b45309;
  --bad: #b91c1c;
  --shadow: 0 1px 2px rgba(21, 31, 43, 0.08);
}
* { box-sizing: border-box; }
body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font: 14px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
header {
  position: sticky;
  top: 0;
  z-index: 10;
  display: grid;
  grid-template-columns: 1fr auto;
  gap: 12px;
  align-items: center;
  padding: 10px 16px;
  background: var(--panel);
  border-bottom: 1px solid var(--line);
  box-shadow: var(--shadow);
}
h1 { margin: 0; font-size: 16px; font-weight: 650; letter-spacing: 0; }
.meta { color: var(--muted); font-size: 12px; display: flex; flex-wrap: wrap; gap: 10px; }
.toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: flex-end; }
button, input, textarea { font: inherit; }
button {
  border: 1px solid var(--line);
  background: var(--panel);
  color: var(--text);
  border-radius: 6px;
  padding: 6px 10px;
  cursor: pointer;
}
button.active, button.primary { border-color: var(--accent); color: #075e57; background: #e7f5f2; }
input[type="search"] {
  min-width: 240px;
  border: 1px solid var(--line);
  border-radius: 6px;
  padding: 7px 9px;
  background: white;
}
.review-shell {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  height: calc(100vh - 64px);
}
.pane {
  overflow: auto;
  padding: 12px;
  border-right: 1px solid var(--line);
}
.pane:last-child { border-right: 0; }
.pane-title {
  position: sticky;
  top: 0;
  z-index: 2;
  padding: 8px 4px;
  background: var(--bg);
  color: var(--muted);
  font-size: 12px;
  text-transform: uppercase;
}
.segment {
  min-height: 116px;
  margin-bottom: 10px;
  padding: 10px;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 8px;
  box-shadow: var(--shadow);
  white-space: pre-wrap;
}
.segment.hidden { display: none; }
.segment-head {
  display: flex;
  justify-content: space-between;
  gap: 8px;
  align-items: flex-start;
  margin-bottom: 7px;
  color: var(--muted);
  font-size: 12px;
}
.badges { display: flex; gap: 5px; flex-wrap: wrap; justify-content: flex-end; }
.badge {
  border: 1px solid #f0d5a8;
  color: var(--warn);
  background: #fff7ed;
  border-radius: 999px;
  padding: 1px 7px;
  font-size: 11px;
}
.badge.status-needs_review, .badge.status-failed { border-color: #fecaca; background: #fef2f2; color: var(--bad); }
.flag-panel {
  display: none;
  margin-top: 10px;
  border-top: 1px solid var(--line);
  padding-top: 10px;
}
.segment.flag-open .flag-panel { display: grid; gap: 8px; }
.kind-grid { display: flex; flex-wrap: wrap; gap: 8px 12px; font-size: 12px; }
textarea {
  width: 100%;
  min-height: 64px;
  resize: vertical;
  border: 1px solid var(--line);
  border-radius: 6px;
  padding: 7px;
}
.suggestions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
footer { padding: 10px 16px; color: var(--muted); font-size: 12px; border-top: 1px solid var(--line); }
@media (max-width: 840px) {
  header { grid-template-columns: 1fr; }
  .toolbar { justify-content: flex-start; }
  .review-shell { grid-template-columns: 1fr; height: auto; }
  .pane { height: 50vh; border-right: 0; border-bottom: 1px solid var(--line); }
  input[type="search"] { min-width: 0; width: 100%; }
  .suggestions { grid-template-columns: 1fr; }
}
"#;

const REVIEW_HTML_PREFIX: &str = r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BookForge Review</title>
<style>
"#;

const REVIEW_HTML_MIDDLE: &str = r#"
</style>
</head>
<body>
<header>
  <div>
    <h1 id="title">BookForge Review</h1>
    <div class="meta" id="meta"></div>
  </div>
  <div class="toolbar">
    <input id="search" type="search" placeholder="Search">
    <button data-filter="all" class="active">all</button>
    <button data-filter="flagged">flagged</button>
    <button data-filter="warnings">warnings</button>
    <button data-filter="needs_review">needs review</button>
    <button data-filter="completed">completed</button>
    <button id="export" class="primary">Export flags</button>
  </div>
</header>
<main class="review-shell">
  <section class="pane" id="sourcePane"><div class="pane-title">Source</div><div id="sourceList"></div></section>
  <section class="pane" id="targetPane"><div class="pane-title">Translation</div><div id="targetList"></div></section>
</main>
<footer>This page contains the full text of your book. Treat as private.</footer>
<script id="embedded-review-json" type="application/json">
"#;

const REVIEW_HTML_SUFFIX: &str = r#"
</script>
<script>
let data;
let flags = {};
let filter = "all";
let syncing = false;

const kinds = ["name", "register", "wrong_translation", "formatting", "tone", "other"];

async function loadReview() {
  try {
    const response = await fetch("./review.json", {cache: "no-store"});
    if (!response.ok) throw new Error("fetch failed");
    return await response.json();
  } catch (_) {
    return JSON.parse(document.getElementById("embedded-review-json").textContent);
  }
}

function flagKey() {
  return `bookforge.review.flags.${data.job_id}`;
}

function loadFlags() {
  try { flags = JSON.parse(localStorage.getItem(flagKey()) || "{}"); }
  catch (_) { flags = {}; }
}

function saveFlags() {
  localStorage.setItem(flagKey(), JSON.stringify(flags));
  updateMeta();
}

function updateMeta() {
  const flagged = Object.keys(flags).length;
  const totals = data.totals;
  document.getElementById("meta").textContent =
    `${data.provider}/${data.model} | ${totals.segments} segments | ${flagged} flagged | ` +
    `${totals.tokens_input} input, ${totals.tokens_input_cached} cached, ${totals.tokens_output} output tokens`;
}

function warningLabel(w) {
  if (w.kind === "length_ratio" && w.value) return `length ${w.value}x`;
  if (w.kind === "url_changed") return "url";
  if (w.kind === "number_changed") return "number";
  return w.kind.replaceAll("_", " ");
}

function renderSegment(segment, side) {
  const flagged = flags[segment.segment_id];
  const el = document.createElement("article");
  el.className = "segment";
  el.dataset.segmentId = segment.segment_id;
  el.dataset.status = segment.status;
  el.dataset.warnings = String(segment.soft_warnings.length > 0);
  el.dataset.flagged = String(Boolean(flagged));
  el.dataset.text = `${segment.source_text} ${segment.target_text}`.toLowerCase();

  const head = document.createElement("div");
  head.className = "segment-head";
  const left = document.createElement("span");
  const approx = segment.tokens.estimated ? " ≈" : "";
  left.textContent = `${segment.ordinal}. ${segment.chapter_title || segment.chapter_id} | ${segment.status} | ${approx}${segment.tokens.input}/${segment.tokens.input_cached}/${segment.tokens.output}`;
  head.appendChild(left);

  const badges = document.createElement("div");
  badges.className = "badges";
  if (flagged) {
    const b = document.createElement("span");
    b.className = "badge";
    b.textContent = flagged.kind;
    badges.appendChild(b);
  }
  if (segment.status !== "succeeded" && segment.status !== "skipped_cached") {
    const b = document.createElement("span");
    b.className = `badge status-${segment.status}`;
    b.textContent = segment.status.replaceAll("_", " ");
    badges.appendChild(b);
  }
  for (const warning of segment.soft_warnings) {
    const b = document.createElement("span");
    b.className = "badge";
    b.textContent = warningLabel(warning);
    b.title = warning.message || warning.kind;
    badges.appendChild(b);
  }
  head.appendChild(badges);
  el.appendChild(head);

  const text = document.createElement("div");
  text.textContent = side === "source" ? segment.source_text : segment.target_text;
  el.appendChild(text);

  if (side === "target") {
    const btn = document.createElement("button");
    btn.type = "button";
    btn.textContent = flagged ? "Edit flag" : "Flag";
    btn.addEventListener("click", () => el.classList.toggle("flag-open"));
    el.appendChild(btn);
    el.appendChild(renderFlagPanel(segment));
  }
  return el;
}

function renderFlagPanel(segment) {
  const current = flags[segment.segment_id] || {kind: "wrong_translation", note: "", suggested_source: "", suggested_target: ""};
  const panel = document.createElement("div");
  panel.className = "flag-panel";

  const kindGrid = document.createElement("div");
  kindGrid.className = "kind-grid";
  for (const kind of kinds) {
    const label = document.createElement("label");
    const radio = document.createElement("input");
    radio.type = "radio";
    radio.name = `kind-${segment.segment_id}`;
    radio.value = kind;
    radio.checked = current.kind === kind;
    label.appendChild(radio);
    label.append(` ${kind.replaceAll("_", " ")}`);
    kindGrid.appendChild(label);
  }
  panel.appendChild(kindGrid);

  const note = document.createElement("textarea");
  note.placeholder = "Note";
  note.value = current.note || "";
  panel.appendChild(note);

  const suggestions = document.createElement("div");
  suggestions.className = "suggestions";
  const source = document.createElement("textarea");
  source.placeholder = "Suggested source";
  source.value = current.suggested_source || "";
  const target = document.createElement("textarea");
  target.placeholder = "Suggested target";
  target.value = current.suggested_target || "";
  suggestions.append(source, target);
  panel.appendChild(suggestions);

  const actions = document.createElement("div");
  const save = document.createElement("button");
  save.type = "button";
  save.className = "primary";
  save.textContent = "Save flag";
  save.addEventListener("click", () => {
    const kind = panel.querySelector("input[type=radio]:checked").value;
    flags[segment.segment_id] = {
      segment_id: segment.segment_id,
      kind,
      note: note.value,
      suggested_source: source.value || null,
      suggested_target: target.value || null
    };
    saveFlags();
    render();
  });
  const clear = document.createElement("button");
  clear.type = "button";
  clear.textContent = "Clear";
  clear.addEventListener("click", () => {
    delete flags[segment.segment_id];
    saveFlags();
    render();
  });
  actions.append(save, clear);
  panel.appendChild(actions);
  return panel;
}

function render() {
  document.getElementById("title").textContent = data.source_book_title || "BookForge Review";
  updateMeta();
  const query = document.getElementById("search").value.trim().toLowerCase();
  const sourceList = document.getElementById("sourceList");
  const targetList = document.getElementById("targetList");
  sourceList.textContent = "";
  targetList.textContent = "";
  for (const segment of data.segments) {
    const hasFlag = Boolean(flags[segment.segment_id]);
    const visible =
      (!query || `${segment.source_text} ${segment.target_text}`.toLowerCase().includes(query)) &&
      (filter === "all" ||
       (filter === "flagged" && hasFlag) ||
       (filter === "warnings" && segment.soft_warnings.length > 0) ||
       (filter === "needs_review" && segment.status === "needs_review") ||
       (filter === "completed" && (segment.status === "succeeded" || segment.status === "skipped_cached")));
    if (!visible) continue;
    sourceList.appendChild(renderSegment(segment, "source"));
    targetList.appendChild(renderSegment(segment, "target"));
  }
}

function exportFlags() {
  const payload = {
    schema_version: 1,
    job_id: data.job_id,
    exported_at: new Date().toISOString(),
    flags: Object.values(flags).map(f => ({
      segment_id: f.segment_id,
      kind: f.kind,
      note: f.note || "",
      suggested_source: f.suggested_source || null,
      suggested_target: f.suggested_target || null
    }))
  };
  const blob = new Blob([JSON.stringify(payload, null, 2)], {type: "application/json"});
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = "flags.json";
  a.click();
  URL.revokeObjectURL(url);
}

function bindSync(a, b) {
  a.addEventListener("scroll", () => {
    if (syncing) return;
    syncing = true;
    const maxA = Math.max(1, a.scrollHeight - a.clientHeight);
    const maxB = Math.max(1, b.scrollHeight - b.clientHeight);
    b.scrollTop = (a.scrollTop / maxA) * maxB;
    syncing = false;
  });
}

document.addEventListener("DOMContentLoaded", async () => {
  data = await loadReview();
  loadFlags();
  render();
  document.getElementById("search").addEventListener("input", render);
  document.querySelectorAll("[data-filter]").forEach(button => {
    button.addEventListener("click", () => {
      filter = button.dataset.filter;
      document.querySelectorAll("[data-filter]").forEach(b => b.classList.toggle("active", b === button));
      render();
    });
  });
  document.getElementById("export").addEventListener("click", exportFlags);
  bindSync(document.getElementById("sourcePane"), document.getElementById("targetPane"));
  bindSync(document.getElementById("targetPane"), document.getElementById("sourcePane"));
});
</script>
</body>
</html>
"#;

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn iso_timestamp_formats_unix_epoch() {
        assert_eq!(
            iso_timestamp(UNIX_EPOCH),
            "1970-01-01T00:00:00Z".to_string()
        );
    }

    #[test]
    fn render_html_embeds_json_fallback() {
        let html = render_html(&json!({"schema_version": 1, "x": "<tag>"}).to_string());
        assert!(html.contains("embedded-review-json"));
        assert!(html.contains("\\u003ctag\\u003e"));
    }
}
