use panache_parser::parser::yaml::{parse_yaml_report, project_events};
use panache_parser::syntax::SyntaxNode as ParserSyntaxNode;
use serde_json::json;
use std::fs;
use std::path::{Path, PathBuf};

const FIXTURE_DIR: &str = "tests/fixtures/yaml-test-suite";
const ALLOWLIST_PATH: &str = "tests/yaml/allowlist.txt";

fn read_lines(path: &Path) -> Vec<String> {
    let content = fs::read_to_string(path)
        .unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display()));
    content
        .lines()
        .map(str::trim)
        .filter(|line| !line.is_empty() && !line.starts_with('#'))
        .map(ToOwned::to_owned)
        .collect()
}

fn fixture_root() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join(FIXTURE_DIR)
}

fn fixture_case_path(case_id: &str) -> PathBuf {
    fixture_root().join(case_id)
}

fn allowlisted_case_paths() -> Vec<(String, PathBuf)> {
    let allowlist = Path::new(env!("CARGO_MANIFEST_DIR")).join(ALLOWLIST_PATH);
    assert!(
        allowlist.exists(),
        "missing allowlist file: {}",
        allowlist.display()
    );
    let case_ids = read_lines(&allowlist);
    assert!(
        !case_ids.is_empty(),
        "allowlist must include at least one case"
    );

    case_ids
        .into_iter()
        .map(|case_id| {
            let case_path = fixture_case_path(&case_id);
            assert!(
                case_path.exists(),
                "fixture case directory missing for {} ({})",
                case_id,
                case_path.display()
            );
            (case_id, case_path)
        })
        .collect()
}

fn all_case_paths() -> Vec<(String, PathBuf)> {
    let root = fixture_root();
    let mut entries: Vec<(String, PathBuf)> = Vec::new();
    for entry in
        fs::read_dir(&root).unwrap_or_else(|e| panic!("failed to read {}: {e}", root.display()))
    {
        let entry = entry.unwrap_or_else(|e| panic!("failed to read dir entry: {e}"));
        let path = entry.path();
        if !path.is_dir() {
            continue;
        }
        let name = path
            .file_name()
            .and_then(|s| s.to_str())
            .expect("valid UTF-8 case id")
            .to_string();
        if path.join("in.yaml").exists() {
            entries.push((name, path));
            continue;
        }
        // No in.yaml at top level. The yaml-test-suite layout uses two kinds
        // of non-leaf directories: 4-char hashed parents that hold numbered
        // subcases (e.g. `2G84/00/in.yaml`), and `name/` / `tags/` index
        // directories built from symlinks back to the hashed parents.
        // Recurse only into the hashed parents to avoid double-counting.
        let is_hashed_parent = name.len() == 4
            && name
                .chars()
                .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit());
        if !is_hashed_parent {
            continue;
        }
        for sub in
            fs::read_dir(&path).unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display()))
        {
            let sub = sub.unwrap_or_else(|e| panic!("failed to read dir entry: {e}"));
            let sub_path = sub.path();
            if !sub_path.is_dir() || !sub_path.join("in.yaml").exists() {
                continue;
            }
            let sub_name = sub_path
                .file_name()
                .and_then(|s| s.to_str())
                .expect("valid UTF-8 subcase name");
            entries.push((format!("{name}/{sub_name}"), sub_path));
        }
    }
    entries.sort_by(|a, b| a.0.cmp(&b.0));
    entries
}

fn fixture_case_events(case_path: &Path) -> Vec<String> {
    let event_path = case_path.join("test.event");
    let event_text = fs::read_to_string(&event_path)
        .unwrap_or_else(|e| panic!("failed to read {}: {e}", event_path.display()));
    // Strip only the line ending, not trailing spaces: yaml-test-suite events
    // can carry significant trailing whitespace (e.g. a folded scalar whose
    // value ends in a space, `=VAL " ... non-empty `), and the projected event
    // stream reproduces it. `lines()` already drops `\n`/`\r\n`; event lines
    // never carry leading indentation, so no further trimming is needed.
    event_text
        .lines()
        .filter(|line| !line.is_empty())
        .map(ToOwned::to_owned)
        .collect()
}

fn cst_text(node: &ParserSyntaxNode) -> String {
    format!("{:#?}\n", node)
}

