"""Tier calibration integrity tests for assessment content generators.

These tests are pure logic/config validation against markdown source files.
They do not perform any Canvas writes.
"""

from __future__ import annotations

import json
import re
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path

import pytest


PLUGIN_ROOT = Path(__file__).resolve().parent.parent
STANDARD_PATH = PLUGIN_ROOT / "standards" / "assessment-best-practices.md"
TEMPLATE_PATH = PLUGIN_ROOT / "templates" / "course-config.json"
FIXTURE_ROOT = Path("/tmp/asucb-tier-fixtures")

TIER_HEADERS = ("introductory", "advanced", "graduate")
SKILL_PATHS = {
    "quiz-generator": PLUGIN_ROOT / "skills" / "quiz-generator" / "SKILL.md",
    "assignment-generator": PLUGIN_ROOT / "skills" / "assignment-generator" / "SKILL.md",
    "discussion-generator": PLUGIN_ROOT / "skills" / "discussion-generator" / "SKILL.md",
    "rubric-creator": PLUGIN_ROOT / "skills" / "rubric-creator" / "SKILL.md",
}


@dataclass(frozen=True)
class MarkdownRow:
    label: str
    line_no: int
    cells: dict[str, str]


@dataclass(frozen=True)
class MarkdownTable:
    path: Path
    section: str
    header_line: int
    headers: tuple[str, ...]
    rows: tuple[MarkdownRow, ...]


def _clean_text(value: str) -> str:
    value = value.strip()
    value = re.sub(r"`+", "", value)
    value = re.sub(r"\*\*", "", value)
    value = value.replace("→", " -> ")
    value = value.replace("–", "-").replace("—", "-")
    value = re.sub(r"\s+", " ", value)
    return value.strip()


def _slug(value: str) -> str:
    cleaned = _clean_text(value).lower()
    return re.sub(r"[^a-z0-9]+", " ", cleaned).strip()


def _tier_name_from_header(header: str) -> str:
    slug = _slug(header)
    for tier in TIER_HEADERS:
        if slug.startswith(tier):
            return tier
    return slug


def _headers_to_tiers(headers: tuple[str, ...]) -> tuple[str, ...]:
    return tuple(_tier_name_from_header(header) for header in headers[1:4])


def _parse_table_row(line: str) -> list[str]:
    stripped = line.strip()
    if not stripped.startswith("|") or not stripped.endswith("|"):
        return []
    return [_clean_text(cell) for cell in stripped.strip("|").split("|")]


def _is_divider(line: str) -> bool:
    stripped = line.strip()
    if not stripped.startswith("|") or not stripped.endswith("|"):
        return False
    cells = [cell.strip() for cell in stripped.strip("|").split("|")]
    return all(cells) and all(set(cell) <= {"-", ":"} for cell in cells)


@lru_cache(maxsize=None)
def _read_lines(path: Path) -> tuple[str, ...]:
    return tuple(path.read_text(encoding="utf-8").splitlines())


@lru_cache(maxsize=None)
def parse_markdown_tables(path: Path) -> tuple[MarkdownTable, ...]:
    lines = _read_lines(path)
    tables: list[MarkdownTable] = []
    current_section = "ROOT"
    index = 0

    while index < len(lines):
        line = lines[index]
        if line.startswith("#"):
            current_section = _clean_text(line.lstrip("#").strip())
        if "|" in line and line.strip().startswith("|"):
            header_cells = _parse_table_row(line)
            if header_cells and index + 1 < len(lines) and _is_divider(lines[index + 1]):
                rows: list[MarkdownRow] = []
                row_index = index + 2
                while row_index < len(lines):
                    row_line = lines[row_index]
                    if "|" not in row_line or not row_line.strip().startswith("|"):
                        break
                    if _is_divider(row_line):
                        break
                    row_cells = _parse_table_row(row_line)
                    if row_cells:
                        label = row_cells[0]
                        cells = {
                            _tier_name_from_header(header_cells[col_idx]): row_cells[col_idx]
                            if col_idx < len(row_cells)
                            else ""
                            for col_idx in range(1, min(len(header_cells), 4))
                        }
                        rows.append(MarkdownRow(label=label, line_no=row_index + 1, cells=cells))
                    row_index += 1

                tables.append(
                    MarkdownTable(
                        path=path,
                        section=current_section,
                        header_line=index + 1,
                        headers=tuple(header_cells),
                        rows=tuple(rows),
                    )
                )
                index = row_index
                continue
        index += 1

    return tuple(tables)


