from __future__ import annotations

import ast
import hashlib
import re
from pathlib import Path


APP_ROOT = Path(__file__).resolve().parents[1]
ORIGINAL_ROOT = APP_ROOT.parent
FRONTEND_ROOT = APP_ROOT / "frontend"
ORIGINAL_STATIC = ORIGINAL_ROOT / "static"

SOURCE_RANGE = re.compile(
    r"/\* PRESERVATION-SOURCE-BEGIN app\.js:(\d+)-(\d+) \*/\n"
    r"(.*?)"
    r"/\* PRESERVATION-SOURCE-END app\.js:\1-\2 \*/\n?",
    re.DOTALL,
)

# These exact original app.js line ranges were retired in slice 11 after the
# definition-only symbols passed static, runtime, and compatibility review.
RETIRED_FRONTEND_SOURCE_RANGES = (
    (3537, 3540),
    (4139, 4145),
    (4973, 4989),
    (9022, 9027),
    (9052, 9055),
)
AUDITED_FRONTEND_SOURCE_LINE_COUNT = 9283

# CR-12 and CR-13 remove only declarations repeated by a later stylesheet
# layer under the same cascade context. Entries are either an exact retired
# fragment or an exact (source, replacement) pair when surrounding context is
# needed to identify one nested occurrence. Every other byte remains part of
# the accepted CSS baseline.
AUDITED_CSS_RETIREMENTS = {
    "styles.css": (
        ".workspace-view.active-view {\n  display: block;\n}\n\n",
        "body.sidebar-collapsed {\n  grid-template-columns: 64px minmax(0, 1fr);\n}\n\n",
        ".rotation-day:last-child { border-right: 0; }\n\n",
        (
            "#screenerView .probability-value strong,\n"
            "#screenerView .probability-value small {\n"
            "  display: block;\n"
            "}\n\n"
        ),
        (
            (
                "@media (min-width: 721px) and (max-width: 1279px) {\n"
                "  .market-tape {\n"
                "    display: none;\n"
                "  }\n\n"
                "  .app-header {"
            ),
            (
                "@media (min-width: 721px) and (max-width: 1279px) {\n"
                "  .app-header {"
            ),
        ),
        (
            "  .module-nav .nav-brand,\n"
            "  .module-nav .nav-group-label,\n"
            "  .module-nav .market-sub-tab,\n"
            "  .module-nav .sidebar-collapse-button {\n"
            "    display: none;\n"
            "  }\n\n"
        ),
        (
            "  .module-nav .nav-group,\n"
            "  body.sidebar-collapsed .module-nav .nav-group {\n"
            "    display: contents;\n"
            "    margin: 0;\n"
            "    padding: 0;\n"
            "    border: 0;\n"
            "  }\n\n"
        ),
        (
            "  .module-nav .module-tab.mobile-primary-tab,\n"
            "  body.sidebar-collapsed .module-nav .module-tab.mobile-primary-tab {\n"
            "    display: flex;\n"
            "  }\n\n"
        ),
        "  body.mentor-directory-open {\n    overflow: hidden;\n  }\n\n",
        (
            "  body,\n"
            "  body.sidebar-collapsed {\n"
            "    display: block;\n"
            "    min-height: 100dvh;\n"
            "    padding-bottom: calc(68px + env(safe-area-inset-bottom));\n"
            "  }\n\n"
        ),
        "  .brand-block {\n    height: 42px;\n  }\n\n",
        (
            "  .header-actions {\n"
            "    position: absolute;\n"
            "    inset: 56px 10px auto;\n"
            "    display: flex;\n"
            "    justify-content: space-between;\n"
            "    gap: 6px;\n"
            "  }\n\n"
        ),
        (
            "  .header-date-group {\n"
            "    height: 42px;\n"
            "    min-width: 0;\n"
            "    flex: 1;\n"
            "  }\n\n"
        ),
        (
            "  .header-actions > .icon-button {\n"
            "    width: 40px;\n"
            "    min-width: 40px;\n"
            "    min-height: 42px;\n"
            "  }\n\n"
        ),
        "  .overview-strip .metric:nth-of-type(1) { grid-column: 2; grid-row: 1; }\n",
        "  .overview-strip .metric:nth-of-type(3) { grid-column: 2; grid-row: 2; }\n",
        "  .curated-strategy-list {\n    grid-template-columns: 1fr;\n  }\n\n",
    ),
    "renovation.css": (
        ".overview-strip .sentiment-block { padding-left: 0; }\n",
        (
            '.overview-strip[data-overview-expanded="true"] .metric-value '
            "{ font-size: 18px; }\n\n"
        ),
    ),
    "redesign-v2.css": (
        "#dragonView .dragon-operation-table .dragon-col-reason { width: auto; }\n",
        (
            "  .market-tape { display: none; }\n"
            "  .header-actions { width: 100%; }\n"
            "  .header-command-group { position: absolute; }\n"
        ),
        (
            "  .sidebar-brand,\n"
            "  .module-nav .nav-group-label,\n"
            "  .sidebar-collapse-button,\n"
            "  .module-nav .market-sub-tab { display: none; }\n"
            "  .module-nav .nav-group,\n"
            "  body.sidebar-collapsed .module-nav .nav-group { display: contents; }\n"
            "  .module-nav .module-tab,\n"
            "  body.sidebar-collapsed .module-nav .module-tab { display: none; }\n"
        ),
        "  .module-nav .module-tab.mobile-primary-tab span { display: inline; }\n",
        (
            "  .overview-strip .metric:nth-of-type(n + 4),\n"
            "  .overview-strip .metric-wide { display: none; }\n"
            "  .overview-toggle { display: none; }\n"
        ),
    ),
}