#[test]
#[ignore = "manual triage report generation"]
fn yaml_suite_generate_triage_report() {
    let mut passes_now = Vec::new();
    let mut error_contract_ok = Vec::new();
    let mut fails_needs_feature = Vec::new();
    let mut fails_needs_error_path = Vec::new();

    for (case_id, case_path) in all_case_paths() {
        let in_yaml = case_path.join("in.yaml");
        if !in_yaml.exists() {
            continue;
        }

        let input = fs::read_to_string(&in_yaml)
            .unwrap_or_else(|e| panic!("failed to read {}: {e}", in_yaml.display()));
        let error_contract = case_path.join("error").exists();
        let has_test_event = case_path.join("test.event").exists();
        let report = parse_yaml_report(&input);

        let event_parity = if has_test_event {
            let expected = fixture_case_events(&case_path);
            let actual = std::panic::catch_unwind(|| project_events(&input));
            actual.ok().map(|events| events == expected)
        } else {
            None
        };

        if !error_contract {
            if report.tree.is_some() && event_parity == Some(true) {
                passes_now.push(case_id);
            } else {
                fails_needs_feature.push(json!({
                    "case_id": case_id,
                    "tree": report.tree.is_some(),
                    "event_parity": event_parity,
                    "diagnostic_codes": report
                        .diagnostics
                        .iter()
                        .map(|d| d.code)
                        .collect::<Vec<_>>(),
                }));
            }
            continue;
        }

        if report.tree.is_none() && !report.diagnostics.is_empty() {
            error_contract_ok.push(json!({
                "case_id": case_id,
                "diagnostic_codes": report
                    .diagnostics
                    .iter()
                    .map(|d| d.code)
                    .collect::<Vec<_>>(),
                "event_parity": event_parity,
            }));
        } else {
            fails_needs_error_path.push(json!({
                "case_id": case_id,
                "tree": report.tree.is_some(),
                "diagnostic_codes": report
                    .diagnostics
                    .iter()
                    .map(|d| d.code)
                    .collect::<Vec<_>>(),
                "event_parity": event_parity,
            }));
        }
    }

    let triage = json!({
        "summary": {
            "total_cases": all_case_paths().len(),
            "passes_now_count": passes_now.len(),
            "error_contract_ok_count": error_contract_ok.len(),
            "fails_needs_feature_count": fails_needs_feature.len(),
            "fails_needs_error_path_count": fails_needs_error_path.len(),
        },
        "passes_now": passes_now,
        "error_contract_ok": error_contract_ok,
        "fails_needs_feature": fails_needs_feature,
        "fails_needs_error_path": fails_needs_error_path,
    });

    let out_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/yaml/triage.json");
    fs::write(
        &out_path,
        serde_json::to_string_pretty(&triage)
            .unwrap_or_else(|e| panic!("failed to serialize triage JSON: {e}")),
    )
    .unwrap_or_else(|e| panic!("failed to write {}: {e}", out_path.display()));
}

#[test]
fn yaml_allowlist_cases_snapshot() {
    let fixture_root = fixture_root();
    assert!(
        fixture_root.exists(),
        "yaml-test-suite fixtures missing; run `task update-yaml-fixtures`"
    );

    for (case_id, case_path) in allowlisted_case_paths() {
        let in_yaml = case_path.join("in.yaml");
        let test_event = case_path.join("test.event");
        let error_file = case_path.join("error");
        assert!(
            test_event.exists(),
            "allowlisted case {} must include test.event ({})",
            case_id,
            test_event.display()
        );
        let input = fs::read_to_string(&in_yaml).unwrap_or_else(|e| {
            panic!(
                "failed to read case {} ({}): {e}",
                case_id,
                in_yaml.display()
            )
        });

        let has_error_contract = error_file.exists();
        let report = parse_yaml_report(&input);
        let parsed = report.tree.is_some();
        let diagnostic_snapshot = report
            .diagnostics
            .iter()
            .map(|d| format!("{}:{}@{}..{}", d.code, d.message, d.byte_start, d.byte_end))
            .collect::<Vec<_>>()
            .join(", ");
        let snapshot = format!(
            "case_id: {case_id}\ninput: {input:?}\nhas_error_contract: {has_error_contract}\nparsed_mapping_tree: {parsed}\ndiagnostics: [{diagnostic_snapshot}]\n"
        );

        insta::assert_snapshot!(format!("yaml_suite_{}", case_id), snapshot);
    }
}

