"""Render a set of enabled module template trees into a vault.

Most Core ships as literal files copied verbatim (so an agent-driven update is just a
file copy); only ``*.j2`` files are rendered through Jinja2 from the vault's config.

**Prefix mapping** — a file's location under a module's template dir decides its vault
destination and its Core/Config/Content bucket:

    AGENTS.md.j2          -> AGENTS.md                 (core)
    Agent/**         -> Agent/**             (core)
    skills/<name>.md      -> Agent/skills/<name>.md    (core — AND the generated
                             Claude Code adapter .claude/skills/<name>/SKILL.md)
    agents/<name>.md      -> Agent/agents/<name>.md    (core — AND the generated
                             Claude Code adapter .claude/agents/<name>.md)
    .claude/**            -> .claude/**                (core)
    .github/**            -> .github/**                (core)
    _config/**            -> Agent/**             (config — never overwritten on update)
    _content/**           -> **  (vault root)          (content — seeded once)
    module.yml, tests/**  -> (not rendered)

**Path expansion** — ``{{area}}`` in a destination path loops over the config's areas
(one rendered file per area); ``{{owner}}`` substitutes the owner's name (to place the
owner's personal-area scaffolds, e.g. ``Concepts/``, under ``Areas/<owner>/``);
``{{current_year}}`` substitutes the current year. The default ``Travel`` area is
scaffolded from a static ``_content/Areas/Travel`` tree, so it is skipped in the per-area loop.
"""
from __future__ import annotations

import datetime as dt
import os
import shutil
from pathlib import Path

import jinja2

SKIP_TOP_FILES = {"module.yml"}
# Only skip Python cache dirs. (Agent/scripts/tests/ — e.g. the shadow-diff harness —
# is a real in-vault tool and MUST ship; the package's own CI tests live at the repo root,
# outside any module template tree, so they're never walked here.)
SKIP_DIRS = {"__pycache__"}
RESERVED_AREAS = {"travel"}  # scaffolded statically, not via the per-area loop

# The owner-name sentinel. A neutral pre-personalization vault has no owner name yet, so
# templates render ``{{ owner.short_name or owner.name or OWNER_PLACEHOLDER }}`` (and the
# ``{{ owner.name or OWNER_PLACEHOLDER }}`` full-name form) — every owner-name slot then
# holds ONE consistent, greppable, obviously-a-placeholder token instead of an empty hole.
# The `settings` first-run pass find/replaces this token → the owner's name across every
# file at once (mirroring how the agent-name default "Alexandria" is propagated). Keep this
# value in sync with the `settings` skill, which greps the vault for it.
OWNER_PLACEHOLDER = "«Owner»"


