# -*- coding: utf-8 -*-
"""
属性测试 — 报告输出属性

Feature: repo-audit
- Property 13: 统计摘要一致性
- Property 14: 报告头部元信息
- Property 15: 写操作仅限 docs/audit/

Validates: Requirements 4.2, 4.5, 4.6, 4.7, 5.2
"""

from __future__ import annotations

import os
import re
import string
from pathlib import Path

from hypothesis import given, settings, assume
from hypothesis import strategies as st

from scripts.audit import (
    AlignmentIssue,
    Category,
    Disposition,
    DocMapping,
    FlowNode,
    InventoryItem,
)
from scripts.audit.inventory_analyzer import render_inventory_report
from scripts.audit.flow_analyzer import render_flow_report
from scripts.audit.doc_alignment_analyzer import render_alignment_report

# ---------------------------------------------------------------------------
# 共享生成器策略
# ---------------------------------------------------------------------------

_PATH_CHARS = string.ascii_letters + string.digits + "_-."

_path_segment = st.text(
    alphabet=_PATH_CHARS,
    min_size=1,
    max_size=12,
)

_rel_path = st.lists(
    _path_segment,
    min_size=1,
    max_size=3,
).map(lambda parts: "/".join(parts))

_safe_text = st.text(
    alphabet=st.characters(
        whitelist_categories=("L", "N", "P", "S", "Z"),
        blacklist_characters="|\n\r",
    ),
    min_size=1,
    max_size=30,
)

_repo_root_str = st.text(
    alphabet=string.ascii_letters + string.digits + "/_-.",
    min_size=3,
    max_size=40,
).map(lambda s: "/" + s.lstrip("/"))


# ---------------------------------------------------------------------------
# InventoryItem 生成器
# ---------------------------------------------------------------------------

def _inventory_item_st() -> st.SearchStrategy[InventoryItem]:
    return st.builds(
        InventoryItem,
        rel_path=_rel_path,
        category=st.sampled_from(list(Category)),
        disposition=st.sampled_from(list(Disposition)),
        description=_safe_text,
    )


_inventory_list = st.lists(_inventory_item_st(), min_size=0, max_size=20)


# ---------------------------------------------------------------------------
# FlowNode 生成器（限制深度和宽度）
# ---------------------------------------------------------------------------

def _flow_node_st(max_depth: int = 2) -> st.SearchStrategy[FlowNode]:
    """生成随机 FlowNode 树，限制深度避免爆炸。"""
    if max_depth <= 0:
        return st.builds(
            FlowNode,
            name=_path_segment,
            source_file=_rel_path,
            node_type=st.sampled_from(["entry", "module", "class", "function"]),
            children=st.just([]),
        )
    return st.builds(
        FlowNode,
        name=_path_segment,
        source_file=_rel_path,
        node_type=st.sampled_from(["entry", "module", "class", "function"]),
        children=st.lists(
            _flow_node_st(max_depth - 1),
            min_size=0,
            max_size=3,
        ),
    )


_flow_tree_list = st.lists(_flow_node_st(), min_size=0, max_size=5)
_orphan_list = st.lists(_rel_path, min_size=0, max_size=10)


# ---------------------------------------------------------------------------
# DocMapping / AlignmentIssue 生成器
# ---------------------------------------------------------------------------

_issue_type_st = st.sampled_from(["stale", "conflict", "missing"])


def _alignment_issue_st() -> st.SearchStrategy[AlignmentIssue]:
    return st.builds(
        AlignmentIssue,
        doc_path=_rel_path,
        issue_type=_issue_type_st,
        description=_safe_text,
        related_code=_rel_path,
    )


def _doc_mapping_st() -> st.SearchStrategy[DocMapping]:
    return st.builds(
        DocMapping,
        doc_path=_rel_path,
        doc_topic=_safe_text,
        related_code=st.lists(_rel_path, min_size=0, max_size=5),
        status=st.sampled_from(["aligned", "stale", "conflict", "orphan"]),
    )


_mapping_list = st.lists(_doc_mapping_st(), min_size=0, max_size=15)
_issue_list = st.lists(_alignment_issue_st(), min_size=0, max_size=15)


