use chrono::Utc;
use serde_json::json;
use std::fs;
use std::io::IsTerminal;
use std::path::PathBuf;
use std::process::Command;
use std::time::Instant;

use crate::commands::coverage;
use crate::output::Envelope;
use crate::report::builder::{self, BuildError, ReportInput};
use crate::report::markdown::{self, RenderError};
use crate::report::sources;
use crate::report::template::{self, Slots};
use crate::report::wiki_render;
use crate::session::{
    active, config,
    event::{SessionEvent, SynthesizeStage},
    layout, log,
};

const CMD: &str = "research synthesize";

pub fn run(slug_arg: Option<&str>, no_render: bool, open: bool, bilingual: bool) -> Envelope {
    let slug = match slug_arg {
        Some(s) => s.to_string(),
        None => match active::get_active() {
            Some(s) => s,
            None => {
                return Envelope::fail(
                    CMD,
                    "NO_ACTIVE_SESSION",
                    "no active session — pass <slug> or run `research new` first",
                );
            }
        },
    };

    if !config::exists(&slug) {
        return Envelope::fail(CMD, "SESSION_NOT_FOUND", format!("no session '{slug}'"))
            .with_context(json!({ "session": slug }));
    }

    let cfg = match config::read(&slug) {
        Ok(c) => c,
        Err(e) => return Envelope::fail(CMD, "IO_ERROR", format!("read session.toml: {e}")),
    };

    let md = match fs::read_to_string(layout::session_md(&slug)) {
        Ok(s) => s,
        Err(e) => return Envelope::fail(CMD, "IO_ERROR", format!("read session.md: {e}")),
    };

    let events = log::read_all(&slug).unwrap_or_default();

    let start = Instant::now();
    let _ = log::append(
        &slug,
        &SessionEvent::SynthesizeStarted {
            timestamp: Utc::now(),
            no_render,
            open,
            bilingual,
            bilingual_provider: requested_bilingual_provider(bilingual),
            note: None,
        },
    );

    let input = ReportInput {
        topic: &cfg.topic,
        preset: &cfg.preset,
        md: &md,
        events: &events,
    };
    let built = match builder::build(&input) {
        Ok(b) => b,
        Err(BuildError::MissingOverview) => {
            let _ = log::append(
                &slug,
                &SessionEvent::SynthesizeFailed {
                    timestamp: Utc::now(),
                    stage: SynthesizeStage::Build,
                    reason: "missing `## Overview` section".into(),
                    note: None,
                },
            );
            return Envelope::fail(
                CMD,
                "MISSING_OVERVIEW",
                "session.md lacks a non-placeholder `## Overview` section — edit it and retry",
            )
            .with_context(json!({ "session": slug }));
        }
    };

    let coverage = coverage::run(Some(&slug));
    if !coverage.ok {
        let (reason, details) = if let Some(err) = coverage.error {
            (
                format!("coverage preflight failed: {}", err.message),
                err.details,
            )
        } else {
            (
                "coverage preflight failed".to_string(),
                serde_json::Value::Null,
            )
        };
        let _ = log::append(
            &slug,
            &SessionEvent::SynthesizeFailed {
                timestamp: Utc::now(),
                stage: SynthesizeStage::Build,
                reason: reason.clone(),
                note: None,
            },
        );
        let mut env =
            Envelope::fail(CMD, "IO_ERROR", reason).with_context(json!({ "session": slug }));
        if !details.is_null() {
            env = env.with_details(details);
        }
        return env;
    }
    if coverage.data["report_ready"] != json!(true) {
        let blockers = coverage.data["report_ready_blockers"].clone();
        let blocker_summary = blockers
            .as_array()
            .map(|items| {
                items
                    .iter()
                    .filter_map(|v| v.as_str())
                    .collect::<Vec<_>>()
                    .join("; ")
            })
            .filter(|s| !s.is_empty())
            .unwrap_or_else(|| "coverage did not report blockers".to_string());
        let _ = log::append(
            &slug,
            &SessionEvent::SynthesizeFailed {
                timestamp: Utc::now(),
                stage: SynthesizeStage::Build,
                reason: format!("report not ready: {blocker_summary}"),
                note: None,
            },
        );
        return Envelope::fail(
            CMD,
            "REPORT_NOT_READY",
            "session does not satisfy `research coverage` gates — fix blockers and retry",
        )
        .with_context(json!({ "session": slug }))
        .with_details(json!({
            "report_ready": coverage.data["report_ready"].clone(),
            "report_ready_blockers": blockers,
        }));
    }

    let report_json_path = layout::session_report_json(&slug);
    let serialized = match serde_json::to_string_pretty(&built.json) {
        Ok(s) => s,
        Err(e) => {
            let _ = log::append(
                &slug,
                &SessionEvent::SynthesizeFailed {
                    timestamp: Utc::now(),
                    stage: SynthesizeStage::Build,
                    reason: format!("serialize: {e}"),
                    note: None,
                },
            );
            return Envelope::fail(CMD, "IO_ERROR", format!("serialize report: {e}"));
        }
    };
    if let Err(e) = fs::write(&report_json_path, &serialized) {
        let _ = log::append(
            &slug,
            &SessionEvent::SynthesizeFailed {
                timestamp: Utc::now(),
                stage: SynthesizeStage::Build,
                reason: format!("write: {e}"),
                note: None,
            },
        );
        return Envelope::fail(CMD, "IO_ERROR", format!("write report.json: {e}"));
    }

    // Render stage — rich-html only. The json-ui rendering path was
    // removed; `synthesize` now emits the same warm-paper editorial HTML
    // that `research report --format rich-html` produces, so there is a
    // single canonical report template across the project.
    let mut report_html_path: Option<String> = None;
    let mut report_html_abs: Option<PathBuf> = None;
    let mut render_error: Option<String> = None;
    let mut render_warnings: Vec<String> = Vec::new();
    if !no_render {
        match render_rich_html(&slug, &md, &cfg.topic, &cfg.tags, &cfg.preset, bilingual) {
            Ok((html_path, warnings)) => {
                report_html_path = Some(rel_path(&html_path));
                report_html_abs = Some(html_path);
                render_warnings = warnings;
            }
            Err(e) => render_error = Some(e),
        }
    }

    let duration_ms = start.elapsed().as_millis() as u64;

    if let Some(err) = &render_error {
        let _ = log::append(
            &slug,
            &SessionEvent::SynthesizeFailed {
                timestamp: Utc::now(),
                stage: SynthesizeStage::Render,
                reason: err.clone(),
                note: None,
            },
        );
    } else {
        let _ = log::append(
            &slug,
            &SessionEvent::SynthesizeCompleted {
                timestamp: Utc::now(),
                report_json_path: rel_path(&report_json_path),
                report_html_path: report_html_path.clone(),
                accepted_sources: built.accepted_count,
                rejected_sources: built.rejected_count,
                duration_ms,
                note: None,
            },
        );
    }

    // Maybe open.
    let mut open_skipped: Option<&'static str> = None;
    if open {
        if should_skip_open() {
            open_skipped = Some("non-interactive environment");
            eprintln!("skipping open (non-interactive)");
        } else if let Some(html) = &report_html_path {
            let html_abs =
                layout::session_dir(&slug).join(html.trim_start_matches(&format!("{slug}/")));
            let spawn_result = if cfg!(target_os = "macos") {
                Command::new("open").arg(&html_abs).spawn()
            } else {
                Command::new("xdg-open").arg(&html_abs).spawn()
            };
            if let Err(e) = spawn_result {
                eprintln!("⚠ open failed: {e}");
            }
        }
    }

    if let Some(err) = render_error {
        return Envelope::fail(CMD, "RENDER_FAILED", err)
            .with_context(json!({ "session": slug }))
            .with_details(json!({
                "report_json_path": rel_path(&report_json_path),
                "accepted_sources": built.accepted_count,
                "rejected_sources": built.rejected_count,
            }));
    }

    let mut all_warnings = built.warnings.clone();
    all_warnings.extend(render_warnings);
    let bilingual_provider = requested_bilingual_provider(bilingual);
    let zh_paragraphs = report_html_abs
        .as_ref()
        .and_then(|path| count_zh_paragraphs(path).ok());
    let bilingual_status = bilingual_status(bilingual, zh_paragraphs, &all_warnings);

    Envelope::ok(
        CMD,
        json!({
            "report_json_path": rel_path(&report_json_path),
            "report_html_path": report_html_path,
            "accepted_sources": built.accepted_count,
            "rejected_sources": built.rejected_count,
            "duration_ms": duration_ms,
            "open_skipped": open_skipped,
            "bilingual": {
                "requested": bilingual,
                "provider": bilingual_provider,
                "status": bilingual_status,
                "zh_paragraphs": zh_paragraphs,
            },
            "warnings": all_warnings,
        }),
    )
    .with_context(json!({ "session": slug }))
}

