"""AI governance policy engine — classify assets for ML/AI training compliance.

Provides TOML-based policy definition, rule matching against manifest entries,
bulk classification with coverage metrics, and HTML/JSON report generation.

Policy files follow ``.census-ai-policy.toml`` format with ``[[rules]]``
supporting glob patterns, size filters, and regulatory framework mapping.

All classification and reporting is 100% local.  Only ``tag_classifications()``
makes API calls (via ``set_tags()``) to persist the classification.
"""

from __future__ import annotations

import fnmatch
import html
import logging
import sys
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional

from . import __version__
from .manifest import Manifest, ManifestEntry

logger = logging.getLogger(__name__)

if sys.version_info >= (3, 11):
    import tomllib
else:
    try:
        import tomllib
    except ModuleNotFoundError:
        import tomli as tomllib  # type: ignore[no-redef,unused-ignore]

VALID_ACTIONS = frozenset({"allow", "exclude"})
TAG_KEY = "ai_training"
MAX_POLICY_RULES = 500
MAX_PATTERNS_PER_RULE = 1000
MAX_PATTERN_LEN = 512
MAX_FIELD_LEN = 256
MAX_POLICY_FILE_SIZE = 10 * 1024 * 1024  # 10 MB


@dataclass
class PolicyRule:
    name: str
    action: str
    patterns: list[str] = field(default_factory=list)
    min_size: Optional[int] = None
    max_size: Optional[int] = None
    description: str = ""


@dataclass
class Policy:
    name: str
    version: str
    framework: str
    default_action: str
    rules: list[PolicyRule]


@dataclass
class ClassificationResult:
    hash_hex: str
    path: str
    action: str
    rule_name: str
    attestation_id: Optional[str] = None
    is_default: bool = False


@dataclass
class PolicyReport:
    policy_name: str
    policy_version: str
    framework: str
    default_action: str
    generated_at: str = ""
    census_version: str = ""
    total_files: int = 0
    classified_by_rule: int = 0
    classified_by_default: int = 0
    allowed: int = 0
    excluded: int = 0
    untaggable: int = 0
    tagged: int = 0
    tag_errors: int = 0
    results: list[ClassificationResult] = field(default_factory=list)