# ===========================================================================
# Property 13: 统计摘要一致性
# ===========================================================================


class TestProperty13SummaryConsistency:
    """Property 13: 统计摘要一致性

    Feature: repo-audit, Property 13: 统计摘要一致性
    Validates: Requirements 4.5, 4.6, 4.7

    对于任意报告的统计摘要，各分类/标签的计数之和应等于对应条目列表的总长度。
    """

    # --- 13a: render_inventory_report 的分类计数之和 = 列表长度 ---

    @given(items=_inventory_list)
    @settings(max_examples=100)
    def test_inventory_category_counts_sum(
        self, items: list[InventoryItem]
    ) -> None:
        """Feature: repo-audit, Property 13: 统计摘要一致性
        Validates: Requirements 4.5

        render_inventory_report 统计摘要中各用途分类的计数之和应等于条目总数。
        """
        report = render_inventory_report(items, "/tmp/repo")

        # 定位"按用途分类"表格，提取各行数字并求和
        cat_sum = _extract_summary_total(report, "按用途分类")
        assert cat_sum == len(items), (
            f"分类计数之和 {cat_sum} != 条目总数 {len(items)}"
        )

    # --- 13b: render_inventory_report 的处置标签计数之和 = 列表长度 ---

    @given(items=_inventory_list)
    @settings(max_examples=100)
    def test_inventory_disposition_counts_sum(
        self, items: list[InventoryItem]
    ) -> None:
        """Feature: repo-audit, Property 13: 统计摘要一致性
        Validates: Requirements 4.5

        render_inventory_report 统计摘要中各处置标签的计数之和应等于条目总数。
        """
        report = render_inventory_report(items, "/tmp/repo")

        disp_sum = _extract_summary_total(report, "按处置标签")
        assert disp_sum == len(items), (
            f"处置标签计数之和 {disp_sum} != 条目总数 {len(items)}"
        )

    # --- 13c: render_flow_report 的孤立模块数量 = orphans 列表长度 ---

    @given(trees=_flow_tree_list, orphans=_orphan_list)
    @settings(max_examples=100)
    def test_flow_orphan_count_matches(
        self, trees: list[FlowNode], orphans: list[str]
    ) -> None:
        """Feature: repo-audit, Property 13: 统计摘要一致性
        Validates: Requirements 4.6

        render_flow_report 统计摘要中的孤立模块数量应等于 orphans 列表长度。
        """
        report = render_flow_report(trees, orphans, "/tmp/repo")

        # 从统计摘要表格中提取"孤立模块"行的数字
        orphan_count = _extract_flow_stat(report, "孤立模块")
        assert orphan_count == len(orphans), (
            f"报告中孤立模块数 {orphan_count} != orphans 列表长度 {len(orphans)}"
        )

    # --- 13d: render_alignment_report 的 issue 类型计数一致 ---

    @given(mappings=_mapping_list, issues=_issue_list)
    @settings(max_examples=100)
    def test_alignment_issue_counts_match(
        self, mappings: list[DocMapping], issues: list[AlignmentIssue]
    ) -> None:
        """Feature: repo-audit, Property 13: 统计摘要一致性
        Validates: Requirements 4.7

        render_alignment_report 统计摘要中过期/冲突/缺失点计数应与
        issues 列表中对应类型的实际数量一致。
        """
        report = render_alignment_report(mappings, issues, "/tmp/repo")

        expected_stale = sum(1 for i in issues if i.issue_type == "stale")
        expected_conflict = sum(1 for i in issues if i.issue_type == "conflict")
        expected_missing = sum(1 for i in issues if i.issue_type == "missing")

        actual_stale = _extract_alignment_stat(report, "过期点数量")
        actual_conflict = _extract_alignment_stat(report, "冲突点数量")
        actual_missing = _extract_alignment_stat(report, "缺失点数量")

        assert actual_stale == expected_stale, (
            f"过期点: 报告 {actual_stale} != 实际 {expected_stale}"
        )
        assert actual_conflict == expected_conflict, (
            f"冲突点: 报告 {actual_conflict} != 实际 {expected_conflict}"
        )
        assert actual_missing == expected_missing, (
            f"缺失点: 报告 {actual_missing} != 实际 {expected_missing}"
        )