def _digest_cron(config: dict) -> str:
    """A daily UTC cron for the GitHub Actions runner, derived from
    automation.send_hour_local + owner.timezone_default. UTC-only, so it may drift +/-1h
    across DST — the digest *content* stays travel-correct via DIGEST_TODAY regardless."""
    auto = config.get("automation") or {}
    sh = int(auto.get("send_hour_local", 9))
    tzname = (config.get("owner") or {}).get("timezone_default") or "UTC"
    utc_hour = sh
    try:
        from zoneinfo import ZoneInfo
        ref = dt.datetime(dt.date.today().year, dt.date.today().month,
                          dt.date.today().day, 12, tzinfo=ZoneInfo(tzname))
        off_h = int((ref.utcoffset() or dt.timedelta()).total_seconds() // 3600)
        utc_hour = (sh - off_h) % 24
    except Exception:
        pass
    return f"0 {utc_hour} * * *"


def build_context(config: dict, *, build=None, modules_info=None) -> dict:
    today = dt.date.today()
    return {
        "digest_cron": _digest_cron(config),
        "alexandria": config.get("alexandria") or {},
        "owner": config.get("owner") or {},
        "OWNER_PLACEHOLDER": OWNER_PLACEHOLDER,  # sentinel for unset owner name (neutral vault)
        "agent": config.get("agent") or {},
        "areas": config.get("areas") or [],
        "owner_digests": config.get("owner_digests") or [],
        "team": config.get("team") or [],
        "peers": config.get("peers") or [],
        "automation": config.get("automation") or {},
        "features": config.get("features") or {},
        "vault_mcp": config.get("vault_mcp") or {},
        # Falsy when no remote is configured — templates branch on `{% if repo %}`
        # and describe the vault as local-only. Never default a placeholder here:
        # one used to render into AGENTS.md as a claimed sync target.
        "repo": config.get("repo") or "",
        "today": today.isoformat(),
        "current_year": today.year,
        # Source-build identity + feature manifest — for Agent/Version.md (W5). Defaulted so
        # a direct render_vault() call (e.g. the test harness) still renders that note clean.
        "build": build or {},
        "modules_info": modules_info if modules_info is not None else {"enabled": [], "available": []},
    }


def _dest_and_bucket(relpath: str):
    parts = relpath.split("/")
    if relpath == "AGENTS.md.j2":
        return "AGENTS.md", "core"
    head = parts[0]
    if head == "_config":
        return "Agent/" + "/".join(parts[1:]), "config"
    if head == "_content":
        return "/".join(parts[1:]), "content"
    if head == "skills":
        return "Agent/skills/" + "/".join(parts[1:]), "core"
    if head == "agents":
        return "Agent/agents/" + "/".join(parts[1:]), "core"
    if head in ("Agent", ".claude", ".github"):
        return relpath, "core"
    return relpath, "core"


PLAYBOOK_PREFIXES = ("Agent/skills/", ".claude/skills/",
                     "Agent/agents/", ".claude/agents/")


def is_playbook(rel: str) -> bool:
    """Is this vault path a playbook — the neutral file or its Claude Code adapter?

    Playbooks *document* the owner-name sentinel (the interview that replaces it is
    described in one), so every sentinel sweep must skip them: substituting there
    corrupts the documentation instead of personalizing a file. This used to be a
    bare ``.claude/`` check, which was correct only for as long as playbooks lived
    exclusively in a vendor folder."""
    return any(rel.startswith(p) for p in PLAYBOOK_PREFIXES)


def claude_skill_dest(dest: str):
    """The Claude Code adapter path for a rendered playbook, or ``None``.

    Playbooks are authored once, under a module's ``skills/``, and land in the
    vault at ``Agent/skills/<name>.md`` — inside the brain's own folder, and so
    inside the MCP jail, where every client can reach them. Claude Code discovers
    skills only at ``.claude/skills/<name>/SKILL.md``, so that path is *generated*
    from the same bytes rather than authored beside them: one source, two
    destinations, no drift. ``.claude/`` is a vendor adapter, not content — which
    is why AGENTS.md tells agents never to read it as content."""
    if not (dest.startswith("Agent/skills/") and dest.endswith(".md")):
        return None
    return f".claude/skills/{Path(dest).stem}/SKILL.md"


def claude_agent_dest(dest: str):
    """The Claude Code adapter path for a rendered subagent definition, or ``None``.

    Same rule as playbooks: a subagent (the `retrieve` retrieval specialist,
    2026-08-22) is authored once under a module's ``agents/`` and lands at
    ``Agent/agents/<name>.md`` — inside the MCP jail, so a served-brain client
    can read the definition and delegate the same way — while Claude Code
    discovers subagents only at ``.claude/agents/<name>.md``. That copy is
    generated from the same bytes, never authored."""
    if not (dest.startswith("Agent/agents/") and dest.endswith(".md")):
        return None
    return f".claude/agents/{Path(dest).name}"


def is_safe_segment(name: str) -> bool:
    """Is ``name`` usable as ONE path component substituted into a destination?

    Area names and the owner's name come from config and are interpolated
    straight into ``{{area}}`` / ``{{owner}}`` destination paths, so a name
    carrying a separator or ``..`` walks the render out of the vault — and
    config is writable over MCP. Anything that is not a single, ordinary
    component is refused here."""
    name = str(name)
    return bool(name.strip()) and not (
        "/" in name or "\\" in name or "\x00" in name
        or name.strip() in (".", "..") or name.strip().startswith(".."))


def _safe_dest(vault: Path, dest: str) -> Path:
    """The absolute path ``dest`` names inside ``vault`` — or ``ValueError``.

    The backstop behind :func:`is_safe_segment`, closing the two ways a write
    lands somewhere the vault's safety net cannot reach. Damage outside the
    repository is invisible to `git reset --hard` and absent from the durability
    bundle, so it is the one kind this renderer must be structurally unable to do.

    1. **Traversal.** ``normpath`` first: ``Path.relative_to`` is purely lexical
       and happily accepts ``Areas/../../X``.
    2. **Symlinked parents.** A directory component that is a symlink diverts an
       entire subtree, so the parent's *real* path must still be inside the vault.
    """
    root = os.path.realpath(vault)
    target = os.path.normpath(os.path.join(root, dest))
    if target != root and not target.startswith(root + os.sep):
        raise ValueError(f"refusing to render outside the vault: {dest!r}")
    parent = os.path.realpath(os.path.dirname(target))
    if parent != root and not parent.startswith(root + os.sep):
        raise ValueError(
            f"refusing to render through a symlinked directory: {dest!r}")
    return Path(target)


def _atomic_write(dest: Path, data: bytes) -> None:
    """Write ``data`` to ``dest`` so the file is only ever whole.

    A hosted brain renders its Core at boot, and a boot is exactly when it is
    likely to be killed: the container gets ~10s of SIGTERM grace, which bash
    does not forward to the renderer, and then SIGKILL. A plain ``write_text``
    truncates first, so a kill lands on a zero-length or half-written file — and
    the post-update health gate only looks at AGENTS.md, so a truncated skill or
    prompt sails through and the next boot commits it. Writing to a sibling temp
    and renaming means an interrupted render leaves every file either wholly old
    or wholly new, never in between."""
    tmp = dest.with_name(f".{dest.name}.alexandria-tmp")
    try:
        with open(tmp, "wb") as fh:
            fh.write(data)
            fh.flush()
            os.fsync(fh.fileno())          # the rename is only atomic if the bytes landed
        os.replace(tmp, dest)
    finally:
        if tmp.exists():
            tmp.unlink()                   # a failed write leaves no debris behind


def _strip_j2(p: str) -> str:
    return p[:-3] if p.endswith(".j2") else p


def _subst_year(p: str, year) -> str:
    return p.replace("{{current_year}}", str(year)).replace("{{ current_year }}", str(year))


def _subst_owner(p: str, owner_name: str) -> str:
    """Substitute ``{{owner}}`` in a destination path with the owner's name — used to
    place the owner's personal-area scaffolds (e.g. ``Concepts/``) under ``Areas/<owner>/``."""
    return p.replace("{{owner}}", owner_name).replace("{{ owner }}", owner_name)


def iter_module_files(module_dir: str):
    base = Path(module_dir)
    for root, dirs, files in os.walk(base):
        dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
        for f in files:
            full = Path(root) / f
            rel = full.relative_to(base).as_posix()
            if rel in SKIP_TOP_FILES:
                continue
            yield full, rel


def plan_module(module_dir: str, context: dict):
    """Yield (src_path, dest_relpath, bucket, is_template, extra_ctx) for one module.

    For a ``{{area}}`` path, render once per configured area (skipping the reserved Travel
    area), exposing that area's ``area`` (name), ``area_description``, and
    to the template so the home note renders with real content."""
    # A name that is not a single safe path component is dropped rather than
    # interpolated. Skipping one area's scaffold degrades that area; letting the
    # name through would write outside the vault entirely. Deliberately not an
    # exception: a poisoned config must not be able to freeze a brain's updates.
    area_objs = []
    for a in context["areas"]:
        name = a.get("name")
        if not name or name.strip().lower() in RESERVED_AREAS:
            continue
        if not is_safe_segment(name):
            print(f"  WARNING: skipping area {name!r} — an area name must be one "
                  "plain folder name (no '/', no '..')")
            continue
        area_objs.append(a)
    year = context["current_year"]
    owner_name = (context.get("owner") or {}).get("name", "")
    if owner_name and not is_safe_segment(owner_name):
        print(f"  WARNING: owner name {owner_name!r} is not a usable folder name — "
              "the owner's personal-area scaffolds are skipped")
        owner_name = ""                       # same path as a not-yet-named owner
    for full, rel in iter_module_files(module_dir):
        dest0, bucket = _dest_and_bucket(rel)
        # Classify template-ness by the SOURCE name: _dest_and_bucket special-cases
        # AGENTS.md.j2 and returns its dest already stripped to "AGENTS.md", so keying off
        # dest0 here would (wrongly) treat it as a static copy and never render its Jinja.
        is_template = rel.endswith(".j2")
        # A neutral pre-personalization vault has no owner name yet (the owner sets it at
        # first-run). Skip the owner's personal-area scaffolds rather than emit a malformed
        # `Areas//…` path — the settings first-run creates them later via `alexandria seed-area`.
        if not owner_name and ("{{owner}}" in dest0 or "{{ owner }}" in dest0):
            continue
        dest0 = _subst_owner(_strip_j2(dest0), owner_name)
        if "{{area}}" in dest0 or "{{ area }}" in dest0:
            for a in area_objs:
                name = a["name"]
                d = _subst_year(dest0.replace("{{area}}", name).replace("{{ area }}", name), year)
                yield full, d, bucket, is_template, {
                    "area": name,
                    "area_description": a.get("description", ""),
                    "area_threads": a.get("threads") or [],
                }
        else:
            dest = _subst_year(dest0, year)
            yield full, dest, bucket, is_template, {}
            adapter = claude_skill_dest(dest) or claude_agent_dest(dest)
            if adapter:
                yield full, adapter, bucket, is_template, {}


def render_vault(vault, module_dirs, config, *, buckets=("core", "config", "content"),
                 overwrite_protected=False, build=None, modules_info=None,
                 already_seeded=()):
    """Render the enabled modules into ``vault``.

    ``buckets`` selects which buckets to write (update passes ("core",) to refresh Core
    only). Config/Content files are never overwritten unless ``overwrite_protected`` —
    so re-running init or updating never clobbers a user's notes or tuned Config.
    ``build`` / ``modules_info`` feed Agent/Version.md (the source commit + feature list);
    when omitted, ``build`` is computed from the software checkout so a bare render still
    stamps the commit. Returns the list of written {path, bucket} entries.
    """
    vault = Path(vault)
    if build is None:
        from . import manifest
        build = manifest.source_build_info()
    context = build_context(config, build=build, modules_info=modules_info)
    env = jinja2.Environment(keep_trailing_newline=True)
    written = []
    for module_dir in module_dirs:
        for src, dest, bucket, is_template, extra in plan_module(module_dir, context):
            if bucket not in buckets:
                continue
            dest_path = _safe_dest(vault, dest)
            # `lexists`, not `exists`: a DANGLING symlink at a Config/Content path
            # reads as absent to `exists()`, so the write-once guard let the render
            # straight through it and created the file wherever it pointed.
            if (os.path.lexists(dest_path) and not overwrite_protected
                    and bucket in ("config", "content")):
                # Preserve existing Config + Content, but still record it so the manifest
                # stays a complete inventory across updates.
                written.append({"path": dest, "bucket": bucket, "written": False})
                continue
            # Seeded once, gone on purpose. A Config/Content path that a previous
            # render created and the owner has since DELETED must stay deleted:
            # "write once if absent" alone re-created it on every update, so a
            # note someone deliberately threw away came back every release (and a
            # renamed one came back as a duplicate). `already_seeded` is the
            # previous manifest — brand-new seeds from a newer version aren't in
            # it, so they still land.
            if (not overwrite_protected and bucket in ("config", "content")
                    and dest in already_seeded):
                written.append({"path": dest, "bucket": bucket, "written": False})
                continue
            dest_path.parent.mkdir(parents=True, exist_ok=True)
            # Core is rewritten every update, so a symlink standing where a Core
            # file belongs is replaced by the real file — never followed. Writing
            # through it would edit the target instead, and if the owner pointed
            # it outside the vault that damage survives every rollback.
            if dest_path.is_symlink():
                dest_path.unlink()
            if is_template:
                out = env.from_string(src.read_text(encoding="utf-8")).render(**context, **extra)
                _atomic_write(dest_path, out.encode("utf-8"))
            else:
                _atomic_write(dest_path, src.read_bytes())
            written.append({"path": dest, "bucket": bucket, "written": True})
    return written


def _seed_render(vault, cfg: dict, area: dict, *, owner_name: str = "") -> list:
    """Render ONE Area's content-bucket scaffold into an existing vault (write-once).

    The overlay narrows the config's ``areas`` to exactly this one — the ``{{area}}``
    paths expand for it alone — and, when ``owner_name`` is given, also sets
    ``owner.name`` so the ``{{owner}}``-placed scaffolds (``Concepts/``) land under
    ``Areas/<owner>/``. Content is write-once (:func:`render_vault` skips existing
    files), so this only ever creates. Raises ``ValueError`` on a missing core template
    tree or module-resolve ERRORs — ``iter_module_files`` silently yields nothing for a
    missing dir, so a broken install must be detected here, never inferred from an
    empty render."""
    from . import config as cfgmod, modules as modmod
    core_dir = modmod.template_dir("core")
    if not os.path.isdir(core_dir):
        raise ValueError(f"core template tree not found at {core_dir}")
    ids, probs = modmod.resolve(cfgmod.enabled_modules(cfg))
    errors = [p for p in probs if p.startswith("ERROR")]
    if errors:
        raise ValueError("; ".join(errors))
    overlay = dict(cfg)
    if owner_name:
        overlay["owner"] = {**(cfg.get("owner") or {}), "name": owner_name}
    overlay["areas"] = [area]
    return render_vault(vault, [modmod.template_dir(i) for i in ids], overlay,
                        buckets=("content",))


def seed_personal_area(vault, cfg: dict, name: str) -> list:
    """The owner's personal Area — ``Areas/<name>/`` + the ``Concepts/`` scaffold.

    The settings first-run step the technician install deliberately leaves out (a
    neutral vault has no owner name to render it from). The engine path behind both
    ``alexandria seed-area`` and the vault-MCP ``seed_area`` tool."""
    area = {"name": name,
            "description": f"Personal & life — {name}'s health, family, "
                           "finances, and personal admin.", "threads": []}
    return _seed_render(vault, cfg, area, owner_name=name)


def seed_area_scaffold(vault, cfg: dict, area: dict) -> list:
    """One generic Area's content scaffold (the ``Areas/<Area>/`` home + TODO notes),
    owner untouched — with ``owner.name`` unset the ``{{owner}}`` paths are skipped
    (:func:`plan_module`), which is correct for either interview ordering. The engine
    path behind the vault-MCP ``area_add`` tool's scaffolding."""
    return _seed_render(vault, cfg, area)