def _find_table(path: Path, section_name: str) -> MarkdownTable:
    for table in parse_markdown_tables(path):
        if _slug(table.section) == _slug(section_name):
            return table
    raise AssertionError(f"Missing table for section '{section_name}' in {path}")


def _find_tier_table(path: Path) -> MarkdownTable:
    for table in parse_markdown_tables(path):
        if _headers_to_tiers(table.headers) == TIER_HEADERS:
            return table
    raise AssertionError(f"Missing tier table with Introductory/Advanced/Graduate columns in {path}")


def _row_map(table: MarkdownTable) -> dict[str, MarkdownRow]:
    return {_slug(row.label): row for row in table.rows}


def _standard_column_as_row(table: MarkdownTable, column_label: str) -> MarkdownRow:
    header_lookup = {_slug(header): idx for idx, header in enumerate(table.headers)}
    key = _slug(column_label)
    if key not in header_lookup:
        raise AssertionError(f"Missing column '{column_label}' in {table.path}:{table.header_line}")

    column_index = header_lookup[key]
    cells: dict[str, str] = {}
    for row in table.rows:
        tier = _slug(row.label)
        if tier in TIER_HEADERS:
            row_cells = _parse_table_row(_read_lines(table.path)[row.line_no - 1])
            cells[tier] = row_cells[column_index] if column_index < len(row_cells) else ""

    return MarkdownRow(label=column_label, line_no=table.header_line, cells=cells)


def _section_text(path: Path, heading: str) -> tuple[str, int]:
    lines = _read_lines(path)
    start = None
    for index, line in enumerate(lines):
        if _clean_text(line) == _clean_text(heading):
            start = index
            break
    if start is None:
        raise AssertionError(f"Missing heading '{heading}' in {path}")

    end = len(lines)
    for index in range(start + 1, len(lines)):
        if lines[index].startswith("## "):
            end = index
            break
    return ("\n".join(lines[start:end]), start + 1)


def _line_number(path: Path, needle: str) -> int:
    for index, line in enumerate(_read_lines(path), start=1):
        if needle in line:
            return index
    raise AssertionError(f"Could not find '{needle}' in {path}")


def _assert_equal_to_standard(
    issues: list[str],
    skill_row: MarkdownRow,
    standard_row: MarkdownRow,
    *,
    skill_label: str,
    standard_label: str,
) -> None:
    for tier in TIER_HEADERS:
        skill_value = _clean_text(skill_row.cells[tier])
        standard_value = _clean_text(standard_row.cells[tier])
        if _slug(skill_value) != _slug(standard_value):
            issues.append(
                f"{skill_row.line_no}: {skill_label} {tier} != canonical '{standard_label}' "
                f"(skill: {skill_value!r}; standard {STANDARD_PATH}:{standard_row.line_no}: {standard_value!r})"
            )


@pytest.fixture(scope="session")
def tier_fixture_dir() -> Path:
    template = json.loads(TEMPLATE_PATH.read_text(encoding="utf-8"))
    FIXTURE_ROOT.mkdir(parents=True, exist_ok=True)

    fixture_data = {
        "introductory": {"course_code": "BIO 101", "level": "100"},
        "advanced": {"course_code": "ENG 320", "level": "300"},
        "graduate": {"course_code": "MGT 560", "level": "500"},
    }

    for tier, metadata in fixture_data.items():
        payload = json.loads(json.dumps(template))
        payload["course"]["title"] = f"Tier Fixture - {tier.title()}"
        payload["course"]["level"] = metadata["level"]
        payload["course"]["tier"] = tier
        payload["course"]["department"] = "Test"
        payload["course"]["discipline"] = "Test"
        payload["canvas"]["course_code"] = metadata["course_code"]

        target_dir = FIXTURE_ROOT / tier
        target_dir.mkdir(parents=True, exist_ok=True)
        target = target_dir / "course-config.json"
        target.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")

    return FIXTURE_ROOT