# ===========================================================================
# Property 14: 报告头部元信息
# ===========================================================================


class TestProperty14ReportHeader:
    """Property 14: 报告头部元信息

    Feature: repo-audit, Property 14: 报告头部元信息
    Validates: Requirements 4.2

    对于任意报告输出，头部应包含一个符合 ISO 格式的时间戳字符串和仓库根目录路径字符串。
    """

    _ISO_TS_RE = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z")

    @given(items=_inventory_list, repo_root=_repo_root_str)
    @settings(max_examples=100)
    def test_inventory_report_header(
        self, items: list[InventoryItem], repo_root: str
    ) -> None:
        """Feature: repo-audit, Property 14: 报告头部元信息
        Validates: Requirements 4.2

        render_inventory_report 头部应包含 ISO 时间戳和仓库路径。
        """
        report = render_inventory_report(items, repo_root)
        header = report[:500]

        assert self._ISO_TS_RE.search(header), (
            "inventory 报告头部缺少 ISO 格式时间戳"
        )
        assert repo_root in header, (
            f"inventory 报告头部缺少仓库路径 '{repo_root}'"
        )

    @given(trees=_flow_tree_list, orphans=_orphan_list, repo_root=_repo_root_str)
    @settings(max_examples=100)
    def test_flow_report_header(
        self, trees: list[FlowNode], orphans: list[str], repo_root: str
    ) -> None:
        """Feature: repo-audit, Property 14: 报告头部元信息
        Validates: Requirements 4.2

        render_flow_report 头部应包含 ISO 时间戳和仓库路径。
        """
        report = render_flow_report(trees, orphans, repo_root)
        header = report[:500]

        assert self._ISO_TS_RE.search(header), (
            "flow 报告头部缺少 ISO 格式时间戳"
        )
        assert repo_root in header, (
            f"flow 报告头部缺少仓库路径 '{repo_root}'"
        )

    @given(mappings=_mapping_list, issues=_issue_list, repo_root=_repo_root_str)
    @settings(max_examples=100)
    def test_alignment_report_header(
        self, mappings: list[DocMapping], issues: list[AlignmentIssue], repo_root: str
    ) -> None:
        """Feature: repo-audit, Property 14: 报告头部元信息
        Validates: Requirements 4.2

        render_alignment_report 头部应包含 ISO 时间戳和仓库路径。
        """
        report = render_alignment_report(mappings, issues, repo_root)
        header = report[:500]

        assert self._ISO_TS_RE.search(header), (
            "alignment 报告头部缺少 ISO 格式时间戳"
        )
        assert repo_root in header, (
            f"alignment 报告头部缺少仓库路径 '{repo_root}'"
        )


# ===========================================================================
# Property 15: 写操作仅限 docs/audit/
# ===========================================================================