def sha256(path: Path) -> str:
    return hashlib.sha256(path.read_bytes()).hexdigest()


def function_contract(path: Path, name: str) -> tuple[str, str]:
    tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
    function = next(
        node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == name
    )
    body = ast.Module(body=function.body, type_ignores=[])
    return (
        ast.dump(function.args, include_attributes=False),
        ast.dump(body, include_attributes=False),
    )


def module_contract(
    path: Path,
    *,
    excluded_definitions: set[str] | None = None,
    excluded_import_modules: set[str] | None = None,
    exclude_imports: bool = False,
) -> str:
    excluded_definitions = excluded_definitions or set()
    excluded_import_modules = excluded_import_modules or set()
    tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
    tree.body = [
        node
        for node in tree.body
        if not (exclude_imports and isinstance(node, (ast.Import, ast.ImportFrom)))
        and not (
            isinstance(node, ast.ImportFrom)
            and node.module in excluded_import_modules
        )
        and getattr(node, "name", None) not in excluded_definitions
    ]
    return ast.dump(tree, include_attributes=False)


def reassembled_frontend_runtime() -> str:
    chunks: dict[tuple[int, int], str] = {}
    for path in FRONTEND_ROOT.rglob("*.js"):
        for match in SOURCE_RANGE.finditer(path.read_text(encoding="utf-8")):
            source_range = (int(match.group(1)), int(match.group(2)))
            if source_range in chunks:
                raise AssertionError(f"duplicate app.js source range: {source_range}")
            chunks[source_range] = match.group(3)

    assembled: list[str] = []
    next_line = 1
    for (start, end), content in sorted(chunks.items()):
        if start != next_line:
            raise AssertionError(
                f"app.js source coverage gap: expected line {next_line}, got {start}"
            )
        assembled.append(content)
        next_line = end + 1

    if next_line != AUDITED_FRONTEND_SOURCE_LINE_COUNT + 1:
        raise AssertionError(
            "app.js source coverage ended at "
            f"{next_line - 1}, expected {AUDITED_FRONTEND_SOURCE_LINE_COUNT}"
        )
    return "".join(assembled)


def original_runtime_after_audited_retirements() -> str:
    lines = (ORIGINAL_STATIC / "app.js").read_text(encoding="utf-8").splitlines(
        keepends=True
    )
    retired = {
        line_number
        for start, end in RETIRED_FRONTEND_SOURCE_RANGES
        for line_number in range(start, end + 1)
    }
    return "".join(
        line for line_number, line in enumerate(lines, start=1) if line_number not in retired
    )


def assert_frontend_runtime_matches_audited_baseline(testcase) -> None:
    testcase.assertEqual(
        reassembled_frontend_runtime(),
        original_runtime_after_audited_retirements(),
    )


def assert_moved_asset_matches(
    testcase,
    original_relative: str,
    frontend_relative: str | None = None,
) -> None:
    target_relative = frontend_relative or original_relative
    if original_relative in AUDITED_CSS_RETIREMENTS:
        original = (ORIGINAL_STATIC / original_relative).read_text(encoding="utf-8")
        for retired in AUDITED_CSS_RETIREMENTS[original_relative]:
            source, replacement = retired if isinstance(retired, tuple) else (retired, "")
            testcase.assertEqual(original.count(source), 1, source)
            original = original.replace(source, replacement, 1)
        testcase.assertEqual(
            (FRONTEND_ROOT / target_relative).read_text(encoding="utf-8"),
            original,
            original_relative,
        )
        return
    testcase.assertEqual(
        sha256(FRONTEND_ROOT / target_relative),
        sha256(ORIGINAL_STATIC / original_relative),
        original_relative,
    )


def assert_page_prefix_matches(testcase, page_relative: str) -> None:
    original = (ORIGINAL_STATIC / page_relative).read_text(encoding="utf-8")
    migrated = (FRONTEND_ROOT / page_relative).read_text(encoding="utf-8")
    testcase.assertTrue(
        migrated.startswith(original.rstrip("\n") + "\n\n"),
        page_relative,
    )