def test_tier_course_config_fixtures_created(tier_fixture_dir: Path):
    expected = {
        "introductory": ("100", "BIO 101"),
        "advanced": ("300", "ENG 320"),
        "graduate": ("500", "MGT 560"),
    }

    for tier, (level, course_code) in expected.items():
        config_path = tier_fixture_dir / tier / "course-config.json"
        assert config_path.exists(), f"Missing fixture {config_path}"
        data = json.loads(config_path.read_text(encoding="utf-8"))
        assert data["course"]["tier"] == tier
        assert data["course"]["level"] == level
        assert data["canvas"]["course_code"] == course_code


@pytest.mark.parametrize("skill_slug", sorted(SKILL_PATHS))
def test_skill_tier_table_has_all_three_columns(skill_slug: str):
    table = _find_tier_table(SKILL_PATHS[skill_slug])
    assert _headers_to_tiers(table.headers) == TIER_HEADERS, (
        f"{table.path}:{table.header_line} tier table must include Introductory/Advanced/Graduate columns"
    )


@pytest.mark.parametrize("skill_slug", sorted(SKILL_PATHS))
def test_skill_tier_table_has_no_empty_cells(skill_slug: str):
    table = _find_tier_table(SKILL_PATHS[skill_slug])
    issues = []
    for row in table.rows:
        for tier in TIER_HEADERS:
            value = row.cells.get(tier, "")
            if not _clean_text(value):
                issues.append(f"{table.path}:{row.line_no} row '{row.label}' has empty {tier} cell")

    assert not issues, "\n".join(issues)


@pytest.mark.parametrize(
    ("skill_slug", "row_label"),
    [
        ("quiz-generator", "Practice Bloom's range"),
        ("quiz-generator", "Graded Bloom's range"),
        ("assignment-generator", "Default Bloom's level"),
        ("discussion-generator", "Default types"),
        ("rubric-creator", "Exemplary"),
    ],
)
def test_tier_calibration_changes_behavior_across_tiers(skill_slug: str, row_label: str):
    table = _find_tier_table(SKILL_PATHS[skill_slug])
    row = _row_map(table)[_slug(row_label)]
    values = [_slug(row.cells[tier]) for tier in TIER_HEADERS]
    assert len(set(values)) == 3, (
        f"{table.path}:{row.line_no} row '{row.label}' must vary across Introductory/Advanced/Graduate tiers"
    )


def test_quiz_generator_matches_canonical_tier_tables():
    skill_table = _find_tier_table(SKILL_PATHS["quiz-generator"])
    skill_rows = _row_map(skill_table)
    bloom_table = _find_table(STANDARD_PATH, "Tier-Specific Bloom's Mapping")
    params_table = _find_table(STANDARD_PATH, "Tier-Specific Assessment Parameters")
    params_rows = _row_map(params_table)

    row_pairs = [
        ("Practice Bloom's range", _standard_column_as_row(bloom_table, "Quiz (Practice)")),
        ("Graded Bloom's range", _standard_column_as_row(bloom_table, "Quiz (Graded)")),
        ("Practice question mix (5 Qs)", params_rows[_slug("Quiz question mix (5 Qs, Practice)")]),
        ("Graded question mix (5 Qs)", params_rows[_slug("Quiz question mix (5 Qs, Graded)")]),
        ("Scenario complexity", params_rows[_slug("Quiz scenario complexity")]),
        ("Feedback style", params_rows[_slug("Feedback style")]),
    ]

    issues: list[str] = []
    for skill_label, standard_row in row_pairs:
        skill_row = skill_rows[_slug(skill_label)]
        _assert_equal_to_standard(
            issues,
            skill_row,
            standard_row,
            skill_label=skill_label,
            standard_label=standard_row.label,
            )

    distractor_row = skill_rows[_slug("Distractor design")]
    distractor_standard = params_rows[_slug("Distractor design")]
    for tier in TIER_HEADERS:
        skill_value = _slug(distractor_row.cells[tier].removeprefix("Based on "))
        standard_value = _slug(distractor_standard.cells[tier])
        if standard_value not in skill_value:
            issues.append(
                f"{SKILL_PATHS['quiz-generator']}:{distractor_row.line_no} distractor design for {tier} "
                f"must include canonical guidance {distractor_standard.cells[tier]!r}"
            )

    assert not issues, "\n".join(f"{SKILL_PATHS['quiz-generator']}:{issue}" for issue in issues)