/// Render the rich-html report to `<session>/report.html` using the same
/// pipeline as `research report --format rich-html`. Returns the path and
/// any non-fatal warnings (e.g. multiple-aside detection from markdown
/// render). `DiagramOutOfBounds` bubbles up as a fatal render error.
fn render_rich_html(
    slug: &str,
    md: &str,
    topic: &str,
    tags: &[String],
    preset: &str,
    bilingual: bool,
) -> Result<(PathBuf, Vec<String>), String> {
    let session_dir = layout::session_dir(slug);
    let rendered = markdown::render_body(md, &session_dir).map_err(|e| match e {
        RenderError::DiagramOutOfBounds(p) => format!(
            "diagram_out_of_bounds: '{}' resolves outside session_dir/diagrams/",
            p.display()
        ),
    })?;
    let sources_section = sources::build_from_jsonl(&layout::session_jsonl(slug));
    let mut warnings = rendered.warnings.clone();
    warnings.extend(sources_section.warnings.iter().cloned());

    // v3: render wiki pages between the numbered sections and Sources.
    let wiki = wiki_render::render_wiki(slug, &session_dir).map_err(|e| match e {
        RenderError::DiagramOutOfBounds(p) => format!(
            "diagram_out_of_bounds (in wiki page): '{}' resolves outside session_dir/diagrams/",
            p.display()
        ),
    })?;
    warnings.extend(wiki.warnings.iter().cloned());
    if wiki.broken_links > 0 {
        warnings.push(format!(
            "broken_wiki_links: {} — see coverage",
            wiki.broken_links
        ));
    }

    // v3: render any SVG files that exist in `<session>/diagrams/` but
    // aren't referenced from session.md or any wiki page. Without this
    // pass, an SVG the agent wrote via `write_diagram` but forgot to
    // pair with `![alt](diagrams/x.svg)` stays invisible in the report.
    // tokio-v3 smoke caught this — `task-lifecycle.svg` (5.5 KB) was on
    // disk but silent.
    let orphan_diagrams_html = render_orphan_diagrams(slug, &session_dir, md);

    let combined_body = match (wiki.page_count, orphan_diagrams_html.is_empty()) {
        (0, true) => rendered.body_html.clone(),
        (0, false) => format!("{}\n{}", rendered.body_html, orphan_diagrams_html),
        (_, true) => format!("{}\n{}", rendered.body_html, wiki.html),
        (_, false) => format!(
            "{}\n{}\n{}",
            rendered.body_html, wiki.html, orphan_diagrams_html
        ),
    };

    let body_html = if bilingual {
        match crate::report::bilingual::inject_zh_translations(&combined_body) {
            Ok((augmented, note)) => {
                if let Some(n) = note {
                    warnings.push(n);
                }
                augmented
            }
            Err(e) => {
                warnings.push(format!("bilingual_skipped: {e}"));
                combined_body.clone()
            }
        }
    } else {
        combined_body
    };

    let tags_str = if tags.is_empty() {
        String::new()
    } else {
        format!(" · tagged {}", tags.join(", "))
    };
    let subtitle = format!("Session: <code>{slug}</code>{tags_str} · preset <code>{preset}</code>");
    let session_footer = format!(
        "Session · {} · {} accepted source{} · {} bytes",
        session_dir.display(),
        sources_section.count,
        if sources_section.count == 1 { "" } else { "s" },
        sources_section.total_bytes,
    );

    let slots = Slots {
        title: topic.to_string(),
        subtitle,
        aside_quote: rendered.aside_html,
        body_html,
        sources_html: sources_section.html,
        generated_at: Utc::now().to_rfc3339(),
        session_footer,
    };
    let html = template::render(&slots);

    let html_path = layout::session_dir(slug).join("report.html");
    fs::write(&html_path, &html).map_err(|e| format!("write report.html: {e}"))?;
    Ok((html_path, warnings))
}