def load_policy(path: Path) -> Policy:
    """Parse and validate a TOML policy file."""
    file_size = path.stat().st_size
    if file_size > MAX_POLICY_FILE_SIZE:
        raise ValueError(
            f"Policy file too large ({file_size} bytes > {MAX_POLICY_FILE_SIZE})"
        )

    raw = path.read_bytes()
    try:
        data = tomllib.loads(raw.decode("utf-8-sig"))
    except (tomllib.TOMLDecodeError, UnicodeDecodeError) as exc:
        raise ValueError(f"Invalid TOML: {exc}") from exc

    if not isinstance(data, dict):
        raise ValueError("Policy file must be a TOML table")

    policy_section = data.get("policy")
    if not isinstance(policy_section, dict):
        raise ValueError("Missing or invalid [policy] section")

    name = policy_section.get("name", "")
    if not isinstance(name, str) or not name.strip():
        raise ValueError("[policy].name is required")
    if len(name) > MAX_FIELD_LEN:
        raise ValueError(f"[policy].name too long ({len(name)} > {MAX_FIELD_LEN})")

    version = str(policy_section.get("version", "1.0"))
    framework = str(policy_section.get("framework", ""))

    default_action = str(policy_section.get("default_action", "exclude"))
    if default_action not in VALID_ACTIONS:
        raise ValueError(
            f"[policy].default_action must be one of {sorted(VALID_ACTIONS)}, "
            f"got {default_action!r}"
        )

    raw_rules = data.get("rules", [])
    if not isinstance(raw_rules, list):
        raise ValueError("[[rules]] must be an array of tables")
    if len(raw_rules) > MAX_POLICY_RULES:
        raise ValueError(f"Too many rules ({len(raw_rules)} > {MAX_POLICY_RULES})")

    rules: list[PolicyRule] = []
    for i, r in enumerate(raw_rules):
        if not isinstance(r, dict):
            raise ValueError(f"Rule #{i + 1} must be a table")

        r_name = r.get("name", "")
        if not isinstance(r_name, str) or not r_name.strip():
            raise ValueError(f"Rule #{i + 1} missing 'name'")

        r_action = str(r.get("action", "exclude"))
        if r_action not in VALID_ACTIONS:
            raise ValueError(
                f"Rule #{i + 1} ({r_name!r}): action must be one of "
                f"{sorted(VALID_ACTIONS)}, got {r_action!r}"
            )

        patterns = r.get("patterns", [])
        if not isinstance(patterns, list):
            raise ValueError(f"Rule #{i + 1} ({r_name!r}): patterns must be a list")
        if len(patterns) > MAX_PATTERNS_PER_RULE:
            raise ValueError(
                f"Rule #{i + 1} ({r_name!r}): too many patterns "
                f"({len(patterns)} > {MAX_PATTERNS_PER_RULE})"
            )
        for p in patterns:
            if not isinstance(p, str):
                raise ValueError(
                    f"Rule #{i + 1} ({r_name!r}): each pattern must be a string"
                )
            if len(p) > MAX_PATTERN_LEN:
                raise ValueError(
                    f"Rule #{i + 1} ({r_name!r}): pattern too long "
                    f"({len(p)} > {MAX_PATTERN_LEN})"
                )

        min_size = r.get("min_size")
        max_size = r.get("max_size")
        if min_size is not None:
            if isinstance(min_size, bool) or not isinstance(min_size, int):
                raise ValueError(
                    f"Rule #{i + 1} ({r_name!r}): min_size must be an integer"
                )
            if min_size < 0:
                raise ValueError(
                    f"Rule #{i + 1} ({r_name!r}): min_size must be non-negative"
                )
        if max_size is not None:
            if isinstance(max_size, bool) or not isinstance(max_size, int):
                raise ValueError(
                    f"Rule #{i + 1} ({r_name!r}): max_size must be an integer"
                )
            if max_size < 0:
                raise ValueError(
                    f"Rule #{i + 1} ({r_name!r}): max_size must be non-negative"
                )
        if (min_size is not None and max_size is not None
                and min_size > max_size):
            raise ValueError(
                f"Rule #{i + 1} ({r_name!r}): min_size ({min_size}) > max_size ({max_size})"
            )

        rules.append(PolicyRule(
            name=r_name,
            action=r_action,
            patterns=patterns,
            min_size=min_size,
            max_size=max_size,
            description=str(r.get("description", "")),
        ))

    return Policy(
        name=name,
        version=version,
        framework=framework,
        default_action=default_action,
        rules=rules,
    )


def _match_rule(entry: ManifestEntry, rule: PolicyRule) -> bool:
    """Check whether a manifest entry matches a policy rule."""
    if rule.min_size is not None and entry.size < rule.min_size:
        return False
    if rule.max_size is not None and entry.size > rule.max_size:
        return False

    if not rule.patterns:
        return rule.min_size is not None or rule.max_size is not None

    return any(fnmatch.fnmatch(entry.path, pat) for pat in rule.patterns)


def classify_manifest(manifest: Manifest, policy: Policy) -> PolicyReport:
    """Classify every manifest entry against the policy rules.

    Returns a ``PolicyReport`` with per-file classifications and coverage
    metrics.  No API calls are made.
    """
    results: list[ClassificationResult] = []

    for entry in manifest.iter_entries():
        matched = False
        for rule in policy.rules:
            if _match_rule(entry, rule):
                results.append(ClassificationResult(
                    hash_hex=entry.hash_hex,
                    path=entry.path,
                    action=rule.action,
                    rule_name=rule.name,
                    attestation_id=entry.attestation_id,
                    is_default=False,
                ))
                matched = True
                break

        if not matched:
            results.append(ClassificationResult(
                hash_hex=entry.hash_hex,
                path=entry.path,
                action=policy.default_action,
                rule_name="(default)",
                attestation_id=entry.attestation_id,
                is_default=True,
            ))

    _apply_most_restrictive(results)

    by_rule = sum(1 for r in results if not r.is_default)
    by_default = sum(1 for r in results if r.is_default)
    allowed = sum(1 for r in results if r.action == "allow")
    excluded = sum(1 for r in results if r.action == "exclude")
    untaggable = sum(1 for r in results if not r.attestation_id)

    return PolicyReport(
        policy_name=policy.name,
        policy_version=policy.version,
        framework=policy.framework,
        default_action=policy.default_action,
        generated_at=datetime.now(timezone.utc).isoformat(),
        census_version=__version__,
        total_files=len(results),
        classified_by_rule=by_rule,
        classified_by_default=by_default,
        allowed=allowed,
        excluded=excluded,
        untaggable=untaggable,
        results=results,
    )