def test_assignment_generator_matches_canonical_tier_defaults():
    skill_path = SKILL_PATHS["assignment-generator"]
    skill_table = _find_tier_table(skill_path)
    skill_rows = _row_map(skill_table)
    bloom_table = _find_table(STANDARD_PATH, "Tier-Specific Bloom's Mapping")
    params_table = _find_table(STANDARD_PATH, "Tier-Specific Assessment Parameters")
    params_rows = _row_map(params_table)

    issues: list[str] = []
    _assert_equal_to_standard(
        issues,
        skill_rows[_slug("Word count")],
        params_rows[_slug("Assignment word count")],
        skill_label="Word count",
        standard_label="Assignment word count",
    )
    _assert_equal_to_standard(
        issues,
        skill_rows[_slug("Deliverable orientation")],
        params_rows[_slug("Assignment deliverable orientation")],
        skill_label="Deliverable orientation",
        standard_label="Assignment deliverable orientation",
    )

    default_bloom_row = skill_rows[_slug("Default Bloom's level")]
    bloom_keywords = {
        "introductory": ["l3", "apply"],
        "advanced": ["l4", "analyze"],
        "graduate": ["l5", "evaluate", "l6", "create", "scholarly", "professional"],
    }
    for tier, keywords in bloom_keywords.items():
        cell = _slug(default_bloom_row.cells[tier])
        for keyword in keywords:
            if keyword not in cell:
                issues.append(
                    f"{skill_path}:{default_bloom_row.line_no} default Bloom row missing '{keyword}' for {tier}"
                )

    intro = " ".join(
        [
            skill_rows[_slug("Task description style")].cells["introductory"],
            skill_rows[_slug("Deliverable checklist")].cells["introductory"],
        ]
    )
    advanced = " ".join(
        [
            skill_rows[_slug("Task description style")].cells["advanced"],
            skill_rows[_slug("Deliverable checklist")].cells["advanced"],
        ]
    )
    graduate = " ".join(
        [
            skill_rows[_slug("Task description style")].cells["graduate"],
            skill_rows[_slug("Deliverable checklist")].cells["graduate"],
        ]
    )

    keyword_expectations = {
        "introductory": ["step-by-step", "checklist"],
        "advanced": ["goal-and-constraints", "determine approach"],
        "graduate": ["no steps", "no checklist", "determines scope"],
    }
    combined_by_tier = {
        "introductory": intro,
        "advanced": advanced,
        "graduate": graduate,
    }
    for tier, keywords in keyword_expectations.items():
        combined = _slug(combined_by_tier[tier])
        for keyword in keywords:
            if _slug(keyword) not in combined:
                issues.append(
                    f"{skill_path}:{skill_rows[_slug('Task description style')].line_no} "
                    f"missing canonical assignment scaffolding cue '{keyword}' for {tier}"
                )

    assert not issues, "\n".join(issues)


