"""Finding Annotator for MCP Security Audit.

Annotate findings with statuses (false positive, accepted risk, TODO, etc.),
persist annotations to JSON, and filter findings based on their annotations.

Usage:
    from mcpaudit.annotator import (
        annotate_finding, load_annotations, filter_annotated,
    )

    annotate_finding("CMD-001:server.py:42", "false_positive",
                     note="Checked manually", author="carlos")
    annotations = load_annotations(".mcpaudit-annotations.json")
    clean = filter_annotated(findings, annotations)

CLI: mcpaudit annotate <finding-id> --status false_positive --note "..."

Zero external dependencies. Part of mcpaudit v2.2.0.
"""

from __future__ import annotations

import json
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any


# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

VALID_STATUSES = frozenset({
    "false_positive",
    "accepted_risk",
    "todo",
    "in_progress",
    "wont_fix",
})

DEFAULT_ANNOTATIONS_FILE = ".mcpaudit-annotations.json"

# Statuses that cause a finding to be excluded from reports
EXCLUDE_STATUSES = frozenset({
    "false_positive",
    "accepted_risk",
    "wont_fix",
})


# ---------------------------------------------------------------------------
# Data models
# ---------------------------------------------------------------------------

@dataclass
class Annotation:
    """An annotation attached to a finding."""

    finding_id: str
    status: str
    note: str = ""
    author: str = ""
    created_at: str = ""
    updated_at: str = ""

    def __post_init__(self) -> None:
        now = _now_iso()
        if not self.created_at:
            self.created_at = now
        if not self.updated_at:
            self.updated_at = now

    def validate(self) -> list[str]:
        """Validate this annotation.

        Returns:
            List of error messages (empty if valid).
        """
        errors: list[str] = []
        if not self.finding_id:
            errors.append("finding_id is required")
        if not self.status:
            errors.append("status is required")
        elif self.status not in VALID_STATUSES:
            errors.append(
                f"Invalid status '{self.status}'. "
                f"Valid: {', '.join(sorted(VALID_STATUSES))}"
            )
        return errors

    def to_dict(self) -> dict[str, Any]:
        return {
            "finding_id": self.finding_id,
            "status": self.status,
            "note": self.note,
            "author": self.author,
            "created_at": self.created_at,
            "updated_at": self.updated_at,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> Annotation:
        return cls(
            finding_id=data.get("finding_id", ""),
            status=data.get("status", ""),
            note=data.get("note", ""),
            author=data.get("author", ""),
            created_at=data.get("created_at", ""),
            updated_at=data.get("updated_at", ""),
        )


@dataclass
class AnnotationStore:
    """Collection of annotations with lookup by finding ID."""

    annotations: dict[str, Annotation] = field(default_factory=dict)

    @property
    def count(self) -> int:
        return len(self.annotations)

    def get(self, finding_id: str) -> Annotation | None:
        """Get annotation for a finding ID."""
        return self.annotations.get(finding_id)

    def has(self, finding_id: str) -> bool:
        """Check if a finding has an annotation."""
        return finding_id in self.annotations

    def is_excluded(self, finding_id: str) -> bool:
        """Check if a finding should be excluded from reports."""
        ann = self.annotations.get(finding_id)
        if ann is None:
            return False
        return ann.status in EXCLUDE_STATUSES

    def filter_by_status(self, status: str) -> list[Annotation]:
        """Get all annotations with a specific status."""
        return [
            a for a in self.annotations.values()
            if a.status == status
        ]

    def list_all(self) -> list[Annotation]:
        """List all annotations sorted by finding_id."""
        return sorted(
            self.annotations.values(),
            key=lambda a: a.finding_id,
        )

    def to_dict(self) -> dict[str, Any]:
        return {
            fid: ann.to_dict()
            for fid, ann in self.annotations.items()
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> AnnotationStore:
        store = cls()
        for fid, ann_data in data.items():
            if isinstance(ann_data, dict):
                ann = Annotation.from_dict(ann_data)
                if not ann.finding_id:
                    ann.finding_id = fid
                store.annotations[fid] = ann
        return store

    def stats(self) -> dict[str, int]:
        """Count annotations by status."""
        counts: dict[str, int] = {}
        for ann in self.annotations.values():
            counts[ann.status] = counts.get(ann.status, 0) + 1
        return counts


# ---------------------------------------------------------------------------
# Finding ID generation
# ---------------------------------------------------------------------------

def make_finding_id(
    rule_id: str, file: str, line: int,
) -> str:
    """Generate a deterministic finding ID.

    Args:
        rule_id: Rule identifier.
        file: File path.
        line: Line number.

    Returns:
        Finding ID string (e.g. "CMD-001:server.py:42").
    """
    return f"{rule_id}:{file}:{line}"


def make_finding_id_from_dict(finding: dict[str, Any]) -> str:
    """Generate finding ID from a finding dict.

    Args:
        finding: Dict with rule_id, file, and line keys.

    Returns:
        Finding ID string.
    """
    return make_finding_id(
        finding.get("rule_id", ""),
        finding.get("file", ""),
        finding.get("line", 0),
    )


# ---------------------------------------------------------------------------
# Core operations
# ---------------------------------------------------------------------------

def annotate_finding(
    finding_id: str,
    status: str,
    note: str = "",
    author: str = "",
    store: AnnotationStore | None = None,
) -> tuple[Annotation, list[str]]:
    """Create or update an annotation for a finding.

    Args:
        finding_id: The finding identifier.
        status: Annotation status.
        note: Optional note/reason.
        author: Who created the annotation.
        store: Optional AnnotationStore to update in-place.

    Returns:
        Tuple of (Annotation, errors).
    """
    ann = Annotation(
        finding_id=finding_id,
        status=status,
        note=note,
        author=author,
    )
    errors = ann.validate()
    if errors:
        return ann, errors

    if store is not None:
        existing = store.get(finding_id)
        if existing is not None:
            ann.created_at = existing.created_at
            ann.updated_at = _now_iso()
        store.annotations[finding_id] = ann

    return ann, []


def bulk_annotate(
    finding_ids: list[str],
    status: str,
    note: str = "",
    author: str = "",
    store: AnnotationStore | None = None,
) -> tuple[list[Annotation], list[str]]:
    """Annotate multiple findings at once.

    Args:
        finding_ids: List of finding IDs.
        status: Status to apply.
        note: Optional note.
        author: Who created.
        store: Optional store to update.

    Returns:
        Tuple of (annotations, errors).
    """
    if status not in VALID_STATUSES:
        return [], [
            f"Invalid status '{status}'. "
            f"Valid: {', '.join(sorted(VALID_STATUSES))}"
        ]

    results: list[Annotation] = []
    all_errors: list[str] = []

    for fid in finding_ids:
        ann, errors = annotate_finding(
            fid, status, note, author, store,
        )
        if errors:
            all_errors.extend(errors)
        else:
            results.append(ann)

    return results, all_errors


def remove_annotation(
    finding_id: str,
    store: AnnotationStore,
) -> bool:
    """Remove an annotation from the store.

    Args:
        finding_id: Finding ID to remove.
        store: AnnotationStore to modify.

    Returns:
        True if removed, False if not found.
    """
    if finding_id in store.annotations:
        del store.annotations[finding_id]
        return True
    return False


# ---------------------------------------------------------------------------
# Filtering
# ---------------------------------------------------------------------------

def filter_annotated(
    findings: list[dict[str, Any]],
    annotations: dict[str, Any] | AnnotationStore,
    exclude_statuses: frozenset[str] | None = None,
) -> list[dict[str, Any]]:
    """Filter findings by removing annotated ones.

    Args:
        findings: List of finding dicts.
        annotations: Annotations dict or AnnotationStore.
        exclude_statuses: Statuses to exclude (default: FP, accepted, wont_fix).

    Returns:
        Filtered list of findings.
    """
    if exclude_statuses is None:
        exclude_statuses = EXCLUDE_STATUSES

    if isinstance(annotations, AnnotationStore):
        store = annotations
    else:
        store = AnnotationStore.from_dict(annotations)

    result: list[dict[str, Any]] = []
    for f in findings:
        fid = make_finding_id_from_dict(f)
        ann = store.get(fid)
        if ann is not None and ann.status in exclude_statuses:
            continue
        result.append(f)
    return result


def filter_by_status(
    findings: list[dict[str, Any]],
    annotations: dict[str, Any] | AnnotationStore,
    status: str,
) -> list[dict[str, Any]]:
    """Get findings that have a specific annotation status.

    Args:
        findings: List of finding dicts.
        annotations: Annotations dict or AnnotationStore.
        status: Status to match.

    Returns:
        Findings with matching annotation status.
    """
    if isinstance(annotations, AnnotationStore):
        store = annotations
    else:
        store = AnnotationStore.from_dict(annotations)

    result: list[dict[str, Any]] = []
    for f in findings:
        fid = make_finding_id_from_dict(f)
        ann = store.get(fid)
        if ann is not None and ann.status == status:
            result.append(f)
    return result


# ---------------------------------------------------------------------------
# Persistence
# ---------------------------------------------------------------------------

def load_annotations(
    path: str | Path = DEFAULT_ANNOTATIONS_FILE,
) -> AnnotationStore:
    """Load annotations from a JSON file.

    Args:
        path: Path to the annotations file.

    Returns:
        AnnotationStore (empty if file doesn't exist).
    """
    filepath = Path(path)
    if not filepath.exists():
        return AnnotationStore()

    try:
        text = filepath.read_text(encoding="utf-8")
    except (OSError, UnicodeDecodeError):
        return AnnotationStore()

    try:
        data = json.loads(text)
    except json.JSONDecodeError:
        return AnnotationStore()

    if not isinstance(data, dict):
        return AnnotationStore()

    return AnnotationStore.from_dict(data)


def save_annotations(
    store: AnnotationStore,
    path: str | Path = DEFAULT_ANNOTATIONS_FILE,
) -> None:
    """Save annotations to a JSON file.

    Args:
        store: AnnotationStore to persist.
        path: Output file path.
    """
    filepath = Path(path)
    data = store.to_dict()
    filepath.write_text(
        json.dumps(data, indent=2, ensure_ascii=False) + "\n",
        encoding="utf-8",
    )


def load_and_annotate(
    finding_id: str,
    status: str,
    note: str = "",
    author: str = "",
    path: str | Path = DEFAULT_ANNOTATIONS_FILE,
) -> tuple[Annotation, list[str]]:
    """Load annotations, add/update one, and save back.

    Convenience function for CLI usage.

    Args:
        finding_id: Finding ID.
        status: Annotation status.
        note: Optional note.
        author: Who annotated.
        path: Annotations file path.

    Returns:
        Tuple of (Annotation, errors).
    """
    store = load_annotations(path)
    ann, errors = annotate_finding(
        finding_id, status, note, author, store,
    )
    if not errors:
        save_annotations(store, path)
    return ann, errors


# ---------------------------------------------------------------------------
# Formatting
# ---------------------------------------------------------------------------

def format_annotations_text(store: AnnotationStore) -> str:
    """Format annotations as human-readable text.

    Args:
        store: AnnotationStore to format.

    Returns:
        Formatted text.
    """
    if store.count == 0:
        return "No annotations.\n"

    lines: list[str] = [
        f"Annotations ({store.count})",
        "=" * 60,
    ]

    stats = store.stats()
    for status, count in sorted(stats.items()):
        lines.append(f"  {status}: {count}")
    lines.append("")

    for ann in store.list_all():
        lines.append(f"  [{ann.status}] {ann.finding_id}")
        if ann.note:
            lines.append(f"    Note: {ann.note}")
        if ann.author:
            lines.append(f"    Author: {ann.author}")
        lines.append(f"    Created: {ann.created_at}")
        if ann.updated_at != ann.created_at:
            lines.append(f"    Updated: {ann.updated_at}")

    return "\n".join(lines) + "\n"


def format_annotations_json(store: AnnotationStore) -> str:
    """Format annotations as JSON.

    Args:
        store: AnnotationStore to format.

    Returns:
        JSON string.
    """
    data = {
        "annotations": store.to_dict(),
        "count": store.count,
        "stats": store.stats(),
    }
    return json.dumps(data, indent=2, ensure_ascii=False)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _now_iso() -> str:
    """Return current UTC time as ISO string."""
    return (
        datetime.now(timezone.utc)
        .strftime("%Y-%m-%dT%H:%M:%SZ")
    )
