"""Skill persistence — write SKILL.md and bundled files to ``skills/``.

Receives validated payloads from the sandbox via the ``skill.save``
action and renders them onto disk. The on-disk format matches the
loader's expectations:

- ``skills/<name>/SKILL.md`` — frontmatter (name, description) + body
- ``skills/<name>/<resource>`` — Level 3 sub-docs at the skill root
- ``skills/<name>/scripts/<script>`` — bundled Python helpers
- ``skills/<name>/scripts/__init__.py`` — auto-stamped so
  ``from skills.<name>.scripts import <module>`` resolves inside
  ``execute_script`` (the sandbox bootstrap puts ``skills/`` on
  ``sys.path``).

Validation runs again here, on the trusted side — the sandbox-side
SDK validators are convenience, not security. Path components are
resolved and asserted to live under the skills root.

Conflict policy: never overwrite. If ``skills/<name>/`` already
exists, return ``status: "exists"`` and write nothing. Use a future
``skill.delete`` action to clear and recreate.
"""

from __future__ import annotations

import logging
import re
from pathlib import Path
from typing import Any, Iterable

logger = logging.getLogger(__name__)

# Mirror the loader's defaults so writer and reader always agree on
# the directory layout.
from boxbot.skills.loader import _DEFAULT_SKILLS_ROOT, _SKILL_FILE

# Anthropic Agent Skills caps + reserved words. Duplicated here so
# the writer is self-contained and doesn't depend on the SDK
# validators (which run in the sandbox).
_NAME_MAX_LEN = 64
_DESCRIPTION_MAX_LEN = 1024
_RESERVED_NAMES = {"anthropic", "claude"}
_NAME_PATTERN = re.compile(r"^[a-z0-9_-]+$")
_BARE_BASENAME = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.-]*$")

# Auto-stamped header on the generated scripts/__init__.py so contributors
# reading the file know not to hand-edit it.
_SCRIPTS_INIT_HEADER = (
    '"""Auto-generated by bb.skill.save() so '
    "`from skills.<name>.scripts import <module>` resolves."
    '"""\n'
)

# Sentinel file stamped into every skill directory written by
# :func:`write_skill`. ``delete_skill`` refuses to remove any skill that
# lacks this marker so built-in skills (shipped under ``<repo>/skills/``
# without the marker) can never be deleted by the agent. Pre-existing
# agent-authored skills that pre-date this marker need a manual one-time
# ``touch`` of this file to become deletable.
_AGENT_MARKER = ".agent-authored"


class SkillWriteError(Exception):
    """Raised when a skill payload is invalid or the target conflicts."""

    def __init__(self, status: str, message: str) -> None:
        super().__init__(message)
        self.status = status  # "error" or "exists"
        self.message = message


def _validate_name(name: Any) -> str:
    if not isinstance(name, str) or not name:
        raise SkillWriteError("error", "skill name must be a non-empty string")
    if len(name) > _NAME_MAX_LEN:
        raise SkillWriteError(
            "error", f"skill name must be ≤{_NAME_MAX_LEN} chars, got {len(name)}"
        )
    if name != name.lower():
        raise SkillWriteError("error", f"skill name must be lowercase, got '{name}'")
    if name in _RESERVED_NAMES:
        raise SkillWriteError("error", f"'{name}' is a reserved skill name")
    if not _NAME_PATTERN.match(name):
        raise SkillWriteError(
            "error",
            f"skill name must match [a-z0-9_-]+, got '{name}'",
        )
    return name


def _validate_description(desc: Any) -> str:
    if not isinstance(desc, str) or not desc.strip():
        raise SkillWriteError("error", "description must be a non-empty string")
    if len(desc) > _DESCRIPTION_MAX_LEN:
        raise SkillWriteError(
            "error",
            f"description must be ≤{_DESCRIPTION_MAX_LEN} chars, got {len(desc)}",
        )
    if "<" in desc or ">" in desc:
        raise SkillWriteError("error", "description must not contain XML brackets")
    return desc