def _apply_most_restrictive(results: list[ClassificationResult]) -> None:
    """For files sharing an attestation_id, apply most-restrictive-wins.

    If any file with the same attestation_id is classified as 'exclude',
    all files sharing that ID are elevated to 'exclude'.
    """
    att_id_map: dict[str, list[ClassificationResult]] = {}
    for r in results:
        if r.attestation_id:
            att_id_map.setdefault(r.attestation_id, []).append(r)

    for group in att_id_map.values():
        if len(group) < 2:
            continue
        if any(r.action == "exclude" for r in group):
            for r in group:
                if r.action != "exclude":
                    r.action = "exclude"
                    r.rule_name = f"{r.rule_name} (elevated: most-restrictive-wins)"


def tag_classifications(
    report: PolicyReport,
    *,
    api_key: str | None = None,
    base_url: str | None = None,
) -> tuple[int, int]:
    """Persist classifications as tags on attestations.

    Tags each attestation with ``ai_training=allowed`` or ``ai_training=excluded``.
    Files without an ``attestation_id`` are skipped.

    Returns ``(tagged_count, error_count)``.
    """
    from .tagging import set_tags

    att_id_to_action: dict[str, str] = {}
    for r in report.results:
        if not r.attestation_id:
            continue
        existing = att_id_to_action.get(r.attestation_id)
        if existing == "exclude":
            continue
        att_id_to_action[r.attestation_id] = r.action

    tagged = 0
    errors = 0
    for att_id, action in att_id_to_action.items():
        value = "allowed" if action == "allow" else "excluded"
        try:
            result = set_tags(
                att_id,
                [(TAG_KEY, value)],
                api_key=api_key,
                base_url=base_url,
            )
            if result.success:
                tagged += 1
            else:
                errors += 1
        except Exception as exc:
            logger.warning("Failed to tag attestation %s: %s", att_id, exc)
            errors += 1

    report.tagged = tagged
    report.tag_errors = errors
    return tagged, errors


def render_ai_report_json(report: PolicyReport) -> dict:
    """Render policy report as a JSON-serializable dict."""
    return asdict(report)


_FRAMEWORK_LABELS: dict[str, str] = {
    "eu-ai-act": "EU AI Act (Regulation 2024/1689)",
    "iso42001": "ISO/IEC 42001:2023",
    "c2pa": "C2PA (Coalition for Content Provenance and Authenticity)",
}


def render_ai_report_html(report: PolicyReport) -> str:
    """Render policy report as self-contained HTML (same style as compliance)."""
    fw_label = html.escape(
        _FRAMEWORK_LABELS.get(report.framework, report.framework or "Custom")
    )
    policy_name = html.escape(report.policy_name)
    policy_version = html.escape(report.policy_version)
    generated = html.escape(report.generated_at)
    version = html.escape(report.census_version)

    coverage_pct = (
        f"{report.classified_by_rule / report.total_files * 100:.1f}"
        if report.total_files > 0
        else "0.0"
    )

    result_rows = ""
    for r in report.results:
        cls = "ok" if r.action == "allow" else "fail-status"
        icon = "&#10003;" if r.action == "allow" else "&#10007;"
        att_cell = html.escape(r.attestation_id or "—")
        result_rows += (
            f'<tr><td class="{cls}">{icon}</td>'
            f"<td>{html.escape(r.path)}</td>"
            f"<td>{html.escape(r.action)}</td>"
            f"<td>{html.escape(r.rule_name)}</td>"
            f"<td><code>{att_cell}</code></td></tr>\n"
        )

    return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>AI Governance Report — {policy_name}</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
       margin: 2rem; color: #333; max-width: 1200px; margin-inline: auto; }}
