from __future__ import annotations

import re
from collections import defaultdict
from pathlib import Path
from typing import Any, Optional, Tuple

from ..agents.registry import validate_agent_id
from .frontmatter import (
    parse_markdown_frontmatter,
    sanitize_ticket_id,
)
from .models import TicketContextEntry, TicketFrontmatter

# Accept TICKET-###.md or TICKET-###<suffix>.md (suffix optional), case-insensitive.
_TICKET_NAME_RE = re.compile(r"^TICKET-(\d{3,})(?:[^/]*)\.md$", re.IGNORECASE)


def parse_ticket_index(name: str) -> Optional[int]:
    match = _TICKET_NAME_RE.match(name)
    if not match:
        return None
    try:
        return int(match.group(1))
    except ValueError:
        return None


def _as_optional_str(value: Any) -> Optional[str]:
    if isinstance(value, str):
        cleaned = value.strip()
        return cleaned or None
    return None


def _parse_context_entries(
    raw: Any,
) -> Tuple[tuple[TicketContextEntry, ...], list[str]]:
    errors: list[str] = []
    if raw is None:
        return (), errors
    if not isinstance(raw, list):
        return (), ["frontmatter.context must be a list when provided."]

    entries: list[TicketContextEntry] = []
    for idx, item in enumerate(raw):
        if not isinstance(item, dict):
            errors.append(f"frontmatter.context[{idx}] must be a mapping.")
            continue

        path_raw = item.get("path")
        path = _as_optional_str(path_raw)
        if not path:
            errors.append(f"frontmatter.context[{idx}].path is required.")
            continue
        if path.startswith("/"):
            errors.append(f"frontmatter.context[{idx}].path must be repo-relative.")
            continue
        if "\\" in path:
            errors.append(
                f"frontmatter.context[{idx}].path may not include backslashes."
            )
            continue
        if ".." in Path(path).parts:
            errors.append(
                f"frontmatter.context[{idx}].path may not include parent traversal."
            )
            continue

        max_bytes_raw = item.get("max_bytes")
        max_bytes: Optional[int] = None
        if max_bytes_raw is not None:
            if isinstance(max_bytes_raw, int) and max_bytes_raw > 0:
                max_bytes = max_bytes_raw
            else:
                errors.append(
                    f"frontmatter.context[{idx}].max_bytes must be a positive integer when provided."
                )
                continue

        required_raw = item.get("required", False)
        if not isinstance(required_raw, bool):
            errors.append(
                f"frontmatter.context[{idx}].required must be a boolean when provided."
            )
            continue

        entries.append(
            TicketContextEntry(path=path, max_bytes=max_bytes, required=required_raw)
        )

    return tuple(entries), errors


