use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};
use tagpath::{compression, family, parser, query};

const FIXTURE: &str = include_str!("../fixtures/tsift-token-savings.json");

#[derive(Debug, Deserialize)]
struct Fixture {
    schema_version: u32,
    token_estimate: String,
    cases: Vec<BenchmarkCase>,
}

#[derive(Debug, Deserialize)]
struct BenchmarkCase {
    name: String,
    surface: String,
    minimum_savings_percent: f64,
    raw_symbols: Vec<RawSymbol>,
    tagpath_families: Vec<ExpectedFamily>,
    #[serde(default)]
    context_pack_inputs: Option<ContextPackInputs>,
    #[serde(default)]
    normalize_query_input: Option<String>,
    #[serde(default)]
    expected_query_tags: Vec<String>,
    #[serde(default)]
    ontology_refs: Vec<OntologyRef>,
}

#[derive(Debug, Deserialize)]
struct RawSymbol {
    identifier: String,
    file: String,
    line: usize,
    context: String,
}

#[derive(Debug, Deserialize)]
struct ExpectedFamily {
    canonical: String,
    count: usize,
    aliases: BTreeMap<String, String>,
}

#[derive(Debug, Deserialize, Serialize)]
struct ContextPackInputs {
    #[serde(default)]
    next_context: Vec<serde_json::Value>,
    #[serde(default)]
    diff: Vec<serde_json::Value>,
    #[serde(default)]
    test: Vec<serde_json::Value>,
    #[serde(default)]
    log: Vec<serde_json::Value>,
}

#[derive(Debug, Deserialize, Serialize)]
struct OntologyRef {
    handle: String,
    tag: String,
    path: String,
    title: String,
    domain: String,
    #[serde(default)]
    summary: Option<String>,
}

#[test]
fn tsift_token_savings_fixture_groups_symbols_by_tagpath_family() {
    let fixture: Fixture = serde_json::from_str(FIXTURE).unwrap();
    assert_eq!(fixture.schema_version, 1);
    assert_eq!(fixture.token_estimate, "ceil(utf8_bytes / 4)");

    for case in &fixture.cases {
        assert!(
            matches!(
                case.surface.as_str(),
                "search"
                    | "explain"
                    | "session-review"
                    | "context-pack"
                    | "normalize-query"
                    | "ontology-refs"
            ),
            "unexpected fixture surface in {}: {}",
            case.name,
            case.surface
        );

        let mut observed_counts = BTreeMap::<String, usize>::new();
        for raw_symbol in &case.raw_symbols {
            let convention = parser::detect_convention(&raw_symbol.identifier);
            let parsed = parser::parse(&raw_symbol.identifier, convention);
            *observed_counts.entry(parsed.tags.join("_")).or_default() += 1;
        }

        let expected_counts: BTreeMap<String, usize> = case
            .tagpath_families
            .iter()
            .map(|expected| (expected.canonical.clone(), expected.count))
            .collect();
        assert_eq!(
            observed_counts, expected_counts,
            "fixture counts must match parsed raw symbols for {}",
            case.name
        );

        let report = compression::build_report(&compression_rows(&case.raw_symbols));
        assert_eq!(report.family_count, case.tagpath_families.len());
        let report_counts: BTreeMap<String, usize> = report
            .families
            .iter()
            .map(|family| (family.canonical.clone(), family.count))
            .collect();
        assert_eq!(
            report_counts, expected_counts,
            "compression report counts must match fixture families for {}",
            case.name
        );

        for expected in &case.tagpath_families {
            let generated = family::generate_family(&expected.canonical);
            assert_eq!(
                generated.aliases, expected.aliases,
                "fixture aliases must stay generated by tagpath for {}",
                expected.canonical
            );
        }
    }
}

#[test]
fn tsift_token_savings_fixture_compacts_all_preview_surfaces() {
    let fixture: Fixture = serde_json::from_str(FIXTURE).unwrap();
    assert_eq!(
        fixture.cases.len(),
        6,
        "fixture should cover search, explain, session-review, context-pack, normalize-query, and ontology-refs previews"
    );
    assert_eq!(
        fixture
            .cases
            .iter()
            .map(|case| case.surface.as_str())
            .collect::<Vec<_>>(),
        vec![
            "search",
            "explain",
            "session-review",
            "context-pack",
            "normalize-query",
            "ontology-refs"
        ]
    );

    for case in &fixture.cases {
        let raw_preview = render_raw_preview(case);
        let tagpath_preview = render_tagpath_preview(case);
        let raw_tokens = compression::estimate_tokens(&raw_preview);
        let tagpath_tokens = compression::estimate_tokens(&tagpath_preview);
        let saved_percent = ((raw_tokens - tagpath_tokens) as f64 / raw_tokens as f64) * 100.0;

        assert!(
            tagpath_tokens < raw_tokens,
            "{} should be smaller as a tagpath family/alias preview\nraw:\n{}\ncompact:\n{}",
            case.name,
            raw_preview,
            tagpath_preview
        );
        assert!(
            saved_percent >= case.minimum_savings_percent,
            "{} saved {saved_percent:.1}% ({raw_tokens} -> {tagpath_tokens}), expected at least {:.1}%",
            case.name,
            case.minimum_savings_percent
        );
    }
}

