"""Protocol compliance linter.

Based on .agent/meta/agent-protocol-rules.md v3.2.0
"""

import hashlib
import json
import re
from pathlib import Path
from typing import NamedTuple


class LintResult(NamedTuple):
    """Check result."""

    rule: str
    passed: bool
    message: str
    file: str | None = None
    line: int | None = None


class ProtocolLinter:
    """Protocol linter based on agent-protocol-rules.md."""

    # Locked directories (🔒) - read-only
    LOCKED_DIRS = [
        "core",
        "adapters",
        "meta",
        "scripts",
    ]

    # Locked files at root
    LOCKED_FILES = [
        "start-here.md",
        "manifest.json",
    ]

    # Locked skills (standard skills)
    LOCKED_SKILLS = [
        "agent-governance",
        "ai-integration",
        "guardian",
        "skill-interface.md",
    ]

    # Required files in project/
    REQUIRED_PROJECT_FILES = [
        "context.md",
        "tech-stack.md",
        "known-issues.md",
        "commands.md",
        "session-journal.md",
        "status.md",   # essential layer — AI needs current project state
        "deploy.md",   # context layer — required reading for infra tasks
    ]

    # Human-readable descriptions for each required project file (used by co scaffold)
    PROJECT_FILE_DESCRIPTIONS: dict[str, str] = {
        "context.md": "Project overview, goals, and business context",
        "tech-stack.md": "Technology choices, versions, and dependencies",
        "known-issues.md": "Active bugs, workarounds, and technical debt",
        "commands.md": "Build, test, run, and deployment commands",
        "session-journal.md": "Per-session AI work log",
        "status.md": "Current phase, task board, blockers, session context",
        "deploy.md": "Deployment environments and infrastructure",
    }

    # Required SOP files in project/sop/ (stem name → description)
    REQUIRED_SOP_FILES: dict[str, str] = {
        "release.md": "Release process SOP (version bump, tag, CI trigger)",
        "debug.md": "Debugging SOP (reproduce, evidence, root cause, fix)",
        "git-workflow.md": "Git commit conventions, branch strategy, tagging rules",
    }

    # Standard directory structure
    STANDARD_DIRS = [
        "core",
        "adapters",
        "meta",
        "scripts",
        "skills",
        "project",
        "project/sop",
    ]

    # Directories to exclude when traversing (third-party/build artifacts)
    IGNORED_DIRS = (".venv", "node_modules", "__pycache__", ".git", "dist", "build")

    def __init__(self, agent_dir: Path):
        self.agent_dir = agent_dir
        self.results: list[LintResult] = []
        self.manifest = self._load_manifest()

    def _load_manifest(self) -> dict[str, object]:
        """Load manifest.json."""
        manifest_path = self.agent_dir / "manifest.json"
        if manifest_path.exists():
            result: dict[str, object] = json.loads(manifest_path.read_text(encoding="utf-8"))
            return result
        return {}

    @staticmethod
    def compute_sha256(file_path: Path) -> str:
        """Compute SHA256 hash of a file."""
        sha256 = hashlib.sha256()
        content = file_path.read_bytes()
        sha256.update(content)
        return sha256.hexdigest()

    def get_all_locked_files(self) -> list[str]:
        """Get list of all locked file paths (relative to .agent)."""
        locked_files = []

        # Root locked files (except manifest.json which contains checksums)
        for f in self.LOCKED_FILES:
            if f != "manifest.json":  # Skip manifest itself
                path = self.agent_dir / f
                if path.exists():
                    locked_files.append(f)

        # Locked directories
        for locked_dir in self.LOCKED_DIRS:
            dir_path = self.agent_dir / locked_dir
            if dir_path.exists():
                for file_path in dir_path.rglob("*"):
                    if file_path.is_file():
                        rel_path = file_path.relative_to(self.agent_dir)
                        locked_files.append(str(rel_path).replace("\\", "/"))

        # Locked skills
        skills_dir = self.agent_dir / "skills"
        if skills_dir.exists():
            for skill in self.LOCKED_SKILLS:
                skill_path = skills_dir / skill
                if skill_path.is_file():
                    locked_files.append(f"skills/{skill}")
                elif skill_path.is_dir():
                    for file_path in skill_path.rglob("*"):
                        if file_path.is_file():
                            rel_path = file_path.relative_to(self.agent_dir)
                            locked_files.append(str(rel_path).replace("\\", "/"))

        return sorted(set(locked_files))

    def generate_checksums(self) -> dict[str, str]:
        """Generate checksums for all locked files."""
        checksums = {}
        for rel_path in self.get_all_locked_files():
            file_path = self.agent_dir / rel_path
            if file_path.exists():
                checksums[rel_path] = self.compute_sha256(file_path)
        return checksums

    def lint_all(self) -> list[LintResult]:
        """Execute all checks."""
        self.check_directory_structure()
        self.check_required_files()
        self.check_integrity()
        self.check_start_here_spec()
        self.check_naming_convention()
        self.check_skills_placement()
        self.check_engine_pollution()
        self.check_internal_links()
        self.check_relations()
        return self.results

    def lint_rule(self, rule: str) -> list[LintResult]:
        """Execute specific rule check."""
        rule_methods = {
            "directory-structure": self.check_directory_structure,
            "required-files": self.check_required_files,
            "integrity-violation": self.check_integrity,
            "start-here-spec": self.check_start_here_spec,
            "naming-convention": self.check_naming_convention,
            "skills-placement": self.check_skills_placement,
            "engine-pollution": self.check_engine_pollution,
            "internal-links": self.check_internal_links,
            "relations": self.check_relations,
        }
        if rule in rule_methods:
            rule_methods[rule]()
        return self.results

    def check_directory_structure(self) -> None:
        """Check standard directories exist."""
        for dir_name in self.STANDARD_DIRS:
            dir_path = self.agent_dir / dir_name
            if dir_path.exists() and dir_path.is_dir():
                self.results.append(
                    LintResult(
                        "directory-structure",
                        True,
                        f"Directory exists: {dir_name}/",
                        dir_name,
                    )
                )
            else:
                self.results.append(
                    LintResult(
                        "directory-structure",
                        False,
                        f"Missing standard directory: {dir_name}/",
                        dir_name,
                    )
                )

    def check_required_files(self) -> None:
        """Check required files in project/ directory."""
        project_dir = self.agent_dir / "project"

        if not project_dir.exists():
            self.results.append(
                LintResult(
                    "required-files",
                    False,
                    "project/ directory does not exist",
                    "project",
                )
            )
            return

        for file_name in self.REQUIRED_PROJECT_FILES:
            file_path = project_dir / file_name
            if file_path.exists():
                self.results.append(
                    LintResult(
                        "required-files",
                        True,
                        "Required file exists",
                        f"project/{file_name}",
                    )
                )
            else:
                self.results.append(
                    LintResult(
                        "required-files",
                        False,
                        "Missing required file",
                        f"project/{file_name}",
                    )
                )

        # Check required SOP files in project/sop/
        sop_dir = project_dir / "sop"
        if not sop_dir.exists():
            self.results.append(
                LintResult(
                    "required-files",
                    False,
                    "Missing required directory",
                    "project/sop",
                )
            )
        else:
            for sop_name in self.REQUIRED_SOP_FILES:
                sop_path = sop_dir / sop_name
                if sop_path.exists():
                    self.results.append(
                        LintResult(
                            "required-files",
                            True,
                            "Required SOP file exists",
                            f"project/sop/{sop_name}",
                        )
                    )
                else:
                    self.results.append(
                        LintResult(
                            "required-files",
                            False,
                            "Missing required SOP file",
                            f"project/sop/{sop_name}",
                        )
                    )

        # Also check root required files
        for file_name in self.LOCKED_FILES:
            file_path = self.agent_dir / file_name
            if file_path.exists():
                self.results.append(
                    LintResult(
                        "required-files",
                        True,
                        "Required file exists",
                        file_name,
                    )
                )
            else:
                self.results.append(
                    LintResult(
                        "required-files",
                        False,
                        "Missing required file",
                        file_name,
                    )
                )

    def check_integrity(self) -> None:
        """Check integrity of locked files using SHA256 checksums."""
        checksums_obj = self.manifest.get("checksums", {})
        stored_checksums: dict[str, str] = (
            checksums_obj if isinstance(checksums_obj, dict) else {}
        )

        if not stored_checksums:
            self.results.append(
                LintResult(
                    "integrity-violation",
                    False,
                    "No checksums found in manifest.json. Run 'co update-checksums' to generate.",
                    "manifest.json",
                )
            )
            return

        locked_files = self.get_all_locked_files()

        for rel_path in locked_files:
            file_path = self.agent_dir / rel_path

            if rel_path not in stored_checksums:
                # New file not in checksums - could be unauthorized addition
                self.results.append(
                    LintResult(
                        "integrity-violation",
                        False,
                        "File not in checksums (possibly unauthorized addition)",
                        rel_path,
                    )
                )
                continue

            if not file_path.exists():
                self.results.append(
                    LintResult(
                        "integrity-violation",
                        False,
                        "Locked file missing",
                        rel_path,
                    )
                )
                continue

            current_hash = self.compute_sha256(file_path)
            expected_hash = stored_checksums[rel_path]

            if current_hash == expected_hash:
                self.results.append(
                    LintResult(
                        "integrity-violation",
                        True,
                        "Integrity verified",
                        rel_path,
                    )
                )
            else:
                self.results.append(
                    LintResult(
                        "integrity-violation",
                        False,
                        "File modified (hash mismatch)",
                        rel_path,
                    )
                )

        # Check for files in checksums that no longer exist
        for rel_path in stored_checksums:
            if rel_path not in locked_files:
                file_path = self.agent_dir / rel_path
                if not file_path.exists():
                    self.results.append(
                        LintResult(
                            "integrity-violation",
                            False,
                            "Locked file deleted",
                            rel_path,
                        )
                    )

    def check_start_here_spec(self) -> None:
        """Check start-here.md does not contain project-specific info."""
        start_here = self.agent_dir / "start-here.md"

        if not start_here.exists():
            return  # Already reported in required-files

        content = start_here.read_text(encoding="utf-8")

        # Patterns that should NOT appear in start-here.md
        forbidden_patterns = [
            (r"^#\s+[A-Z][a-zA-Z0-9_-]+\s*$", "Project name as title"),
            (r"项目概述|Project Overview", "Project overview section"),
            (r"开发状态|Development Status", "Development status"),
            (r"技术栈|Tech Stack", "Tech stack info"),
            (r"目录结构|Directory Structure", "Directory structure"),
            (r"核心数据类型|Core Data Types", "Core data types"),
            (r"常用命令|Common Commands", "Common commands"),
        ]

        found_violation = False
        for pattern, desc in forbidden_patterns:
            matches = list(re.finditer(pattern, content, re.MULTILINE))
            if matches:
                found_violation = True
                for match in matches:
                    line_num = content[: match.start()].count("\n") + 1
                    self.results.append(
                        LintResult(
                            "start-here-spec",
                            False,
                            f"Should not contain {desc}: '{match.group().strip()}'",
                            "start-here.md",
                            line_num,
                        )
                    )

        if not found_violation:
            self.results.append(
                LintResult(
                    "start-here-spec",
                    True,
                    "No project-specific content detected",
                    "start-here.md",
                )
            )

    def _is_under_ignored_dir(self, relative_path: Path) -> bool:
        """Return True if path has any ignored directory in its parts."""
        return any(part in self.IGNORED_DIRS for part in relative_path.parts)

    def check_naming_convention(self) -> None:
        """Check kebab-case naming convention for .md files."""
        # kebab-case: base name or base.template.md
        pattern = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*(\.template)?\.md$")
        exceptions = {"MANIFEST.json", "VERSION", "SKILL.md", "README.md", "CHANGELOG.md"}

        for path in self.agent_dir.rglob("*.md"):
            relative = path.relative_to(self.agent_dir)
            if self._is_under_ignored_dir(relative):
                continue
            if path.name in exceptions:
                continue

            if pattern.match(path.name):
                self.results.append(
                    LintResult(
                        "naming-convention",
                        True,
                        "Follows kebab-case",
                        str(relative),
                    )
                )
            else:
                self.results.append(
                    LintResult(
                        "naming-convention",
                        False,
                        f"Should use kebab-case: {path.name}",
                        str(relative),
                    )
                )

    def check_skills_placement(self) -> None:
        """Check project-specific skills are in _project/ directory."""
        skills_dir = self.agent_dir / "skills"

        if not skills_dir.exists():
            return

        # Get all items directly under skills/
        for item in skills_dir.iterdir():
            if item.name.startswith("."):
                continue

            relative = item.relative_to(self.agent_dir)

            # Check if it's a standard skill or _project
            if item.name in self.LOCKED_SKILLS or item.name == "_project":
                self.results.append(
                    LintResult(
                        "skills-placement",
                        True,
                        "Valid skill location",
                        str(relative),
                    )
                )
            else:
                # Non-standard item in skills/ root
                self.results.append(
                    LintResult(
                        "skills-placement",
                        False,
                        f"Project skill must be in skills/_project/: {item.name}",
                        str(relative),
                    )
                )

    def check_engine_pollution(self) -> None:
        """Check for hardcoded paths in locked directories."""
        pollution_patterns = [
            (r"[A-Z]:\\\\", "Windows path"),
            (r"(?<![a-zA-Z])[A-Z]:/", "Windows path"),
            (r"/home/\w+/", "Unix home path"),
            (r"/Users/\w+/", "macOS user path"),
            (r"localhost:\d+", "Hardcoded localhost"),
            (r"127\.0\.0\.1:\d+", "Hardcoded IP"),
        ]

        for locked_dir in self.LOCKED_DIRS:
            dir_path = self.agent_dir / locked_dir
            if not dir_path.exists():
                continue

            for path in dir_path.rglob("*.md"):
                relative = path.relative_to(self.agent_dir)
                content = path.read_text(encoding="utf-8")

                found_pollution = False
                for pattern, desc in pollution_patterns:
                    matches = list(re.finditer(pattern, content, re.IGNORECASE))
                    if matches:
                        found_pollution = True
                        for match in matches:
                            line_num = content[: match.start()].count("\n") + 1
                            self.results.append(
                                LintResult(
                                    "engine-pollution",
                                    False,
                                    f"Found {desc}: {match.group()}",
                                    str(relative),
                                    line_num,
                                )
                            )

                if not found_pollution:
                    self.results.append(
                        LintResult(
                            "engine-pollution",
                            True,
                            "No pollution detected",
                            str(relative),
                        )
                    )

    @staticmethod
    def _strip_fenced_code_blocks(content: str) -> str:
        """Replace fenced code block contents with blank lines to preserve line numbers."""
        return re.sub(
            r"^```[^\n]*\n.*?^```",
            lambda m: "\n" * m.group().count("\n"),
            content,
            flags=re.MULTILINE | re.DOTALL,
        )

    # Directories whose links resolve relative to .agent/ root, not their source location
    LINK_CHECK_SKIP_DIRS = ["templates"]

    def check_internal_links(self) -> None:
        """Check internal link validity."""
        link_pattern = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")

        for path in self.agent_dir.rglob("*.md"):
            relative = path.relative_to(self.agent_dir)
            if self._is_under_ignored_dir(relative):
                continue
            if any(
                relative.parts[0] == skip_dir
                for skip_dir in self.LINK_CHECK_SKIP_DIRS
                if relative.parts
            ):
                continue

            content = path.read_text(encoding="utf-8")
            stripped = self._strip_fenced_code_blocks(content)

            for match in link_pattern.finditer(stripped):
                link_text, link_target = match.groups()

                if link_target.startswith(("http://", "https://", "#", "mailto:")):
                    continue

                if link_target.startswith("/"):
                    target_path = self.agent_dir / link_target[1:]
                else:
                    target_path = path.parent / link_target

                target_str = str(target_path)
                if "#" in target_str:
                    target_path = Path(target_str.split("#")[0])

                line_num = content[: match.start()].count("\n") + 1

                if target_path.exists():
                    self.results.append(
                        LintResult(
                            "internal-links",
                            True,
                            f"Link valid: {link_target}",
                            str(relative),
                            line_num,
                        )
                    )
                else:
                    self.results.append(
                        LintResult(
                            "internal-links",
                            False,
                            f"Broken link: {link_target}",
                            str(relative),
                            line_num,
                        )
                    )


    def check_relations(self) -> None:
        """Check that declared references and collaborations are accessible."""
        from cokodo_agent.relations import (
            check_collaborations,
            check_references,
            load_collaborations,
            load_references,
        )

        refs = load_references(self.agent_dir)
        collabs = load_collaborations(self.agent_dir)

        if not refs and not collabs:
            self.results.append(
                LintResult("relations", True, "No relations declared", None)
            )
            return

        project_root = self.agent_dir.parent

        if refs:
            statuses = check_references(self.agent_dir, project_root)
            for st in statuses:
                if st.accessible:
                    self.results.append(
                        LintResult(
                            "relations",
                            True,
                            f"Reference '{st.name}' accessible ({st.source_type})",
                            "manifest.json",
                        )
                    )
                else:
                    self.results.append(
                        LintResult(
                            "relations",
                            False,
                            f"Reference '{st.name}' inaccessible: {st.error}",
                            "manifest.json",
                        )
                    )

        if collabs:
            cstatuses = check_collaborations(self.agent_dir, project_root)
            for cst in cstatuses:
                if cst.partner_accessible:
                    sync = "in sync" if cst.shared_files_in_sync else "drifted"
                    self.results.append(
                        LintResult(
                            "relations",
                            True,
                            f"Collaboration '{cst.name}' partner accessible ({sync})",
                            "manifest.json",
                        )
                    )
                else:
                    self.results.append(
                        LintResult(
                            "relations",
                            False,
                            f"Collaboration '{cst.name}' partner inaccessible: {cst.error}",
                            "manifest.json",
                        )
                    )

        from cokodo_agent.relations import detect_circular_references

        cycles = detect_circular_references(self.agent_dir, project_root)
        if cycles:
            for cycle in cycles:
                self.results.append(
                    LintResult(
                        "relations",
                        False,
                        f"Circular reference detected: {cycle}",
                        "manifest.json",
                    )
                )
        else:
            self.results.append(
                LintResult(
                    "relations",
                    True,
                    "No circular references detected",
                    "manifest.json",
                )
            )


def update_checksums(agent_dir: Path) -> dict[str, str]:
    """Update checksums in manifest.json and return the checksums."""
    manifest_path = agent_dir / "manifest.json"

    if not manifest_path.exists():
        raise FileNotFoundError(f"manifest.json not found at {manifest_path}")

    # Load existing manifest
    manifest = json.loads(manifest_path.read_text(encoding="utf-8"))

    # Generate new checksums
    linter = ProtocolLinter(agent_dir)
    checksums = linter.generate_checksums()

    # Update manifest
    manifest["checksums"] = checksums

    # Write back with proper formatting
    manifest_path.write_text(
        json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
    )

    return checksums