h1 {{ border-bottom: 2px solid #0066cc; padding-bottom: .5rem; }}
h2 {{ margin-top: 2rem; color: #0066cc; }}
table {{ border-collapse: collapse; width: 100%; margin: 1rem 0; }}
th, td {{ border: 1px solid #ddd; padding: .5rem .75rem; text-align: left; }}
th {{ background: #f5f5f5; }}
.ok {{ color: #28a745; font-weight: bold; }}
.fail-status {{ color: #dc3545; font-weight: bold; }}
.summary-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
                 gap: 1rem; margin: 1rem 0; }}
.summary-card {{ background: #f8f9fa; border: 1px solid #dee2e6; border-radius: .5rem;
                 padding: 1rem; text-align: center; }}
.summary-card .number {{ font-size: 2rem; font-weight: bold; color: #0066cc; }}
.summary-card .label {{ color: #6c757d; font-size: .875rem; }}
code {{ background: #f1f1f1; padding: .15rem .35rem; border-radius: .25rem; font-size: .875rem; }}
footer {{ margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #eee;
          font-size: .8rem; color: #999; }}
</style>
</head>
<body>
<h1>AI Governance Report</h1>
<p><strong>Policy:</strong> {policy_name} v{policy_version}<br>
<strong>Framework:</strong> {fw_label}<br>
<strong>Generated:</strong> {generated}<br>
<strong>Census version:</strong> {version}</p>

<h2>Summary</h2>
<div class="summary-grid">
  <div class="summary-card"><div class="number">{report.total_files}</div><div class="label">Total files</div></div>
  <div class="summary-card"><div class="number">{report.allowed}</div><div class="label">Allowed</div></div>
  <div class="summary-card"><div class="number">{report.excluded}</div><div class="label">Excluded</div></div>
  <div class="summary-card"><div class="number">{coverage_pct}%</div><div class="label">Rule coverage</div></div>
  <div class="summary-card"><div class="number">{report.classified_by_default}</div><div class="label">Default fallback</div></div>
  <div class="summary-card"><div class="number">{report.untaggable}</div><div class="label">Untaggable</div></div>
</div>

<h2>Classifications</h2>
<table>
<thead><tr><th></th><th>File</th><th>Action</th><th>Rule</th><th>Attestation</th></tr></thead>
<tbody>
{result_rows}</tbody>
</table>

<footer>CertiSigma Census v{version} — AI Governance Report — {fw_label}</footer>
</body>
</html>"""


_TEMPLATE_TOML = """\
# CertiSigma Census — AI Training Policy
# See: https://developers.certisigma.ch/census#ai-governance

[policy]
name = "AI Training Data Policy"
version = "1.0"
framework = "eu-ai-act"         # eu-ai-act | iso42001 | c2pa | custom
default_action = "exclude"      # safety-first: unmatched files are excluded

# Rules are evaluated top-to-bottom; first match wins.
# Actions: "allow" (permitted for AI training) or "exclude" (prohibited).

[[rules]]
name = "Allow public documentation"
action = "allow"
description = "Public-facing docs and licenses are safe for training"
patterns = ["*.md", "*.txt", "*.rst", "LICENSE*", "NOTICE*"]

[[rules]]
name = "Allow structured data"
action = "allow"
description = "Open data formats"
patterns = ["*.csv", "*.json", "*.xml", "*.yaml", "*.yml"]

[[rules]]
name = "Exclude source code"
action = "exclude"
description = "Copyrighted source code"
patterns = ["*.py", "*.js", "*.ts", "*.java", "*.go", "*.rs", "*.c", "*.cpp", "*.h"]

[[rules]]
name = "Exclude large binaries"
action = "exclude"
description = "Binary assets above 100 MB"
min_size = 104857600

# Add your rules above this line.
# Files not matching any rule are classified by default_action.
"""


def generate_template_policy() -> str:
    """Return the default TOML policy template."""
    return _TEMPLATE_TOML