#[test]
fn yaml_allowlist_cases_cst_snapshot() {
    let fixture_root = fixture_root();
    assert!(
        fixture_root.exists(),
        "yaml-test-suite fixtures missing; run `task update-yaml-fixtures`"
    );

    for (case_id, case_path) in allowlisted_case_paths() {
        let in_yaml = case_path.join("in.yaml");
        let input = fs::read_to_string(&in_yaml).unwrap_or_else(|e| {
            panic!(
                "failed to read case {} ({}): {e}",
                case_id,
                in_yaml.display()
            )
        });

        let report = parse_yaml_report(&input);
        let diagnostics = report
            .diagnostics
            .iter()
            .map(|d| format!("{}:{}@{}..{}", d.code, d.message, d.byte_start, d.byte_end))
            .collect::<Vec<_>>()
            .join("\n");
        let cst = report.tree.as_ref().map(cst_text).unwrap_or_default();
        let snapshot = format!(
            "case_id: {case_id}\ninput: {input:?}\ndiagnostics:\n{diagnostics}\ncst:\n{cst}"
        );
        insta::assert_snapshot!(format!("yaml_cst_suite_{}", case_id), snapshot);
    }
}

#[test]
fn yaml_allowlist_losslessness_raw_input() {
    for (case_id, case_path) in allowlisted_case_paths() {
        let input_path = case_path.join("in.yaml");
        let error_file = case_path.join("error");
        let input = fs::read_to_string(&input_path)
            .unwrap_or_else(|e| panic!("failed to read {}: {e}", input_path.display()));
        let report = parse_yaml_report(&input);
        let tree = report.tree;

        if error_file.exists() {
            assert!(
                tree.is_none(),
                "error-contract case {} should fail YAML parse",
                case_id
            );
            assert!(
                !report.diagnostics.is_empty(),
                "error-contract case {} should provide diagnostics",
                case_id
            );
            continue;
        }

        let tree = tree.unwrap_or_else(|| panic!("failed to parse raw input for {}", case_id));
        let tree_text = tree.text().to_string();
        assert_eq!(
            input, tree_text,
            "yaml raw losslessness mismatch for {}",
            case_id
        );
    }
}

#[test]
fn yaml_allowlist_projected_event_parity() {
    for (case_id, case_path) in allowlisted_case_paths() {
        let input_path = case_path.join("in.yaml");
        let error_file = case_path.join("error");
        let input = fs::read_to_string(&input_path)
            .unwrap_or_else(|e| panic!("failed to read {}: {e}", input_path.display()));
        let expected_events = fixture_case_events(&case_path);
        let actual_events = project_events(&input);
        let report = parse_yaml_report(&input);

        if error_file.exists() {
            assert!(
                report.tree.is_none(),
                "error-contract case {} should fail YAML parse",
                case_id
            );
            assert!(
                !report.diagnostics.is_empty(),
                "error-contract case {} should provide diagnostics",
                case_id
            );
            assert_ne!(
                actual_events, expected_events,
                "error-contract case {} unexpectedly matches success event parity",
                case_id
            );
        } else {
            assert_eq!(
                actual_events, expected_events,
                "projected event stream mismatch for {}",
                case_id
            );
        }
    }
}

#[test]
fn yaml_document_start_emitted_as_dedicated_token() {
    use panache_parser::syntax::SyntaxKind;

    let report = parse_yaml_report("---\ntitle: test\n");
    let tree = report.tree.expect("should parse");

    let has_doc_start = tree
        .descendants_with_tokens()
        .filter_map(|el| el.into_token())
        .any(|tok| tok.kind() == SyntaxKind::YAML_DOCUMENT_START);
    assert!(
        has_doc_start,
        "tree should contain YAML_DOCUMENT_START token"
    );

    assert_eq!(
        tree.text().to_string(),
        "---\ntitle: test\n",
        "losslessness"
    );

    let events = project_events("---\ntitle: test\n");
    assert_eq!(
        events,
        vec![
            "+STR",
            "+DOC ---",
            "+MAP",
            "=VAL :title",
            "=VAL :test",
            "-MAP",
            "-DOC",
            "-STR"
        ]
    );
}

#[test]
fn yaml_document_end_emitted_as_dedicated_token() {
    use panache_parser::syntax::SyntaxKind;

    let report = parse_yaml_report("title: test\n...\n");
    let tree = report.tree.expect("should parse");

    let has_doc_end = tree
        .descendants_with_tokens()
        .filter_map(|el| el.into_token())
        .any(|tok| tok.kind() == SyntaxKind::YAML_DOCUMENT_END);
    assert!(has_doc_end, "tree should contain YAML_DOCUMENT_END token");

    assert_eq!(
        tree.text().to_string(),
        "title: test\n...\n",
        "losslessness"
    );
}