def lint_ticket_frontmatter(
    data: dict[str, Any],
    *,
    fallback_ticket_id: str | None = None,
) -> Tuple[Optional[TicketFrontmatter], list[str]]:
    """Validate and normalize ticket frontmatter.

    Required keys:
    - ticket_id: stable opaque ticket identity (or a caller-supplied fallback)
    - agent: string (or the special value "user")
    - done: bool
    """

    errors: list[str] = []
    if not isinstance(data, dict) or not data:
        return None, ["Missing or invalid YAML frontmatter (expected a mapping)."]

    extra = {k: v for k, v in data.items()}

    raw_ticket_id = data.get("ticket_id")
    ticket_id = sanitize_ticket_id(raw_ticket_id)
    if raw_ticket_id is not None and ticket_id is None:
        errors.append(
            "frontmatter.ticket_id must match [A-Za-z0-9._-]{6,128} when provided."
        )
    if ticket_id is None:
        ticket_id = sanitize_ticket_id(fallback_ticket_id)
    if not ticket_id:
        errors.append(
            "frontmatter.ticket_id is required and must match [A-Za-z0-9._-]{6,128}."
        )

    agent_raw = data.get("agent")
    agent = _as_optional_str(agent_raw)
    if not agent:
        errors.append("frontmatter.agent is required (e.g. 'codex' or 'opencode').")
    else:
        # Special built-in ticket handler.
        if agent != "user":
            try:
                validate_agent_id(agent)
            except ValueError as exc:
                errors.append(f"frontmatter.agent is invalid: {exc}")

    done_raw = data.get("done")
    done: Optional[bool]
    if isinstance(done_raw, bool):
        done = done_raw
    else:
        done = None
        errors.append("frontmatter.done is required and must be a boolean.")

    title = _as_optional_str(data.get("title"))
    goal = _as_optional_str(data.get("goal"))

    # Optional model/reasoning overrides.
    model = _as_optional_str(data.get("model"))
    reasoning = _as_optional_str(data.get("reasoning"))
    context, context_errors = _parse_context_entries(data.get("context"))
    errors.extend(context_errors)

    # Remove normalized keys from extra.
    for key in (
        "ticket_id",
        "agent",
        "done",
        "title",
        "goal",
        "model",
        "reasoning",
        "context",
    ):
        extra.pop(key, None)

    if errors:
        return None, errors

    assert ticket_id is not None
    assert agent is not None
    assert done is not None
    return (
        TicketFrontmatter(
            ticket_id=ticket_id,
            agent=agent,
            done=done,
            title=title,
            goal=goal,
            model=model,
            reasoning=reasoning,
            context=context,
            extra=extra,
        ),
        [],
    )


def lint_dispatch_frontmatter(
    data: dict[str, Any],
) -> Tuple[dict[str, Any], list[str]]:
    """Validate DISPATCH.md frontmatter.

    Keys:
    - mode: "notify" | "pause" | "turn_summary" (defaults to notify)
    """

    errors: list[str] = []
    if not isinstance(data, dict):
        return {}, ["Invalid YAML frontmatter (expected a mapping)."]

    mode_raw = data.get("mode")
    mode = mode_raw.strip().lower() if isinstance(mode_raw, str) else "notify"
    if mode not in ("notify", "pause", "turn_summary"):
        errors.append("frontmatter.mode must be 'notify', 'pause', or 'turn_summary'.")

    normalized = dict(data)
    normalized["mode"] = mode
    return normalized, errors


def lint_ticket_directory(ticket_dir: Path) -> list[str]:
    """Validate ticket directory for duplicate logical identities.

    Returns a list of error messages (empty if valid).

    This check ensures that ticket indices are unique across all ticket files.
    Duplicate indices lead to non-deterministic ordering and confusing behavior.
    """

    if not ticket_dir.exists() or not ticket_dir.is_dir():
        return []

    errors: list[str] = []
    index_to_paths: dict[int, list[str]] = defaultdict(list)
    ticket_id_to_paths: dict[str, list[str]] = defaultdict(list)

    for path in ticket_dir.iterdir():
        if not path.is_file():
            continue
        idx = parse_ticket_index(path.name)
        if idx is None:
            continue
        index_to_paths[idx].append(path.name)
        try:
            raw = path.read_text(encoding="utf-8")
        except OSError:
            continue
        data, _body = parse_markdown_frontmatter(raw)
        ticket_id = sanitize_ticket_id(data.get("ticket_id"))
        if ticket_id:
            ticket_id_to_paths[ticket_id].append(path.name)

    for idx, filenames in index_to_paths.items():
        if len(filenames) > 1:
            filenames_str = ", ".join([f"'{f}'" for f in filenames])
            errors.append(
                f"Duplicate ticket index {idx:03d}: multiple files share the same index ({filenames_str}). "
                "Rename or remove duplicates to ensure deterministic ordering."
            )

    for ticket_id, filenames in ticket_id_to_paths.items():
        if len(filenames) > 1:
            filenames_str = ", ".join([f"'{f}'" for f in filenames])
            errors.append(
                f"Duplicate ticket_id {ticket_id!r}: multiple files share the same logical ticket identity ({filenames_str}). "
                "Backfill or rewrite one of the ticket_ids so ticket-owned state remains unambiguous."
            )

    return errors