fn should_skip_open() -> bool {
    if std::env::var("SYNTHESIZE_NO_OPEN").is_ok() {
        return true;
    }
    if std::env::var("CI").is_ok() {
        return true;
    }
    !std::io::stdin().is_terminal()
}

fn requested_bilingual_provider(bilingual: bool) -> Option<String> {
    if !bilingual {
        return None;
    }
    Some(
        std::env::var("ASR_BILINGUAL_PROVIDER")
            .or_else(|_| std::env::var("ASCENT_RESEARCH_BILINGUAL_PROVIDER"))
            .unwrap_or_else(|_| default_bilingual_provider().to_string()),
    )
}

fn default_bilingual_provider() -> &'static str {
    #[cfg(feature = "provider-claude")]
    {
        "claude"
    }
    #[cfg(all(not(feature = "provider-claude"), feature = "provider-codex"))]
    {
        "codex"
    }
    #[cfg(not(any(feature = "provider-claude", feature = "provider-codex")))]
    {
        "none"
    }
}

fn count_zh_paragraphs(path: &std::path::Path) -> Result<usize, std::io::Error> {
    let html = fs::read_to_string(path)?;
    Ok(html.matches(r#"class="tr-zh""#).count() + html.matches(r#"class='tr-zh'"#).count())
}

fn bilingual_status(
    requested: bool,
    zh_paragraphs: Option<usize>,
    warnings: &[String],
) -> &'static str {
    if !requested {
        return "not_requested";
    }
    if warnings
        .iter()
        .any(|warning| warning.starts_with("bilingual_skipped:"))
    {
        return "skipped";
    }
    match zh_paragraphs {
        Some(n) if n > 0 => "complete",
        Some(_) => "missing_zh",
        None => "unknown",
    }
}

/// Scan `<session>/diagrams/` for `.svg` files that aren't referenced
/// from session.md or any wiki page, and produce an HTML block that
/// inlines them so the agent's effort isn't lost on the rendered
/// report. Each orphan SVG renders as a `.diagram` block with a
/// caption derived from the filename.
fn render_orphan_diagrams(_slug: &str, session_dir: &std::path::Path, md: &str) -> String {
    let diagrams_dir = session_dir.join("diagrams");
    let Ok(entries) = fs::read_dir(&diagrams_dir) else {
        return String::new();
    };
    let mut on_disk: Vec<String> = entries
        .filter_map(|e| e.ok())
        .filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("svg"))
        .filter_map(|e| {
            e.path()
                .file_name()
                .and_then(|s| s.to_str())
                .map(str::to_string)
        })
        .collect();
    on_disk.sort();
    if on_disk.is_empty() {
        return String::new();
    }

    // A filename is "referenced" if ANY of session.md / wiki pages
    // contain `diagrams/<filename>`. Cheap substring check — the md
    // render layer already enforces the exact `![](diagrams/…)` shape
    // before inlining, so the false-positive risk of unrelated text is
    // near-zero.
    let mut all_text = md.to_string();
    if let Ok(entries) = fs::read_dir(session_dir.join("wiki")) {
        for e in entries.flatten() {
            if e.path().extension().and_then(|s| s.to_str()) == Some("md")
                && let Ok(body) = fs::read_to_string(e.path())
            {
                all_text.push('\n');
                all_text.push_str(&body);
            }
        }
    }
    let orphans: Vec<String> = on_disk
        .into_iter()
        .filter(|f| !all_text.contains(&format!("diagrams/{f}")))
        .collect();
    if orphans.is_empty() {
        return String::new();
    }

    let mut out = String::new();
    out.push_str(r#"<section class="orphan-diagrams"><h2><span class="section-num">DIAGRAMS</span><span>Supplementary figures</span></h2>"#);
    for fname in &orphans {
        let path = diagrams_dir.join(fname);
        let svg = match fs::read_to_string(&path) {
            Ok(s) => s,
            Err(_) => continue,
        };
        if svg.len() > 512 * 1024 {
            continue;
        }
        let caption = fname
            .strip_suffix(".svg")
            .unwrap_or(fname)
            .replace('-', " ");
        out.push_str(r#"<div class="diagram">"#);
        out.push_str(&svg);
        out.push_str(&format!("<p class=\"caption\">{caption}</p>"));
        out.push_str("</div>");
    }
    out.push_str("</section>");
    out
}

fn rel_path(p: &std::path::Path) -> String {
    let comps: Vec<_> = p.components().collect();
    let n = comps.len();
    if n >= 2 {
        format!(
            "{}/{}",
            comps[n - 2].as_os_str().to_string_lossy(),
            comps[n - 1].as_os_str().to_string_lossy()
        )
    } else {
        p.to_string_lossy().into_owned()
    }
}