def test_assignment_word_count_ranges_are_distinct_and_canonical():
    table = _find_tier_table(SKILL_PATHS["assignment-generator"])
    row = _row_map(table)[_slug("Word count")]
    expected = {
        "introductory": "300-500 words (or 2-4 min video)",
        "advanced": "500-800 words (or 4-7 min video)",
        "graduate": "800-1,200 words (or 8-12 min presentation); research proposals and policy briefs may exceed this",
    }

    issues = []
    for tier, expected_value in expected.items():
        actual = _clean_text(row.cells[tier])
        if _slug(actual) != _slug(expected_value):
            issues.append(
                f"{table.path}:{row.line_no} {tier} word count expected {expected_value!r}, got {actual!r}"
            )

    assert len({_slug(row.cells[tier]) for tier in TIER_HEADERS}) == 3, (
        f"{table.path}:{row.line_no} assignment word count must differ across all three tiers"
    )
    assert not issues, "\n".join(issues)


def test_discussion_generator_matches_canonical_tier_defaults():
    skill_path = SKILL_PATHS["discussion-generator"]
    skill_table = _find_tier_table(skill_path)
    skill_rows = _row_map(skill_table)
    bloom_table = _find_table(STANDARD_PATH, "Tier-Specific Bloom's Mapping")
    params_table = _find_table(STANDARD_PATH, "Tier-Specific Assessment Parameters")
    params_rows = _row_map(params_table)

    issues: list[str] = []
    _assert_equal_to_standard(
        issues,
        skill_rows[_slug("Initial post word count")],
        params_rows[_slug("Discussion initial post")],
        skill_label="Initial post word count",
        standard_label="Discussion initial post",
    )
    _assert_equal_to_standard(
        issues,
        skill_rows[_slug("Peer response word count")],
        params_rows[_slug("Discussion peer response")],
        skill_label="Peer response word count",
        standard_label="Discussion peer response",
    )

    default_types_row = skill_rows[_slug("Default types")]
    default_type_keywords = {
        "introductory": ["type e", "type a", "type b"],
        "advanced": ["type b", "type c", "type d"],
        "graduate": ["type c", "type d", "peer critique"],
    }
    for tier, keywords in default_type_keywords.items():
        cell = _slug(default_types_row.cells[tier])
        for keyword in keywords:
            if keyword not in cell:
                issues.append(
                    f"{skill_path}:{default_types_row.line_no} default types row missing '{keyword}' for {tier}"
                )

    keyword_expectations = {
        "Prompt design": {
            "introductory": ["guiding sub-questions", "sentence starters"],
            "advanced": ["single open-ended question", "analytical structure"],
            "graduate": ["no sub-questions", "analytical lens"],
        },
        "Peer engagement": {
            "introductory": ["agreeing", "disagreeing with evidence"],
            "advanced": ["engage substantively"],
            "graduate": ["methodological assumptions", "theoretical framing"],
        },
    }

    for row_label, per_tier_keywords in keyword_expectations.items():
        row = skill_rows[_slug(row_label)]
        for tier, keywords in per_tier_keywords.items():
            cell = _slug(row.cells[tier])
            for keyword in keywords:
                if _slug(keyword) not in cell:
                    issues.append(
                        f"{skill_path}:{row.line_no} row '{row.label}' missing canonical cue "
                        f"'{keyword}' for {tier}"
                    )

    assert not issues, "\n".join(issues)


def test_discussion_recommended_progression_stays_within_tier_band():
    path = SKILL_PATHS["discussion-generator"]
    lines = _read_lines(path)
    expected = {
        "Introductory (100-299):": {"a", "b", "e"},
        "Advanced (300-499):": {"b", "c", "d"},
        "Graduate (500+):": {"c", "d"},
    }
    issues = []

    for heading, allowed in expected.items():
        heading_line = _line_number(path, heading)
        start = heading_line - 1
        end = len(lines)
        for index in range(start + 1, len(lines)):
            if lines[index].startswith("**"):
                end = index
                break
        block = "\n".join(lines[start:end])
        found = {match.lower() for match in re.findall(r"Type ([A-E])", block)}
        disallowed = sorted(found - allowed)
        if disallowed:
            issues.append(
                f"{path}:{heading_line} progression for {heading.rstrip(':')} uses out-of-band type(s) {disallowed}; "
                f"allowed={sorted(allowed)}"
            )

    assert not issues, "\n".join(issues)