#[test]
fn tsift_token_savings_context_pack_case_carries_handle_refs() {
    let fixture: Fixture = serde_json::from_str(FIXTURE).unwrap();
    let case = fixture
        .cases
        .iter()
        .find(|case| case.surface == "context-pack")
        .expect("fixture should include context-pack");
    let inputs = case
        .context_pack_inputs
        .as_ref()
        .expect("context-pack case should include raw context-pack inputs");

    assert!(!inputs.next_context.is_empty());
    assert!(!inputs.diff.is_empty());
    assert!(!inputs.test.is_empty());
    assert!(!inputs.log.is_empty());

    let raw = serde_json::to_string(inputs).unwrap();
    assert!(raw.contains("ontology_refs"));
    assert!(raw.contains(".naming/tags/"));
    assert!(raw.contains("summary_refs"));
    assert!(raw.contains("artifact"));
}

#[test]
fn tsift_token_savings_query_and_ontology_cases_stay_handle_sized() {
    let fixture: Fixture = serde_json::from_str(FIXTURE).unwrap();
    let query_case = fixture
        .cases
        .iter()
        .find(|case| case.surface == "normalize-query")
        .expect("fixture should include normalize-query");
    let input = query_case
        .normalize_query_input
        .as_ref()
        .expect("normalize-query case should include input");
    let normalized = query::normalize_query(input);
    let observed = normalized
        .tags
        .iter()
        .map(|tag| tag.tag.as_str())
        .collect::<Vec<_>>();
    for expected in &query_case.expected_query_tags {
        assert!(
            observed.contains(&expected.as_str()),
            "normalize-query fixture should emit {expected:?}: {observed:?}"
        );
    }
    assert!(
        normalized.tags.iter().any(|tag| tag
            .sources
            .iter()
            .any(|source| source == "ontology_ref_handle")),
        "identifier-shaped query input should preserve source handles"
    );

    let ontology_case = fixture
        .cases
        .iter()
        .find(|case| case.surface == "ontology-refs")
        .expect("fixture should include ontology-refs");
    assert!(!ontology_case.ontology_refs.is_empty());
    for ontology_ref in &ontology_case.ontology_refs {
        assert!(ontology_ref.handle.starts_with("ont-"));
        assert!(ontology_ref.path.starts_with(".naming/tags/"));
        assert!(ontology_ref.path.ends_with(".md"));
    }
    let compact_preview = render_tagpath_preview(ontology_case);
    assert!(
        compact_preview.contains("ontology_ref\tont-context\tcontext\t.naming/tags/context.md")
    );
    assert!(compact_preview.contains("ontology_ref\tont-query\tquery\t.naming/tags/query.md"));
    assert!(
        !compact_preview.contains("summary:"),
        "compact ontology preview should keep handles instead of inline prose"
    );
}

fn render_raw_preview(case: &BenchmarkCase) -> String {
    let mut preview = compression::render_raw_symbol_preview(&compression_rows(&case.raw_symbols));
    if let Some(inputs) = &case.context_pack_inputs {
        preview.push('\n');
        preview.push_str(&serde_json::to_string(inputs).unwrap());
    }
    if let Some(input) = &case.normalize_query_input {
        preview.push('\n');
        preview.push_str("normalize_query_input: ");
        preview.push_str(input);
    }
    if !case.ontology_refs.is_empty() {
        preview.push('\n');
        preview.push_str(&serde_json::to_string(&case.ontology_refs).unwrap());
    }
    preview
}

fn render_tagpath_preview(case: &BenchmarkCase) -> String {
    let report = compression::build_report(&compression_rows(&case.raw_symbols));
    let mut preview = report.compact_preview;
    if let Some(inputs) = &case.context_pack_inputs {
        let section_rows = [
            ("next_context", inputs.next_context.len()),
            ("diff", inputs.diff.len()),
            ("test", inputs.test.len()),
            ("log", inputs.log.len()),
        ]
        .into_iter()
        .filter(|(_, count)| *count > 0)
        .map(|(section, count)| format!("{section}\tcount:{count}\texpand:tsift {section}"))
        .collect::<Vec<_>>()
        .join("\n");
        preview.push('\n');
        preview.push_str(&section_rows);
    }
    if let Some(input) = &case.normalize_query_input {
        let query_rows = query::normalize_query_tags(input)
            .into_iter()
            .filter(|tag| {
                case.expected_query_tags
                    .iter()
                    .any(|expected| expected == &tag.tag)
            })
            .map(|tag| format!("query_tag\t{}\tweight:{:.1}", tag.tag, tag.weight))
            .collect::<Vec<_>>()
            .join("\n");
        preview.push('\n');
        preview.push_str(&query_rows);
    }
    if !case.ontology_refs.is_empty() {
        let ontology_rows = case
            .ontology_refs
            .iter()
            .map(|ontology_ref| {
                format!(
                    "ontology_ref\t{}\t{}\t{}",
                    ontology_ref.handle, ontology_ref.tag, ontology_ref.path
                )
            })
            .collect::<Vec<_>>()
            .join("\n");
        preview.push('\n');
        preview.push_str(&ontology_rows);
    }
    preview
}

fn compression_rows(raw_symbols: &[RawSymbol]) -> Vec<compression::RawSymbolRow> {
    raw_symbols
        .iter()
        .map(|symbol| compression::RawSymbolRow {
            identifier: symbol.identifier.clone(),
            file: symbol.file.clone().into(),
            line: symbol.line,
            column: 0,
            context: Some(symbol.context.clone()),
        })
        .collect::<Vec<_>>()
}
