from __future__ import annotations

import json
import os
import re
import sys
from dataclasses import dataclass
from datetime import UTC, datetime
from hashlib import sha256
from pathlib import Path
from typing import Any

from hooklib.agent_conventions import resolve_project_dir


def _project_config_dir_name() -> str:
    """Return the project-level hidden directory name for the active tool.

    Reads AGENTS_TOOL from the environment; falls back to '.devin'.
    """
    tool = os.environ.get("AGENTS_TOOL", "")
    if tool:
        return resolve_project_dir(tool)
    return resolve_project_dir(None)


def utc_now_iso() -> str:
    """Return the current UTC time as an ISO-8601 formatted string."""
    return datetime.now(UTC).isoformat()


def project_dir() -> Path:
    """Return the project root directory from DEVIN_PROJECT_DIR env var."""
    return Path(os.environ.get("DEVIN_PROJECT_DIR", ".")).resolve()


def agent_dir() -> Path:
    """Return the tool-specific project config directory (e.g. .devin, .opencode)."""
    path = project_dir() / _project_config_dir_name()
    path.mkdir(parents=True, exist_ok=True)
    return path


def devin_dir() -> Path:
    """Backward-compatible alias that respects the AGENTS_TOOL env var."""
    return agent_dir()


def cache_dir(name: str) -> Path:
    """Return a cache subdirectory under the agent directory, creating it if needed."""
    path = agent_dir() / "cache" / name
    path.mkdir(parents=True, exist_ok=True)
    return path


def logs_dir() -> Path:
    """Return the logs directory under the agent directory, creating it if needed."""
    path = agent_dir() / "logs"
    path.mkdir(parents=True, exist_ok=True)
    return path


def reports_dir() -> Path:
    """Return the reports directory under the agent directory, creating it if needed."""
    path = agent_dir() / "reports"
    path.mkdir(parents=True, exist_ok=True)
    return path


def stderr(message: str) -> None:
    """Write a diagnostic message to stderr for hook output capture."""
    sys.stderr.write(message + "\n")


def append_jsonl(path: Path, record: dict[str, Any]) -> None:
    """Append a JSON record as a new line to a JSONL file."""
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("a", encoding="utf-8") as handle:
        handle.write(json.dumps(record, sort_keys=True) + "\n")


def read_json(path: Path, default: Any = None) -> Any:
    """Read a JSON file, returning *default* if the file is missing or invalid."""
    try:
        with path.open(encoding="utf-8") as handle:
            result: Any = json.load(handle)
            return result
    except (FileNotFoundError, json.JSONDecodeError):
        return default


def write_json(path: Path, data: Any) -> None:
    """Write *data* as indented JSON to *path*."""
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("w", encoding="utf-8") as handle:
        json.dump(data, handle, indent=2, sort_keys=True)
        handle.write("\n")


def stable_hash(value: str) -> str:
    """Return a deterministic SHA-256 hex digest for *value*."""
    return sha256(value.encode("utf-8")).hexdigest()


def parse_mcp_tool_name(
    tool_name: str,
    tool_input: dict[str, Any] | None = None,
) -> tuple[str | None, str | None]:
    """Parse an MCP tool name into (server_name, tool_name) components.

    Args:
        tool_name: The full tool name (e.g. 'mcp__time__get_current_time').
        tool_input: The tool input dict, used for 'mcp_call_tool' fallback.

    Returns:
        A tuple of (server_name, tool_name) or (None, None) if not an MCP tool.
    """
    if not tool_name.startswith("mcp__"):
        if tool_name == "mcp_call_tool" and isinstance(tool_input, dict):
            server_name: Any = tool_input.get("server_name")
            actual_tool_name: Any = tool_input.get("tool_name")
            if isinstance(server_name, str) and isinstance(actual_tool_name, str):
                return server_name, actual_tool_name
        return None, None

    parts = tool_name.split("__")
    if len(parts) < 3:
        return None, None

    return parts[1], "__".join(parts[2:])


def sanitize_filename(value: str) -> str:
    """Replace non-filename-safe characters in *value* with hyphens."""
    return re.sub(r"[^A-Za-z0-9._-]+", "-", value).strip("-") or "unknown"


def truncate_text(value: str, limit: int = 400) -> str:
    """Truncate *value* to *limit* characters, appending a notice if trimmed."""
    if len(value) <= limit:
        return value
    return f"{value[:limit]}... ({len(value) - limit} chars trimmed)"


@dataclass
class HookContext:
    """Immutable snapshot of a hook event payload with convenience accessors.

    Attributes:
        payload: The raw hook event dictionary from stdin.
        fallback_event_name: Used when the payload does not include an event name.
    """

    payload: dict[str, Any]
    fallback_event_name: str | None = None

    @property
    def event_name(self) -> str:
        """Resolve the event name from payload or fallback."""
        event: Any = self.payload.get("hook_event_name") or self.payload.get("hookEventName") or self.fallback_event_name or "unknown"
        return str(event)

    @property
    def tool_name(self) -> str:
        """Return the tool name from the payload."""
        name: Any = self.payload.get("tool_name", "")
        return str(name)

    @property
    def tool_input(self) -> dict[str, Any]:
        """Return the tool input dict from the payload (always a dict)."""
        tool_input: Any = self.payload.get("tool_input", {})
        return tool_input if isinstance(tool_input, dict) else {}

    @property
    def tool_response(self) -> dict[str, Any]:
        """Return the tool response dict from the payload (always a dict)."""
        tool_response: Any = self.payload.get("tool_response", {})
        return tool_response if isinstance(tool_response, dict) else {}

    @property
    def server_name(self) -> str | None:
        """Extract the MCP server name from the tool name."""
        server_name, _tool_name = parse_mcp_tool_name(self.tool_name, self.tool_input)
        return server_name

    @property
    def mcp_tool_name(self) -> str | None:
        """Extract the MCP tool name from the tool name."""
        _server_name, tool_name = parse_mcp_tool_name(self.tool_name, self.tool_input)
        return tool_name

    def log(self, channel: str, **extra: Any) -> None:
        """Write an event record to the hook-events JSONL log."""
        record: dict[str, Any] = {
            "timestamp": utc_now_iso(),
            "channel": channel,
            "event_name": self.event_name,
            "tool_name": self.tool_name or None,
            "server_name": self.server_name,
            "mcp_tool_name": self.mcp_tool_name,
        }
        record.update(extra)
        append_jsonl(logs_dir() / "hook-events.jsonl", record)

    def emit_decision(self, decision: str, reason: str) -> None:
        """Emit a hook decision (placeholder for future hook output support)."""
        _ = (decision, reason)


def read_hook_context(*, fallback_event_name: str | None = None) -> HookContext:
    """Read hook event data from stdin and return a HookContext.

    On invalid JSON, logs the error and exits cleanly so the hook chain
    is not blocked.
    """
    raw = sys.stdin.read()
    if not raw.strip():
        payload: dict[str, Any] = {}
    else:
        try:
            payload = json.loads(raw)
        except json.JSONDecodeError as exc:
            append_jsonl(
                logs_dir() / "hook-errors.jsonl",
                {
                    "timestamp": utc_now_iso(),
                    "event_name": fallback_event_name or "unknown",
                    "error_type": "invalid_hook_json",
                    "error": str(exc),
                    "stdin_preview": truncate_text(raw, 800),
                },
            )
            stderr(f"Invalid hook JSON: {exc}")
            raise SystemExit(0) from None

    return HookContext(payload=payload, fallback_event_name=fallback_event_name)