def test_rubric_creator_matches_canonical_tier_defaults():
    skill_path = SKILL_PATHS["rubric-creator"]
    skill_table = _find_tier_table(skill_path)
    skill_rows = _row_map(skill_table)

    issues: list[str] = []
    keyword_expectations = {
        "Exemplary": {
            "introductory": ["application", "examples"],
            "advanced": ["novel", "multiple perspectives"],
            "graduate": ["scholarly", "expert judgment", "disciplinary debates"],
        },
        "Proficient": {
            "introductory": ["applies concepts", "required components"],
            "advanced": ["evidence-based reasoning", "multiple perspectives"],
            "graduate": ["scholarly", "multiple frameworks", "defensible conclusions"],
        },
        "Developing": {
            "introductory": ["gaps", "incomplete"],
            "advanced": ["surface-level", "single perspective"],
            "graduate": ["descriptive rather than evaluative", "limited synthesis"],
        },
        "Beginning": {
            "introductory": ["minimal attempt", "review"],
            "advanced": ["descriptive rather than analytical", "foundational concepts"],
            "graduate": ["scholarly or professional level", "without analysis"],
        },
    }

    for row_label, per_tier_keywords in keyword_expectations.items():
        row = skill_rows[_slug(row_label)]
        for tier, keywords in per_tier_keywords.items():
            cell = _slug(row.cells[tier])
            for keyword in keywords:
                if _slug(keyword) not in cell:
                    issues.append(
                        f"{skill_path}:{row.line_no} rubric row '{row.label}' missing '{keyword}' for {tier}"
                    )

    assert not issues, "\n".join(f"{skill_path}:{issue}" for issue in issues)


@pytest.mark.parametrize(
    ("skill_slug", "heading", "expected_phrases"),
    [
        (
            "quiz-generator",
            "## Mode 4: Regenerate Quiz",
            [
                "within tier ceiling",
                "within tier floor",
                "Keep the same Bloom's range",
                "Generate entirely new scenarios, stems, and answer choices",
                "Do not reuse any question stems or scenarios",
            ],
        ),
        (
            "assignment-generator",
            "## Mode 3: Regenerate Assignment",
            [
                "within tier ceiling",
                "within tier floor",
                "Keep same Bloom's level and word count",
                "Same objectives",
                "To go further, you'd need to change the course tier",
            ],
        ),
        (
            "discussion-generator",
            "## Mode 4: Regenerate Discussion",
            [
                "within the tier's range",
                "within the tier's range",
                "Keep the same discussion type and word count",
                "same objectives",
                "change the course tier",
            ],
        ),
    ],
)
def test_regeneration_respects_tier_boundaries_and_different_angle(skill_slug: str, heading: str, expected_phrases: list[str]):
    path = SKILL_PATHS[skill_slug]
    section, section_line = _section_text(path, heading)
    issues = []

    for phrase in expected_phrases:
        if phrase.lower() not in section.lower():
            issues.append(f"{path}:{section_line} regeneration section missing phrase {phrase!r}")

    assert not issues, "\n".join(issues)


@pytest.mark.parametrize("skill_slug", ["assignment-generator", "discussion-generator"])
def test_explicit_tier_default_lists_do_not_omit_graduate(skill_slug: str):
    path = SKILL_PATHS[skill_slug]
    issues = []
    for line_no, line in enumerate(_read_lines(path), start=1):
        if "Introductory" in line and "Advanced" in line and "Graduate" not in line:
            if "tier default" in line.lower() or "Use tier defaults" in line:
                issues.append(
                    f"{path}:{line_no} lists Introductory and Advanced tier defaults but omits Graduate"
                )

    assert not issues, "\n".join(issues)