def _validate_body(body: Any) -> str:
    if not isinstance(body, str) or not body.strip():
        raise SkillWriteError("error", "body must be a non-empty string")
    return body


def _validate_basename(filename: Any, kind: str) -> str:
    if not isinstance(filename, str) or not filename:
        raise SkillWriteError("error", f"{kind} filename must be a non-empty string")
    if "/" in filename or "\\" in filename:
        raise SkillWriteError(
            "error", f"{kind} filename must be a bare basename, got '{filename}'"
        )
    if filename.startswith(".") or not _BARE_BASENAME.match(filename):
        raise SkillWriteError(
            "error", f"{kind} filename '{filename}' is not a valid basename"
        )
    return filename


def _yaml_escape_inline(value: str) -> str:
    """Quote a string for inline YAML if it contains characters PyYAML would
    otherwise interpret. We use double quotes and escape only the two chars
    that matter inside double-quoted YAML scalars: backslash and double quote.
    """
    needs_quotes = any(c in value for c in ":#'\"\n") or value != value.strip()
    if not needs_quotes:
        return value
    escaped = value.replace("\\", "\\\\").replace('"', '\\"')
    # YAML double-quoted strings can't contain raw newlines; collapse them.
    escaped = escaped.replace("\n", "\\n").replace("\r", "\\r")
    return f'"{escaped}"'


def _render_frontmatter(name: str, description: str) -> str:
    return (
        "---\n"
        f"name: {_yaml_escape_inline(name)}\n"
        f"description: {_yaml_escape_inline(description)}\n"
        "---\n\n"
    )


def _render_skill_md(name: str, description: str, body: str) -> str:
    fm = _render_frontmatter(name, description)
    if not body.endswith("\n"):
        body = body + "\n"
    return fm + body


def _coerce_named_list(items: Any, kind: str) -> list[dict[str, str]]:
    """Validate the shape of ``scripts`` / ``resources`` payloads."""
    if items is None:
        return []
    if not isinstance(items, list):
        raise SkillWriteError("error", f"{kind} must be a list")
    out: list[dict[str, str]] = []
    seen: set[str] = set()
    for i, item in enumerate(items):
        if not isinstance(item, dict):
            raise SkillWriteError("error", f"{kind}[{i}] must be a dict")
        filename = _validate_basename(item.get("filename"), f"{kind}[{i}]")
        content = item.get("content")
        if not isinstance(content, str):
            raise SkillWriteError("error", f"{kind}[{i}] content must be a string")
        if filename in seen:
            raise SkillWriteError(
                "error", f"duplicate {kind} filename '{filename}'"
            )
        seen.add(filename)
        out.append({"filename": filename, "content": content})
    return out


def _resolve_target(name: str, root: Path) -> Path:
    """Resolve ``skills/<name>`` against ``root`` and refuse paths that
    escape the skills root, even though the name validator should have
    already rejected anything dangerous."""
    target = (root / name).resolve()
    root_resolved = root.resolve()
    try:
        target.relative_to(root_resolved)
    except ValueError as exc:
        raise SkillWriteError(
            "error", f"resolved path escapes skills root: {target}"
        ) from exc
    return target