class TestProperty15WritesOnlyDocsAudit:
    """Property 15: 写操作仅限 docs/audit/

    Feature: repo-audit, Property 15: 写操作仅限 docs/audit/
    Validates: Requirements 5.2

    对于任意审计执行过程，所有文件写操作的目标路径应以 docs/audit/ 为前缀。
    由于需要实际文件系统，使用较少迭代。
    """

    @staticmethod
    def _make_minimal_repo(base: Path, variant: int) -> Path:
        """构造最小仓库结构，variant 控制变体以增加多样性。"""
        repo = base / f"repo_{variant}"
        repo.mkdir()

        # 必需的 cli 入口
        cli_dir = repo / "cli"
        cli_dir.mkdir()
        (cli_dir / "__init__.py").write_text("", encoding="utf-8")
        (cli_dir / "main.py").write_text(
            "# -*- coding: utf-8 -*-\ndef main(): pass\n",
            encoding="utf-8",
        )

        # config 目录
        config_dir = repo / "config"
        config_dir.mkdir()
        (config_dir / "__init__.py").write_text("", encoding="utf-8")

        # docs 目录
        docs_dir = repo / "docs"
        docs_dir.mkdir()

        # 根据 variant 添加不同的额外文件
        if variant % 3 == 0:
            (repo / "README.md").write_text("# 项目\n", encoding="utf-8")
        if variant % 3 == 1:
            scripts_dir = repo / "scripts"
            scripts_dir.mkdir()
            (scripts_dir / "__init__.py").write_text("", encoding="utf-8")
        if variant % 3 == 2:
            (docs_dir / "notes.md").write_text("# 笔记\n", encoding="utf-8")

        return repo

    @staticmethod
    def _snapshot_files(repo: Path) -> dict[str, float]:
        """记录仓库中所有文件的 mtime 快照（排除 docs/audit/）。"""
        snap: dict[str, float] = {}
        for p in repo.rglob("*"):
            if p.is_file():
                rel = p.relative_to(repo).as_posix()
                if not rel.startswith("docs/audit"):
                    snap[rel] = p.stat().st_mtime
        return snap

    @given(variant=st.integers(min_value=0, max_value=9))
    @settings(max_examples=10)
    def test_writes_only_under_docs_audit(self, variant: int) -> None:
        """Feature: repo-audit, Property 15: 写操作仅限 docs/audit/
        Validates: Requirements 5.2

        运行 run_audit 后，docs/audit/ 外不应有新文件被创建。
        docs/audit/ 下应有报告文件。
        """
        import tempfile
        from scripts.audit.run_audit import run_audit

        with tempfile.TemporaryDirectory() as tmp_dir:
            tmp_path = Path(tmp_dir)
            repo = self._make_minimal_repo(tmp_path, variant)
            before_snap = self._snapshot_files(repo)

            run_audit(repo)

            # 验证 docs/audit/ 下有新文件
            audit_dir = repo / "docs" / "audit"
            assert audit_dir.is_dir(), "docs/audit/ 目录未创建"
            audit_files = list(audit_dir.iterdir())
            assert len(audit_files) > 0, "docs/audit/ 下无报告文件"

            # 验证 docs/audit/ 外无新文件
            for p in repo.rglob("*"):
                if p.is_file():
                    rel = p.relative_to(repo).as_posix()
                    if rel.startswith("docs/audit"):
                        continue
                    assert rel in before_snap, (
                        f"docs/audit/ 外出现了新文件: {rel}"
                    )


# ===========================================================================
# 辅助函数 — 从报告文本中提取统计数字
# ===========================================================================

def _extract_summary_total(report: str, section_name: str) -> int:
    """从 inventory 报告的统计摘要中提取指定分区的数字之和。

    查找 "### {section_name}" 下的 Markdown 表格，
    累加每行最后一列的数字（排除合计行）。
    """
    lines = report.split("\n")
    in_section = False
    total = 0

    for line in lines:
        stripped = line.strip()
        if stripped == f"### {section_name}":
            in_section = True
            continue
        if in_section and stripped.startswith("###"):
            # 进入下一个子节
            break
        if in_section and stripped.startswith("|") and "**合计**" not in stripped:
            # 跳过表头和分隔行
            if stripped.startswith("| 用途分类") or stripped.startswith("| 处置标签"):
                continue
            if stripped.startswith("|---"):
                continue
            # 提取最后一列的数字
            cells = [c.strip() for c in stripped.split("|") if c.strip()]
            if cells:
                try:
                    total += int(cells[-1])
                except ValueError:
                    pass

    return total


def _extract_flow_stat(report: str, label: str) -> int:
    """从 flow 报告统计摘要表格中提取指定指标的数字。"""
    # 匹配 "| 孤立模块 | 5 |" 格式
    pattern = re.compile(rf"\|\s*{re.escape(label)}\s*\|\s*(\d+)\s*\|")
    m = pattern.search(report)
    return int(m.group(1)) if m else -1


def _extract_alignment_stat(report: str, label: str) -> int:
    """从 alignment 报告统计摘要中提取指定指标的数字。

    匹配 "- 过期点数量：3" 格式。
    """
    # 兼容全角/半角冒号
    pattern = re.compile(rf"{re.escape(label)}[：:]\s*(\d+)")
    m = pattern.search(report)
    return int(m.group(1)) if m else -1