#[test]
fn yaml_block_sequence_scalar_items_cst() {
    use panache_parser::syntax::SyntaxKind;

    let report = parse_yaml_report("- foo\n- bar\n- 42\n");
    let tree = report.tree.expect("should parse");

    let has_seq = tree
        .descendants()
        .any(|n| n.kind() == SyntaxKind::YAML_BLOCK_SEQUENCE);
    assert!(has_seq, "tree should contain YAML_BLOCK_SEQUENCE node");

    let item_count = tree
        .descendants()
        .filter(|n| n.kind() == SyntaxKind::YAML_BLOCK_SEQUENCE_ITEM)
        .count();
    assert_eq!(item_count, 3, "should have 3 sequence items");

    assert_eq!(
        tree.text().to_string(),
        "- foo\n- bar\n- 42\n",
        "losslessness"
    );
}

#[test]
fn yaml_block_sequence_scalar_projected_events() {
    let events = project_events("- foo\n- bar\n- 42\n");
    assert_eq!(
        events,
        vec![
            "+STR",
            "+DOC",
            "+SEQ",
            "=VAL :foo",
            "=VAL :bar",
            "=VAL :42",
            "-SEQ",
            "-DOC",
            "-STR"
        ]
    );
}

#[test]
fn yaml_block_sequence_single_item() {
    let events = project_events("- foo\n");
    assert_eq!(
        events,
        vec!["+STR", "+DOC", "+SEQ", "=VAL :foo", "-SEQ", "-DOC", "-STR"]
    );
}

#[test]
fn yaml_empty_value_key_only_lossless() {
    use panache_parser::syntax::SyntaxKind;

    let report = parse_yaml_report("foo:\nbar: baz\n");
    let tree = report.tree.expect("should parse");
    assert_eq!(tree.text().to_string(), "foo:\nbar: baz\n", "losslessness");

    let entry_count = tree
        .descendants()
        .filter(|n| n.kind() == SyntaxKind::YAML_BLOCK_MAP_ENTRY)
        .count();
    assert_eq!(entry_count, 2, "should have 2 map entries");
}

#[test]
fn yaml_nested_map_lossless() {
    use panache_parser::syntax::SyntaxKind;

    let report = parse_yaml_report("foo:\n  bar: baz\n");
    let tree = report.tree.expect("should parse");
    assert_eq!(
        tree.text().to_string(),
        "foo:\n  bar: baz\n",
        "losslessness"
    );

    let outer = tree
        .descendants()
        .find(|n| n.kind() == SyntaxKind::YAML_BLOCK_MAP_ENTRY)
        .expect("outer entry");
    let outer_key = outer
        .children()
        .find(|n| n.kind() == SyntaxKind::YAML_BLOCK_MAP_KEY)
        .expect("outer key");
    let key_text = outer_key
        .children()
        .find(|n| n.kind() == SyntaxKind::YAML_SCALAR)
        .expect("key scalar")
        .text()
        .to_string();
    assert_eq!(key_text, "foo");
}

#[test]
fn yaml_nested_map_projected_events() {
    let events = project_events("foo:\n  bar: baz\n");
    assert_eq!(
        events,
        vec![
            "+STR",
            "+DOC",
            "+MAP",
            "=VAL :foo",
            "+MAP",
            "=VAL :bar",
            "=VAL :baz",
            "-MAP",
            "-MAP",
            "-DOC",
            "-STR"
        ]
    );
}

#[test]
fn yaml_flat_map_with_empty_value_projected_events() {
    let events = project_events("foo:\nbar: baz\n");
    assert_eq!(
        events,
        vec![
            "+STR",
            "+DOC",
            "+MAP",
            "=VAL :foo",
            "=VAL :",
            "=VAL :bar",
            "=VAL :baz",
            "-MAP",
            "-DOC",
            "-STR"
        ]
    );
}

/// Pins the post-PR-C empty-stream contract: inputs that contain only
/// trivia (comments / whitespace / a bare `...` marker) produce a
/// `YAML_STREAM` with no `YAML_DOCUMENT` children, which the events
/// projection emits as just `+STR -STR`. Regressing this would re-introduce
/// the comment-only-document bug fixed while building the YAML parser's
/// empty-stream handling.
#[test]
fn yaml_empty_stream_projects_no_document_events() {
    for input in ["# Comment only.\n", "...\n", "# comment\n...\n"] {
        let events = project_events(input);
        assert_eq!(
            events,
            vec!["+STR", "-STR"],
            "input {input:?} should project to an empty stream"
        );
    }
}