def write_skill(
    payload: dict[str, Any],
    *,
    skills_root: Path | None = None,
) -> dict[str, Any]:
    """Write a validated skill payload to disk.

    Returns a result dict suitable for the sandbox action response:
    - ``{"status": "ok", "name": ..., "path": ..., "files": [...]}``
    - ``{"status": "exists", "name": ..., "path": ...}``  (no write)
    - ``{"status": "error", "message": ...}``  (no write)

    Never raises for validation/conflict cases — those are returned as
    structured responses so the agent sees them. Unexpected I/O errors
    do propagate.
    """
    try:
        name = _validate_name(payload.get("name"))
        description = _validate_description(payload.get("description"))
        body = _validate_body(payload.get("body"))
        scripts = _coerce_named_list(payload.get("scripts"), "scripts")
        resources = _coerce_named_list(payload.get("resources"), "resources")
    except SkillWriteError as exc:
        return {"status": exc.status, "message": exc.message}

    # Reject SKILL.md as a resource name — it's reserved for the body.
    for r in resources:
        if r["filename"] == _SKILL_FILE:
            return {
                "status": "error",
                "message": f"'{_SKILL_FILE}' is reserved — set the body instead",
            }

    root = (skills_root or _DEFAULT_SKILLS_ROOT)
    try:
        target = _resolve_target(name, root)
    except SkillWriteError as exc:
        return {"status": exc.status, "message": exc.message}

    if target.exists():
        return {
            "status": "exists",
            "name": name,
            "path": str(target),
            "message": (
                f"skill '{name}' already exists at {target}; "
                "delete it first or pick a different name"
            ),
        }

    # Create the skill directory and write everything. We don't bother
    # with rollback on partial failure: the directory didn't exist before,
    # so a partial state is at worst a half-written skill that the user
    # can `rm -rf`. mkdir-then-write is the simplest correct shape.
    target.mkdir(parents=True, exist_ok=False)
    written: list[str] = []

    # Stamp the agent-authored marker first so even a partial write
    # leaves the dir clearly identifiable as agent-owned (and therefore
    # deletable).
    (target / _AGENT_MARKER).write_text("", encoding="utf-8")

    skill_md = target / _SKILL_FILE
    skill_md.write_text(_render_skill_md(name, description, body), encoding="utf-8")
    written.append(_SKILL_FILE)

    for r in resources:
        path = target / r["filename"]
        path.write_text(r["content"], encoding="utf-8")
        written.append(r["filename"])

    if scripts:
        scripts_dir = target / "scripts"
        scripts_dir.mkdir(exist_ok=False)
        init_path = scripts_dir / "__init__.py"
        init_path.write_text(_SCRIPTS_INIT_HEADER, encoding="utf-8")
        written.append("scripts/__init__.py")
        for script in scripts:
            path = scripts_dir / script["filename"]
            path.write_text(script["content"], encoding="utf-8")
            written.append(f"scripts/{script['filename']}")

    logger.info("Wrote skill '%s' to %s (%d files)", name, target, len(written))

    return {
        "status": "ok",
        "name": name,
        "path": str(target),
        "files": written,
    }


def delete_skill(
    payload: dict[str, Any],
    *,
    skills_root: Path | None = None,
) -> dict[str, Any]:
    """Remove an agent-authored skill directory.

    Refuses to delete skills that lack the ``.agent-authored`` marker —
    that is, anything that wasn't created by :func:`write_skill`. This
    is the only thing keeping ``bb.skill.delete("bb")`` from wiping a
    built-in.

    Result shapes mirror :func:`write_skill`:

    - ``{"status": "ok", "name": ..., "path": ...}`` — removed
    - ``{"status": "not_found", "name": ..., "path": ...}`` — no such dir
    - ``{"status": "forbidden", "name": ..., "path": ...}`` — exists but
      not agent-authored
    - ``{"status": "error", "message": ...}`` — validation or I/O error
    """
    try:
        name = _validate_name(payload.get("name"))
    except SkillWriteError as exc:
        return {"status": exc.status, "message": exc.message}

    root = (skills_root or _DEFAULT_SKILLS_ROOT)
    try:
        target = _resolve_target(name, root)
    except SkillWriteError as exc:
        return {"status": exc.status, "message": exc.message}

    if not target.exists():
        return {
            "status": "not_found",
            "name": name,
            "path": str(target),
            "message": f"skill '{name}' does not exist",
        }

    marker = target / _AGENT_MARKER
    if not marker.exists():
        return {
            "status": "forbidden",
            "name": name,
            "path": str(target),
            "message": (
                f"skill '{name}' is not agent-authored (no {_AGENT_MARKER} "
                "marker); only skills created via bb.skill.save can be deleted"
            ),
        }

    import shutil
    shutil.rmtree(target)
    logger.info("Deleted skill '%s' at %s", name, target)
    return {
        "status": "ok",
        "name": name,
        "path": str(target),
    }
