"""Single source of truth for filesystem layout and the ingest backend.

Resolves every path to an absolute Path under the WORKSPACE root. A workspace is any directory
holding a ``citadel.toml`` marker file (a PURE marker — a comment plus a format-version line,
never configuration; scaffold one with ``citadel init``). Discovery order at import time (must
never raise on import):

1. ``$CITADEL_WORKSPACE`` — the explicit override an MCP-host config sets so ``citadel serve``
   works from any CWD;
2. the nearest ``citadel.toml`` walking UP from the CWD (a nested marker shadows an outer one);
3. no marker, but BOTH ``CITADEL_WIKI_DIR`` and ``CITADEL_RAW_DIR`` are set: an env-dirs
   workspace — the CWD is only the nominal root (those dirs carry absolute keys anyway);
4. otherwise NO workspace resolved: ``WORKSPACE_FOUND`` is False and ``WORKSPACE_ROOT`` falls
   back to the bare CWD — ``cli.main`` fails loud on every workspace-needing subcommand instead
   of silently building a wiki in a random directory.

Reads env with sane defaults so a fresh workspace works out of the box. Includes a tiny optional
``.env`` loader (no dependency) that populates ``os.environ`` from ``WORKSPACE_ROOT/.env`` when a
workspace actually resolved (``WORKSPACE_FOUND``) and the var is unset.

Ingest runs through a coding-agent CLI (claude/copilot/agy), so there is no
API key to manage — the CLI uses whatever subscription it is logged into.

No logic beyond path/setting/rules resolution (plus the tiny content hash over the effective
rules tree, :func:`rules_version`, which is pure derivation over that resolution).

NOTE: other modules reference ``config.INGEST_MODEL`` / ``config.LLM_CLI`` / ``config.RAW_DIR`` /
etc. at call-time (``from . import config`` then ``config.RAW_DIR``) so tests can monkeypatch
these attributes. The WIKI path is the one exception: it is read through the
:func:`wiki_dir`/:func:`index_path`/… ACCESSORS, because ingest redirects it per source (see
:func:`wiki_redirect`) — the module attributes stay the process-wide base those accessors fall
back to, so monkeypatching them still configures the whole layout.
"""

from __future__ import annotations

import contextlib
import contextvars
import hashlib
import os
import shutil
import stat
import threading
import time
from pathlib import Path, PurePosixPath

from . import okf


def _safe_resolve(path: Path) -> Path:
    """``path.resolve()`` that never raises (falls back to an absolute, un-resolved path on a
    rare OS error), so path-identity math works even on a not-yet-existing target."""
    try:
        return path.resolve()
    except OSError:
        return path if path.is_absolute() else path.absolute()


def _path_id(path: Path | str) -> str:
    """The normalized, case-folded identity of a path for pure STRING comparison
    (``normcase(normpath(...))``) — no ``resolve()``, so it never touches the filesystem and never
    blocks on a dead mount. The one spelling-normalizer behind root de-duplication
    (:func:`source_roots`) and the native-form registry below."""
    return os.path.normcase(os.path.normpath(str(path)))


# --- Native (non-resolved) path spellings: the UNC-vs-drive-letter seam ------------------------
# Every configured path is ``resolve()``-d, which is what makes path IDENTITY unambiguous (manifest
# keys, root containment, staging vs. live). On Windows that resolution has one side effect we must
# not hand to a CHILD process: ``Path.resolve()`` rewrites a mapped network drive (``T:\team-wiki``)
# into its UNC form (``\\fileserver\share\team-wiki``) — what ``GetFinalPathNameByHandle`` reports —
# no matter whether the ``.env`` named the drive letter or the share. Some agent CLIs then refuse to
# work at all ("environment blocks UNC/network paths") because their sandbox rejects a UNC working
# directory, and git treats the two spellings as different repositories (``safe.directory`` /
# ``core.filemode`` complaints) — so a workspace that behaves perfectly when the user cds into
# ``T:\...`` breaks the moment citadel spawns the same tool with the resolved cwd.
#
# The fix is deliberately narrow: the resolved form stays the ONE identity everywhere, and the
# non-resolved spelling is remembered ALONGSIDE it purely for what child processes are handed.
# Nothing is guessed — an alias is recorded only where we already hold both spellings of the same
# directory (a ``.env`` value or the process CWD, plus its resolution), and only in the one case
# that matters: a resolved UNC path whose native spelling is not UNC. Everywhere else (all of
# POSIX, every ordinary Windows path) the registry stays EMPTY and :func:`native_form` is the
# identity function, so those invocations are byte-for-byte unchanged.
NATIVE_FORMS: dict[str, str] = {}


def _is_unc_path(path: Path | str) -> bool:
    """True for a Windows UNC path (``\\\\server\\share\\...``, or its forward-slash spelling).
    A pure string test, so it stays meaningful — and unit-testable — on any platform."""
    return str(path).replace("/", "\\").startswith("\\\\")


def _prefer_native(resolved: Path | str, native: Path | str) -> bool:
    """Whether ``native`` is worth remembering as the child-process spelling of ``resolved``: only
    when the two genuinely differ AND resolution turned a non-UNC path into a UNC one (the Windows
    mapped-drive rewrite). Every other pair — all of POSIX, a workspace the user named by UNC in
    the first place — keeps a single spelling."""
    return _path_id(resolved) != _path_id(native) and _is_unc_path(resolved) and not _is_unc_path(native)


def _record_native_form(resolved: Path | str, native: Path | str) -> None:
    """Remember ``native`` as the child-process spelling of ``resolved``, but only when
    :func:`_prefer_native` says it is worth it — which is what keeps :data:`NATIVE_FORMS` empty
    (and :func:`native_form` an identity function) outside the Windows mapped-drive case."""
    if _prefer_native(resolved, native):
        NATIVE_FORMS[_path_id(resolved)] = str(native)


def native_form(path: Path | str) -> Path:
    """The spelling of ``path`` to hand a CHILD process — the agent CLI's ``cwd`` and its directory
    grants, git's ``-C`` — namely the recorded non-resolved alias when there is one (a Windows
    mapped drive letter instead of ``resolve()``'s UNC rewrite), else ``path`` unchanged.

    An alias is INHERITED down the tree: a directory under one that has an alias is rebased onto
    it, so the DERIVED paths get the fix too, not just the ones a ``.env`` spelled out. That is the
    common case, not an edge one — with a workspace at ``T:\\team-wiki`` the wiki is
    ``WORKSPACE_ROOT / "wiki"`` (no ``CITADEL_WIKI_DIR`` at all), and it is git's ``-C`` argument.
    Rebasing is sound because a drive mapping covers the WHOLE subtree: if
    ``\\\\srv\\share\\team-wiki`` and ``T:\\team-wiki`` are the same directory, so are their
    identically-named children.

    Read at call time (tests monkeypatch :data:`NATIVE_FORMS` like any other config attribute) and
    purely lexical: it never touches the filesystem, so a dead mount cannot make it hang. The empty
    registry — every POSIX layout, every ordinary Windows one — short-circuits before any of it."""
    if not NATIVE_FORMS:
        return Path(path)
    p = Path(path)
    recorded = NATIVE_FORMS.get(_path_id(p))
    if recorded:
        return Path(recorded)
    for parent in p.parents:
        alias = NATIVE_FORMS.get(_path_id(parent))
        if alias:
            return Path(alias) / p.relative_to(parent)
    return p


def child_cwd() -> str:
    """The working directory to spawn a child process in: :data:`WORKSPACE_ROOT` in its
    child-friendly spelling (:func:`native_form`). Every ``cwd=`` that citadel passes to a
    subprocess goes through this one accessor, so the UNC seam is fixed in exactly one place."""
    return str(native_form(WORKSPACE_ROOT))


WORKSPACE_MARKER: str = "citadel.toml"


def _find_marker_root(start: Path) -> Path | None:
    """The nearest directory at/above ``start`` holding a :data:`WORKSPACE_MARKER`, or None.
    Nearest wins: a nested workspace's marker shadows an outer one, so running from inside the
    inner workspace never touches the outer one's wiki/manifest."""
    cur = _safe_resolve(start)
    for candidate in (cur, *cur.parents):
        try:
            if (candidate / WORKSPACE_MARKER).is_file():
                return candidate
        except OSError:
            return None
    return None


def _resolve_workspace_with_mechanism(cwd: Path | None = None) -> tuple[Path | None, str]:
    """The workspace root AND a short label for the discovery rule that resolved it, from ONE walk
    of the module-docstring order (env override > nearest marker > env-dirs pair > none). The
    SINGLE owner of that order: :func:`_resolve_workspace` and :func:`workspace_mechanism` both read
    it, so the order lives in exactly one place. Pure and never raises — callers (and tests) may pass
    an explicit ``cwd``; the import-time call uses the process CWD."""
    try:
        cwd = cwd if cwd is not None else Path.cwd()
        env = os.environ.get("CITADEL_WORKSPACE", "").strip()
        if env:
            return _safe_resolve(Path(env).expanduser()), "CITADEL_WORKSPACE env"
        marker_root = _find_marker_root(cwd)
        if marker_root is not None:
            return marker_root, f"{WORKSPACE_MARKER} marker"
        if os.environ.get("CITADEL_WIKI_DIR", "").strip() and os.environ.get("CITADEL_RAW_DIR", "").strip():
            return _safe_resolve(cwd), "CITADEL_WIKI_DIR/CITADEL_RAW_DIR"
        return None, "none (cwd fallback)"
    except OSError:  # e.g. the CWD vanished — nothing can resolve; fail loud later, not here
        return None, "none (cwd fallback)"


def _resolve_workspace(cwd: Path | None = None) -> Path | None:
    """Resolve the workspace root per the module-docstring order, or None when NO workspace
    resolved. Thin wrapper over :func:`_resolve_workspace_with_mechanism` (the order's one owner)."""
    return _resolve_workspace_with_mechanism(cwd)[0]


def workspace_mechanism() -> str:
    """The discovery rule that resolves the workspace, as a short label — one of
    ``"CITADEL_WORKSPACE env"``, ``"citadel.toml marker"``, ``"CITADEL_WIKI_DIR/CITADEL_RAW_DIR"``,
    or ``"none (cwd fallback)"``. Re-resolves cheaply through the single discovery-order owner
    (:func:`_resolve_workspace_with_mechanism`), so ``citadel doctor`` can name HOW the workspace was
    found without a second copy of the order (reads env + CWD at call time, like discovery itself)."""
    return _resolve_workspace_with_mechanism()[1]


# Keys the workspace `.env` assigned MORE THAN ONCE, in first-seen order. Recorded by
# :func:`_load_dotenv` (which runs before CONFIG_WARNINGS exists) and folded into that list below.
_DOTENV_DUPLICATE_KEYS: list[str] = []


def _load_dotenv(root: Path) -> None:
    """If ``root/.env`` exists, set any ``KEY=VALUE`` whose KEY is not already
    in ``os.environ``. Ignores blank / ``#`` lines, strips surrounding quotes.
    Best-effort; never raises.

    One line is one setting: a key assigned twice keeps its FIRST line and the later ones are
    dropped (the same first-wins rule that lets a real environment variable override the file).
    That is easy to trip over on the list-valued settings — a list of globs reads like something
    you extend line by line — so a repeated key is recorded for :data:`CONFIG_WARNINGS` rather
    than silently swallowed. The list settings take ONE line, comma-separated."""
    env_path = root / ".env"
    try:
        text = env_path.read_text(encoding="utf-8")
    except (OSError, ValueError):
        return
    seen: set[str] = set()
    for raw_line in text.splitlines():
        line = raw_line.strip()
        if not line or line.startswith("#"):
            continue
        if line.startswith("export "):
            line = line[len("export ") :].strip()
        if "=" not in line:
            continue
        key, _, value = line.partition("=")
        key = key.strip()
        if not key:
            continue
        if key in seen and key not in _DOTENV_DUPLICATE_KEYS:
            _DOTENV_DUPLICATE_KEYS.append(key)
        seen.add(key)
        if key in os.environ:
            continue
        value = value.strip()
        if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
            value = value[1:-1]
        os.environ[key] = value


def _cwd_fallback() -> Path:
    """The bare process CWD as the nominal (non-)workspace root; never raises on import."""
    try:
        return _safe_resolve(Path.cwd())
    except OSError:  # the CWD vanished — nothing can resolve; fail loud later, not here
        return Path(".")


def _native_workspace_alias(root: Path) -> Path | None:
    """The non-resolved spelling of the workspace ``root`` when THIS process already holds one:
    the ``CITADEL_WORKSPACE`` value as typed, or the process CWD / one of its parents (the marker
    walk resolves upward, so the root is at or above the CWD). None when no candidate matches —
    the normal case everywhere except a Windows mapped drive.

    ``os.path.abspath`` rather than ``resolve()`` is the whole point: it normalizes without asking
    the OS for a final path, so a mapped drive letter survives it. The cheap string gate
    (:func:`_prefer_native`) runs BEFORE the resolve, so no platform pays for a candidate walk it
    can never use."""
    candidates: list[Path] = []
    env = os.environ.get("CITADEL_WORKSPACE", "").strip()
    if env:
        candidates.append(Path(os.path.abspath(os.path.expanduser(env))))
    try:
        cwd = Path(os.path.abspath(os.getcwd()))
        candidates.append(cwd)
        candidates.extend(cwd.parents)
    except OSError:
        pass
    for candidate in candidates:
        if _prefer_native(root, candidate) and _safe_resolve(candidate) == root:
            return candidate
    return None


_resolved_root = _resolve_workspace()
# Whether discovery actually found a workspace. False means WORKSPACE_ROOT is only the bare CWD
# fallback — cli.main fails loud on every workspace-needing subcommand.
WORKSPACE_FOUND: bool = _resolved_root is not None
WORKSPACE_ROOT: Path = _resolved_root if _resolved_root is not None else _cwd_fallback()
del _resolved_root

# Remember how the user (or the CWD) actually SPELLED the workspace root, for the child processes
# citadel spawns there — see NATIVE_FORMS. A no-op unless resolution rewrote a mapped drive letter
# into a UNC path.
_ws_native = _native_workspace_alias(WORKSPACE_ROOT)
if _ws_native is not None:
    _record_native_form(WORKSPACE_ROOT, _ws_native)
del _ws_native

# Load the optional workspace .env BEFORE reading the env settings below, so a bare .env
# (no exported vars) also works. Only when a workspace actually resolved — the fallback
# CWD is NOT a workspace, so a stray .env in some random directory is never slurped.
if WORKSPACE_FOUND:
    _load_dotenv(WORKSPACE_ROOT)


def _split_list_env(value: str) -> list[str]:
    """Split a comma/newline-separated env value into a clean list (whitespace stripped, blank
    entries dropped) — the ONE splitter behind the list-valued settings
    (``CITADEL_IGNORE_PATTERNS``, ``CITADEL_RAW_DIRS``)."""
    return [p.strip() for p in value.replace("\n", ",").split(",") if p.strip()]


# Problems found while parsing env settings at import, surfaced by ``citadel doctor`` as a WARN
# line. This module must never crash on a bad value: it is imported before any subcommand runs,
# so an import-time ValueError would take down every command — including doctor, the one tool
# built to diagnose exactly this class of misconfiguration.
CONFIG_WARNINGS: list[str] = []

# The `.env` was already parsed above, so its duplicate-key findings are folded in as soon as the
# list exists — a setting written on several lines silently keeps only the first one. The wording
# says "occurrence in .env" rather than "line is used" because the file is not always what wins: a
# variable already set in the real environment overrides the whole file, so ALL of its lines are
# ignored. Either way the later lines do nothing, which is what the reader needs to know.
for _dup in _DOTENV_DUPLICATE_KEYS:
    CONFIG_WARNINGS.append(
        f"{_dup} is assigned more than once in .env - only the FIRST occurrence is considered (and "
        "none of them when the variable is already set in the environment). A list setting takes "
        "ONE line with comma-separated entries (e.g. CITADEL_INCLUDE_PATTERNS=.pdf,.txt)"
    )


def _int_env(key: str, default: int) -> int:
    """Read an integer env setting tolerantly: blank/missing means "unset" and yields the
    default; an unparseable value yields the default AND records the problem in
    :data:`CONFIG_WARNINGS` instead of crashing at import."""
    raw = os.environ.get(key, "").strip()
    if not raw:
        return default
    try:
        return int(raw)
    except ValueError:
        CONFIG_WARNINGS.append(f"{key}={raw!r} is not an integer - using the default {default}")
        return default


# The ONE pair of recognized boolean token sets — every boolean knob parses through _bool_env,
# so "what counts as on/off" can never drift between knobs again.
_TRUTHY_TOKENS = ("1", "true", "yes", "on")
_FALSY_TOKENS = ("0", "false", "no", "off")


def _bool_env(key: str, default: bool) -> bool:
    """Read a boolean env setting tolerantly, mirroring :func:`_int_env`: blank/missing means
    "unset" and yields the default (an uncommented-but-emptied ``.env`` line is not an opt-out —
    previously a bare ``CITADEL_IMAGE_SUPPORT=`` silently DISABLED a default-on feature); a
    recognized truthy/falsy token yields its value; anything else yields the default AND records
    the problem in :data:`CONFIG_WARNINGS` instead of silently coercing."""
    raw = os.environ.get(key, "").strip().lower()
    if not raw:
        return default
    if raw in _TRUTHY_TOKENS:
        return True
    if raw in _FALSY_TOKENS:
        return False
    CONFIG_WARNINGS.append(
        f"{key}={raw!r} is not a recognized boolean (1/true/yes/on or 0/false/no/off) - "
        f"using the default {'1' if default else '0'}"
    )
    return default


def _resolve_dir_entry(value: str) -> Path:
    """Resolve ONE configured-directory value to an absolute Path: expand a leading ``~``, take
    an ABSOLUTE value AS-IS — including a Windows mapped-drive path (``T:\\team-wiki\\wiki``) or
    a POSIX mount (``/mnt/share/wiki``) — and resolve a RELATIVE value against the WORKSPACE
    ROOT (never the process CWD). Always ``_safe_resolve``-d, so ``CITADEL_*_DIR`` and every
    ``CITADEL_RAW_DIRS`` entry resolve through the identical path.

    The value's own (non-resolved) spelling is remembered for child processes on the way through
    (:func:`_record_native_form`), so a ``CITADEL_WIKI_DIR=T:\\other-share\\wiki`` — an ABSOLUTE
    override, the one spelling nothing else could derive — is still handed to the agent CLI / git as
    a drive-letter path even though its identity here is the resolved UNC form. A default or
    relative dir needs no record: it lives under the workspace root, so :func:`native_form` inherits
    the root's own alias."""
    path = Path(value).expanduser()
    if not path.is_absolute():
        path = WORKSPACE_ROOT / path
    resolved = _safe_resolve(path)
    _record_native_form(resolved, Path(os.path.abspath(path)))
    return resolved


def _dir_setting(env_key: str, default: Path) -> Path:
    """Resolve a configurable directory (wiki/raw/docs) to an absolute Path.

    With no override, use ``default``. With ``CITADEL_*_DIR`` set, resolve it through
    :func:`_resolve_dir_entry` — absolute overrides as-is (so the wiki/raw can live OUTSIDE the
    workspace, e.g. on a mounted network drive), relative ones against the workspace root, so
    ``CITADEL_WIKI_DIR=wiki`` means ``WORKSPACE_ROOT/wiki`` regardless of where ``citadel`` is
    launched from. Always returns a ``resolve()``-d absolute path."""
    raw = os.environ.get(env_key, "").strip()
    if not raw:
        return default.resolve()
    return _resolve_dir_entry(raw)


WIKI_DIR: Path = _dir_setting("CITADEL_WIKI_DIR", WORKSPACE_ROOT / "wiki")
RAW_DIR: Path = _dir_setting("CITADEL_RAW_DIR", WORKSPACE_ROOT / "raw")
DOCS_DIR: Path = _dir_setting("CITADEL_DOCS_DIR", WORKSPACE_ROOT / "docs")


def _resolve_raw_dirs() -> list[Path]:
    """Every raw ROOT discovery walks, from ``CITADEL_RAW_DIRS`` — a comma/newline-separated list
    (split by :func:`_split_list_env`), each entry resolved through :func:`_resolve_dir_entry`
    exactly like a ``CITADEL_*_DIR`` override (relative against ``WORKSPACE_ROOT``, absolute — a
    mounted share — as-is, ``_safe_resolve``-d). Unset/blank falls back to the single
    :data:`RAW_DIR`.

    Relationship to ``RAW_DIR``: ``RAW_DIR`` stays the PRIMARY root — the fallback the agent
    prompt's raw-dir bullet names for an out-of-root source — while ``RAW_DIRS`` is the full
    walk list. When ``CITADEL_RAW_DIRS`` is set it REPLACES the list, so include the primary
    root explicitly if it should still be scanned. Root-membership consumers iterate the deduped
    union of both via :func:`source_roots`."""
    raw_env = os.environ.get("CITADEL_RAW_DIRS", "").strip()
    if not raw_env:
        return [Path(RAW_DIR)]
    return [_resolve_dir_entry(item) for item in _split_list_env(raw_env)]


# Read at call time by discovery/grammar/llm (tests monkeypatch this list directly, like RAW_DIR).
RAW_DIRS: list[Path] = _resolve_raw_dirs()


def rel_or_abs_posix(path: Path | str) -> str:
    """The canonical identity key / agent-facing path for a raw source or a configured directory:
    its POSIX path **relative to** ``WORKSPACE_ROOT`` when it lives under the workspace (short, and
    resolvable from the agent's workspace-root CWD), or its **absolute** POSIX path when it does
    not — e.g. a wiki/raw tree on a mounted network drive (``T:/team-wiki/raw/notes.md``).

    This is the single source of truth for turning a path into a key, used by the manifest, the
    agent prompt, and the citation/resource bookkeeping. It replaces the old basename fallback,
    which collided distinct out-of-workspace files onto the same key and was unresolvable from the
    workspace root. Read ``WORKSPACE_ROOT`` at call time so tests can monkeypatch the layout."""
    rp = _safe_resolve(Path(path))
    try:
        return rp.relative_to(_safe_resolve(WORKSPACE_ROOT)).as_posix()
    except ValueError:
        return rp.as_posix()


def source_path_for_key(key: str) -> Path:
    """Inverse of :func:`rel_or_abs_posix`: the absolute filesystem Path a source key denotes. An
    absolute key (an out-of-workspace source, e.g. ``T:/team-wiki/raw/notes.md``) is used as-is; a
    workspace-relative key (``raw/notes.md``) is joined under ``WORKSPACE_ROOT``. Replaces the
    scattered root-relative joins that silently mis-resolved out-of-workspace keys."""
    p = Path(key)
    return p if p.is_absolute() else WORKSPACE_ROOT / key


def source_roots() -> list[Path]:
    """Every directory that counts as a RAW SOURCE ROOT: the multi-root walk list
    (:data:`RAW_DIRS`) plus the primary :data:`RAW_DIR` (kept alongside for layouts whose
    ``CITADEL_RAW_DIRS`` replaces the list without re-listing the primary), de-duplicated in
    that order. The one union every root-membership consumer iterates — citation containment
    (``grammar.is_source_citation``), display collapsing (:func:`display_key`), the agent-CLI
    directory grants (``llm._external_dirs``), and the repo corpus-root guard
    (``ingest._is_repo_source``). Read at call time so tests can monkeypatch either attribute."""
    roots: list[Path] = []
    seen: set[str] = set()
    for root in (*RAW_DIRS, RAW_DIR):
        ident = _path_id(root)
        if ident not in seen:
            seen.add(ident)
            roots.append(Path(root))
    return roots


def root_covering(path: Path | str) -> Path | None:
    """The configured raw root (:data:`RAW_DIRS`, the walk list — read at call time) that
    lexically contains ``path``, or None for a path under NO configured root (an explicit
    out-of-root ingest, a root removed from the config). Containment is ``grammar.is_within`` —
    purely lexical, no ``resolve()`` — so a lookup never blocks on a dead mount. Config owns the
    roots: the deletion sweep's root scoping and the agent prompt's raw-dir bullet both go
    through this one lookup."""
    from . import grammar  # call-time import: grammar depends on config, not the other way round

    for root in RAW_DIRS:
        if grammar.is_within(path, root):
            return root
    return None


def display_key(key: str) -> str:
    """A short, human-facing rendering of a source key for the ingest CONSOLE output.

    An out-of-repo source on a mounted network drive carries an ABSOLUTE key — e.g.
    ``//fileserver/share/dept/2026/reports/raw/sub/notes.pdf`` — whose long prefix before the raw
    folder is noise while a live multi-file ingest scrolls past (and it wrecks the one-line progress
    display). When the key lies under ``RAW_DIR`` (or ``DOCS_DIR``), collapse it to
    ``<folder-name>/<path-below>`` — dropping the WHOLE prefix before that folder, so
    ``//fileserver/.../raw/sub/notes.pdf`` shows as ``raw/sub/notes.pdf``. A repo-relative key
    (already short, e.g. ``raw/notes.md``) or any key not under those roots is returned unchanged
    apart from slash-normalization.

    Display-only: the CANONICAL key is still what the manifest, the report, and the citation
    bookkeeping use. Read ``RAW_DIR`` / ``DOCS_DIR`` at call time so tests can monkeypatch the layout.
    Always returns a ``str`` (the input normalized to forward slashes) and never raises — every
    fallback path returns the normalized string, so the ``-> str`` contract holds even for a non-str
    (e.g. ``Path``) input and the normalization is never dropped.

    Three tiers, each a fallback for the one before: an exact prefix match against a configured
    root; failing that, a cut at the last path segment NAMED like a root (which is what rescues a
    Windows drive letter mapped to a share, where the configured ``T:\\proj\\raw`` and the key's
    ``//fileserver.corp.example/proj/raw/...`` are the same folder but share no common text); and
    failing that, :func:`_clip_middle`, so an absolute key belonging to no known root still cannot
    flood the console."""
    text = str(key).replace("\\", "/").strip()
    if not text:
        return text
    try:
        p = Path(text)
        # Resolve an ABSOLUTE key through the same normalization the roots use, so the two compare in
        # one space; leave a relative (in-repo) key alone — resolving it would bind it to the CWD.
        kp = PurePosixPath(_safe_resolve(p).as_posix()) if p.is_absolute() else PurePosixPath(text)
        roots = [*source_roots(), DOCS_DIR]
        for root in roots:
            base = PurePosixPath(_safe_resolve(root).as_posix())
            try:
                rel = kp.relative_to(base)
            except ValueError:
                continue
            return base.name + "/" + "/".join(rel.parts) if rel.parts else base.name
        # Prefix-matching FAILED but the key may still live under a configured root that simply
        # does not compare equal as a string: a Windows drive mapped to a share (``T:\proj\raw``)
        # resolves to itself, while the key travelled as the UNC path the drive points at
        # (``//fileserver.corp.example/proj/raw/...``). Nothing relates the two textually, so fall
        # back to the root's FOLDER NAME and cut at its last occurrence in the key — the same
        # ``raw/sub/notes.pdf`` the matched branch produces, without needing the prefixes to agree.
        names = {r.name.lower() for r in (PurePosixPath(_safe_resolve(x).as_posix()) for x in roots) if r.name}
        parts = kp.parts
        for i in range(len(parts) - 1, -1, -1):
            if parts[i].lower() in names:
                return "/".join(parts[i:])
    except (OSError, ValueError):
        return text
    return _clip_middle(text)


def display_path(path) -> str:
    """A short, human-facing rendering of an arbitrary FILESYSTEM path for console output — the
    :func:`display_key` idea applied to the paths citadel itself writes (today: the per-session LLM
    transcripts ``CITADEL_LLM_LOG_DIR`` produces).

    A workspace on a mounted network drive turns every such path into a ~200-character UNC string,
    and one announced per agent session — seven for a chunked source — wraps over three terminal
    rows each and buries the progress display it prints alongside. Under the workspace root the
    path is shown RELATIVE to it (``.citadel_llm_logs/20260818-152715.4240.1.log``), which is both
    short and still exactly what you type to open it; anything else falls back to
    :func:`display_key`, so it is collapsed against a raw root or clipped rather than printed in
    full.

    Display-only and never raises — like ``display_key``, every fallback returns the normalized
    string."""
    text = str(path).replace("\\", "/").strip()
    if not text:
        return text
    try:
        p = Path(text)
        if p.is_absolute():
            root = PurePosixPath(_safe_resolve(WORKSPACE_ROOT).as_posix())
            try:
                rel = PurePosixPath(_safe_resolve(p).as_posix()).relative_to(root)
            except ValueError:
                return display_key(text)
            return "/".join(rel.parts) or "."
    except (OSError, ValueError):
        # A path that could not even be resolved (a dead mount under the workspace root) is
        # exactly the long absolute one this exists to shorten, so it takes the SAME fallback as
        # a path that simply lies outside the workspace — never the full string.
        pass
    return display_key(text)


# How many trailing path segments an unrecognized absolute key keeps (see `_clip_middle`).
_DISPLAY_TAIL_PARTS = 3


def _clip_middle(text: str) -> str:
    """Last-resort shortening for an ABSOLUTE key that matched no configured root at all (a stale
    manifest entry from an earlier layout, a source reached through a path citadel no longer walks).

    Such a key would otherwise print in full — and a UNC path with a long FQDN host is exactly the
    noise that makes a scrolling multi-file ingest unreadable. Keep the tail, which is the part that
    identifies the file, and mark the drop with an ASCII ``.../`` (this module's output stays
    encodable on a legacy Windows code page). A relative in-repo key is already short and is never
    touched."""
    if not text.startswith("/") and ":" not in text[:3]:
        return text
    parts = [p for p in text.split("/") if p]
    if len(parts) <= _DISPLAY_TAIL_PARTS:
        return text
    return ".../" + "/".join(parts[-_DISPLAY_TAIL_PARTS:])


def is_outside_workspace(path: Path | str) -> bool:
    """True if ``path`` does NOT live under ``WORKSPACE_ROOT`` — so the agentic CLI must be
    granted explicit access to it (e.g. claude ``--add-dir``), since its file tools are otherwise
    scoped to the workspace-root working directory."""
    try:
        _safe_resolve(Path(path)).relative_to(_safe_resolve(WORKSPACE_ROOT))
        return False
    except ValueError:
        return True


def robust_mkdir(path: Path | str, attempts: int = 5) -> None:
    """``Path(path).mkdir(parents=True, exist_ok=True)`` hardened for a wiki on a network share.

    A UNC/SMB path can momentarily report an existing directory as *not* a directory (e.g. right
    after a sibling tree was deleted), so ``os.mkdir`` raises ``FileExistsError`` (Windows error
    183) and pathlib re-raises it even though ``exist_ok=True`` — because its ``self.is_dir()``
    re-check transiently returns False. That is the race that aborted a long ingest in
    ``rebuild_indexes`` mid-finalize.

    A ``FileExistsError`` is treated as success ONLY once the target is confirmed to BE a directory
    (retrying briefly to ride out the transient share state). If the path exists but never resolves
    to a directory — a genuine file collision — the error is re-raised rather than swallowed, so it
    surfaces here instead of as a confusing ``NotADirectoryError`` on the next write. Other
    transient ``OSError``s are likewise retried a few times before being re-raised."""
    p = Path(path)
    for attempt in range(attempts):
        try:
            p.mkdir(parents=True, exist_ok=True)
            return
        except OSError as exc:
            # FileExistsError on an existing DIRECTORY is the share's transient is_dir() race —
            # success once we can confirm it really is a directory. A real file collision (or any
            # other still-failing OSError) re-raises once the retries are exhausted.
            if isinstance(exc, FileExistsError) and p.is_dir():
                return
            if attempt == attempts - 1:
                raise
            time.sleep(0.2 * (attempt + 1))


def robust_rmtree(path: Path | str, attempts: int = 5) -> None:
    """Best-effort recursive delete that tolerates the Windows read-only bit and the transient
    locks/latency common on network shares. Retries a few times and never raises — a tree that
    still will not delete is left for the caller (which overwrites it via ``dirs_exist_ok=True``,
    or sweeps it next run), which beats aborting the whole run.

    Replaces a bare ``shutil.rmtree(..., ignore_errors=True)``: that swallowed the failure and left
    the directory in place, which then made a follow-up ``copytree`` crash with ``FileExistsError``.
    Lives here (beside :func:`robust_mkdir`/:func:`atomic_write_text`) because every module that
    writes derived state beside the wiki needs the same hardening — ingest's staging copies and
    resume's checkpoint slots alike."""

    def _clear_readonly(func, p, _exc):
        # Windows marks some files read-only; add the write bit (OR onto the existing mode so we
        # don't wipe read/execute — clearing a directory's execute bit on POSIX would block the
        # traversal the retried delete needs) and retry the one failed operation.
        try:
            os.chmod(p, os.stat(p).st_mode | stat.S_IWRITE)
            func(p)
        except OSError:
            pass

    for attempt in range(attempts):
        if not os.path.exists(path):
            return
        # "Never raises" has to hold literally: `onexc` covers per-entry failures, but rmtree can
        # still raise from the call itself (the target vanishing between the exists() check and the
        # walk, a non-directory, a share error the callback cannot resolve). This runs in `finally`
        # blocks, where an escaping exception would MASK the original failure — so swallow it and
        # let the exists() check + retry below decide.
        with contextlib.suppress(OSError):
            shutil.rmtree(path, onexc=_clear_readonly)
        if not os.path.exists(path):
            return
        time.sleep(0.2 * (attempt + 1))


def atomic_write_text(path: Path | str, text: str, attempts: int = 4) -> None:
    """Write ``text`` to ``path`` atomically: a same-directory temp sibling + ``os.replace``, so a
    concurrent reader never sees a torn file and a crash mid-write leaves the previous version
    intact (the manifest and failures catalog are last-write-wins WHOLE files — a partial write
    would silently un-record completed sources). The temp carries the ``.citadeltmp`` suffix the
    staging copy already skips, and the replace retries briefly to ride out the transient sharing
    violations SMB shares and AV scanners cause on Windows (the ``_robust_*`` pattern)."""
    p = Path(path)
    # pid AND thread id: `citadel ingest --jobs N` runs several sources in one process, so a
    # pid-only temp name is no longer unique — two writers would share one temp file and the loser's
    # os.replace would fail on a file the winner already moved.
    tmp = p.with_name(f"{p.name}.{os.getpid()}.{threading.get_ident()}.citadeltmp")
    tmp.write_text(text, encoding="utf-8")
    try:
        for attempt in range(attempts):
            try:
                os.replace(tmp, p)
                return
            except OSError:
                if attempt == attempts - 1:
                    raise
                time.sleep(0.2 * (attempt + 1))
    except OSError:
        with contextlib.suppress(OSError):
            tmp.unlink()
        raise


# The rules layer the agentic CLI reads (by path) each run — a TREE of markdown files packaged
# with the wheel (citadel/rules/: schema.md + core.md read every session, tasks/ per lifecycle,
# formats/ per Python-detectable source format, genres/ applied by the agent's own content
# judgment; see citadel/rules/README.md) so a pip-installed citadel carries its own rules. Python
# never reads them to build prompts; they MUST be real files on disk (the agent opens them by
# path), so they resolve directly off this module's location — absolute, readable from ANY CWD,
# in a checkout and in site-packages alike. A workspace may OVERRIDE any file with
# rules/<same tree-relative name> (first-hit-wins per filename; see :func:`effective_rules_file`)
# and APPEND house rules as rules/local.md (:func:`local_rules_file`).
PACKAGED_RULES_DIR: Path = Path(__file__).resolve().parent / "rules"


def workspace_rules_dir() -> Path | None:
    """The workspace rules overlay directory (``WORKSPACE_ROOT/rules``), or None when no workspace
    resolved — the bare-CWD fallback is NOT a workspace, so a stray ``./rules`` in some random
    directory is never consulted. Read at call time so tests can monkeypatch the layout."""
    if not WORKSPACE_FOUND:
        return None
    return Path(WORKSPACE_ROOT) / "rules"


def rules_relname(name: str) -> str:
    """Normalize a (possibly user-supplied) tree-relative rules name: backslashes (Windows input)
    become forward slashes, surrounding whitespace is stripped. Normalization ONLY — validation
    lives at the join points (:func:`rules_join`)."""
    return str(name).replace("\\", "/").strip()


def rules_join(base: Path, relname: str) -> Path:
    """Join a tree-relative rules name under ``base`` through ``okf.safe_join`` — the
    non-negotiable path guard — so ``rules show``/``rules eject``/prompt composition can never
    reach outside a rules tree. Additionally rejects a Windows drive-letter name
    (``C:/evil.md``), which POSIX path math would otherwise read as a harmless relative
    segment. Raises :class:`okf.OKFError` on any unsafe name; returns the resolved Path."""
    if ":" in relname:
        raise okf.OKFError(f"unsafe path: {relname!r}")
    return okf.safe_join(Path(base), relname)


def effective_rules_file(relname: str) -> Path:
    """Resolve ONE rules file by its tree-relative name (``core.md``, ``tasks/ingest.md``, …),
    first-hit-wins per filename: a workspace ``rules/<relname>`` shadows the packaged
    ``citadel/rules/<relname>``. Falls back to the packaged path (which may not exist for a bogus
    name — callers that need existence check it). Both joins go through :func:`rules_join`
    (``okf.safe_join``), so a traversal/absolute/drive-letter name raises ``okf.OKFError``
    instead of resolving outside the rules trees. Read at call time."""
    relname = rules_relname(relname)
    ws = workspace_rules_dir()
    if ws is not None:
        candidate = rules_join(ws, relname)
        try:
            if candidate.is_file():
                return candidate
        except OSError:
            pass
    return rules_join(PACKAGED_RULES_DIR, relname)


def effective_genres() -> list[Path]:
    """Every effective genre brief, sorted by filename: the ``genres/<name>.md`` DIRECT children
    (no deeper nesting) of :func:`rules_relnames` — the one union walk over the packaged
    ``genres/`` starter set and the workspace ``rules/genres/`` overlay (a workspace file shadows
    the packaged same-name via :func:`effective_rules_file`). The ingest prompt enumerates
    exactly this list, so a genre file dropped into the workspace participates with no code
    change."""
    return [effective_rules_file(rel) for rel in rules_relnames() if rel.startswith("genres/") and rel.count("/") == 1]


def local_rules_file() -> Path | None:
    """The workspace ``rules/local.md`` when present, else None. Always APPENDED as one more path
    to every session's rules list — the additive, upgrade-safe home for house rules (a forked
    default goes through ``citadel rules eject`` instead)."""
    ws = workspace_rules_dir()
    if ws is None:
        return None
    candidate = ws / "local.md"
    try:
        return _safe_resolve(candidate) if candidate.is_file() else None
    except OSError:
        return None


def rules_relnames() -> list[str]:
    """Sorted tree-relative names of every EFFECTIVE rules file: the union of ``*.md`` files under
    the packaged tree and the workspace ``rules/`` overlay (each name resolves through
    :func:`effective_rules_file`). Drives :func:`rules_version` and ``citadel rules list``."""
    names: set[str] = set()
    ws = workspace_rules_dir()
    roots = [PACKAGED_RULES_DIR] + ([ws] if ws is not None else [])
    for root in roots:
        try:
            names.update(p.relative_to(root).as_posix() for p in root.rglob("*.md") if p.is_file())
        except OSError:
            continue
    return sorted(names)


def rules_version() -> str:
    """A short content hash of the effective rules tree: sha256 over the sorted
    (tree-relative name, bytes) of every effective rules file, truncated to 12 hex chars.
    Stamped per source into the ingest manifest (``rules_version``) so a later
    ``curate --stale-rules`` can find sources ingested under older rules. Zero manual
    maintenance — editing ANY effective file (packaged, workspace override, an added genre,
    ``rules/local.md``) changes it. Cheap (the tree is a few KB): compute once per ingest run,
    not per source."""
    h = hashlib.sha256()
    for rel in rules_relnames():
        try:
            data = effective_rules_file(rel).read_bytes()
        except OSError:
            continue
        h.update(rel.encode("utf-8"))
        h.update(b"\x00")
        h.update(data)
        h.update(b"\x00")
    return h.hexdigest()[:12]


INDEX_PATH: Path = WIKI_DIR / "index.md"
# The generated provenance catalog wiki/sources/index.md (mirrors INDEX_PATH). The ONE filesystem
# path for it — store.rebuild_indexes, the MCP wiki_sources tool, and the CLI `sources` subcommand
# all resolve it here rather than re-joining the "sources"/"index.md" literal. (store keeps the
# wiki-relative "sources/index.md" string for the catalog's own link math.)
SOURCES_INDEX_PATH: Path = WIKI_DIR / "sources" / "index.md"
LOG_PATH: Path = WIKI_DIR / "log.md"
MANIFEST_PATH: Path = WIKI_DIR / ".citadel_ingested.json"
# Persistent record of raw sources that could NOT be ingested — a binary/unsupported file, or a
# source whose agent session errored/timed out — with the reason, so a failure survives the run
# instead of scrolling past in the console. Regenerated each run (a source that later succeeds is
# dropped) and surfaced in wiki/sources/index.md.
FAILURES_PATH: Path = WIKI_DIR / ".citadel_failures.json"


# --- The wiki in effect right HERE (per-context, never process-global) ------------------------
# Ingest runs every source's agent session against a per-source STAGING copy of the wiki, so for
# the duration of that session "the wiki" is the staging dir, not WIKI_DIR. That redirect used to
# ASSIGN the module attributes below (and os.environ), which made it process-global: one process
# could only ever be inside one redirect, and two sources could never be in flight at once — the
# blocker the 2026-07 audit named (finding 1.2.5) under `--jobs N`.
#
# It is a ContextVar instead: the override is CONTEXT-local rather than process-global, so N worker
# threads can each hold their OWN staging redirect while the main thread still sees the live wiki.
# Two properties of CPython make that work, and they pull in opposite directions:
#   * a thread does NOT inherit the context of the thread that started or submitted to it — neither
#     a plain `threading.Thread` nor a `ThreadPoolExecutor` worker (unlike an asyncio task, which
#     copies its context), so a worker starts out seeing the live wiki, which is correct;
#   * but a POOLED thread is REUSED across work items, and a value left set by one item is still
#     there for the next one on that thread.
# So the `finally` reset in :func:`wiki_redirect` is load-bearing, not tidiness: it is what keeps a
# finished source's staging path from being handed to the next source that lands on the same worker.
# Unset (the overwhelmingly common case: every read path, every CLI command, the MCP server) the
# accessors return the module attributes verbatim, so the process-wide layout — and every test that
# monkeypatches it — behaves exactly as before.
_WIKI_OVERRIDE: contextvars.ContextVar["Path | None"] = contextvars.ContextVar("citadel_wiki_dir", default=None)


def wiki_dir() -> Path:
    """The wiki directory THIS context reads and writes: the active :func:`wiki_redirect` target
    (ingest's per-source staging copy) when one is in effect, else the process-wide
    :data:`WIKI_DIR`. Every in-package consumer goes through this accessor rather than reading
    ``config.WIKI_DIR`` directly — that is what lets two sources stage concurrently."""
    override = _WIKI_OVERRIDE.get()
    return override if override is not None else WIKI_DIR


def index_path() -> Path:
    """:data:`INDEX_PATH` (``<wiki>/index.md``), redirect-aware — see :func:`wiki_dir`."""
    override = _WIKI_OVERRIDE.get()
    return override / "index.md" if override is not None else INDEX_PATH


def sources_index_path() -> Path:
    """:data:`SOURCES_INDEX_PATH` (``<wiki>/sources/index.md``), redirect-aware."""
    override = _WIKI_OVERRIDE.get()
    return override / "sources" / "index.md" if override is not None else SOURCES_INDEX_PATH


def log_path() -> Path:
    """:data:`LOG_PATH` (``<wiki>/log.md``), redirect-aware."""
    override = _WIKI_OVERRIDE.get()
    return override / "log.md" if override is not None else LOG_PATH


def manifest_path() -> Path:
    """:data:`MANIFEST_PATH` (``<wiki>/.citadel_ingested.json``), redirect-aware."""
    override = _WIKI_OVERRIDE.get()
    return override / ".citadel_ingested.json" if override is not None else MANIFEST_PATH


def failures_path() -> Path:
    """:data:`FAILURES_PATH` (``<wiki>/.citadel_failures.json``), redirect-aware."""
    override = _WIKI_OVERRIDE.get()
    return override / ".citadel_failures.json" if override is not None else FAILURES_PATH


@contextlib.contextmanager
def wiki_redirect(target: Path | str):
    """Point :func:`wiki_dir` (and every path derived from it) at ``target`` for the duration of
    the block, in THIS context only — a sibling thread's redirect, and the main thread's live
    wiki, are unaffected. Restored on every exit path, including an exception.

    The raw/docs dirs are deliberately untouched: staging is a SIBLING of the live wiki, so every
    relative citation the agent writes (``../../raw/x.md``) resolves identically before and after
    the promote."""
    token = _WIKI_OVERRIDE.set(Path(target))
    try:
        yield
    finally:
        _WIKI_OVERRIDE.reset(token)


def child_env() -> dict[str, str]:
    """The environment to hand a CHILD process that must see THIS context's wiki: ``os.environ``
    plus an explicit ``CITADEL_WIKI_DIR``. The agentic CLI (and the ``citadel check`` it shells
    out to) resolves its own config at import, so the redirect has to reach it through the
    environment — passed per spawn rather than assigned into ``os.environ``, which is
    process-global and would make two concurrent sessions overwrite each other's wiki."""
    env = dict(os.environ)
    env["CITADEL_WIKI_DIR"] = str(wiki_dir())
    return env


# Ingest backend: which coding-agent CLI to shell out to, and which model alias/id to pass it.
# No API key is used. Every supported backend (claude / copilot / agy) honors --model, so this is
# ONE knob with one meaning. It deliberately has NO default: an unset workspace runs each CLI on
# its own configured default model, rather than being handed an alias only one backend knows
# (the old claude-only "sonnet" default, which was meaningless — and unpassable — elsewhere).
LLM_CLI: str = os.environ.get("CITADEL_LLM_CLI", "claude")
INGEST_MODEL: str = os.environ.get("CITADEL_INGEST_MODEL", "").strip()
# The model `citadel curate` sessions run under. Curate is a bounded cleanup pass (re-sort, split,
# re-ground) that can run on a cheaper/faster model than a full ingest — set CITADEL_CURATE_MODEL to
# choose it. Empty (the default) falls back to INGEST_MODEL, so a workspace that never sets it
# curates on the same model it ingests with. Every backend is passed --model, so this knob is
# authoritative for all of them. Read at call time / swapped in by curate.py so tests can
# monkeypatch it.
CURATE_MODEL: str = os.environ.get("CITADEL_CURATE_MODEL", "").strip()
LLM_TIMEOUT: int = _int_env("CITADEL_LLM_TIMEOUT", 1200)
# Hermetic agent sessions (the 2026-07 audit's reproducibility fix): when ON (default) and the
# installed CLI ADVERTISES a session-isolation flag (claude --bare — skip user hooks/CLAUDE.md/MCP
# discovery), that flag is appended to every agent session, so the user's PERSONAL agent
# configuration never leaks into ingest — sessions run on citadel's rules tree alone. The flag is
# feature-probed per binary (llm._hermetic_flags), so an older CLI without it runs exactly as
# before; 0 deliberately re-admits the personal config. Read at call time (tests monkeypatch it).
HERMETIC: bool = _bool_env("CITADEL_HERMETIC", True)

# Observability for the otherwise-headless agent session — by default the CLI's stdout/stderr is
# captured only to detect failure and read the result envelope's cost/usage figures, then
# DISCARDED, so there is no record of what the model actually DID (the very thing you need when
# one backend/model produces no edits while another does). Two opt-in knobs, both read at call
# time (so tests/CLI flags can override them):
#
# - CITADEL_LLM_LOG_DIR: a directory to write ONE transcript file per agent session (prompt + full
#   stdout/stderr + exit code + duration). Relative paths resolve under WORKSPACE_ROOT. Empty = off.
# - CITADEL_LLM_VERBOSE: stream the CLI's output to the terminal live as the session runs, instead of
#   capturing it silently — so you can watch the model work ("see everything") without dropping
#   the non-interactive pipeline. copilot/agy stream their full agentic transcript; the claude
#   CLI (run with --output-format json) only emits its final result envelope, so prefer a transcript
#   log there.
LLM_LOG_DIR: str = os.environ.get("CITADEL_LLM_LOG_DIR", "").strip()
LLM_VERBOSE: bool = _bool_env("CITADEL_LLM_VERBOSE", False)

# Git-repository sources. A sub-folder under raw/ that is a git checkout (holds a ``.git``) — or
# carries an opt-in ``.citadelsource`` marker for a git-less snapshot — is ingested as ONE source: a
# size-capped DIGEST of its high-signal files (README, dependency manifests, the connection/config
# layer, the data-transform/pipeline core, entry points) is folded in by a SINGLE agent session,
# and the source is tracked by its HEAD commit so a later commit re-ingests only the diff. Set
# CITADEL_REPO_SUPPORT=0 to fall back to the old per-file walk (every file its own source).
REPO_SUPPORT: bool = _bool_env("CITADEL_REPO_SUPPORT", True)
# Total character budget for one repo digest, and the per-file cap inside it (a long file is
# truncated with a marker). Generous defaults so the transform/pipeline core fits; raise for big
# repos, lower to keep sessions cheap.
REPO_DIGEST_MAX_CHARS: int = _int_env("CITADEL_REPO_DIGEST_MAX_CHARS", 120000)
REPO_PER_FILE_MAX_CHARS: int = _int_env("CITADEL_REPO_PER_FILE_MAX_CHARS", 8000)

# Image sources. When on (default), a raw file that is a recognized image (screenshot, scan,
# diagram, chart, photo) is handed to the agent to READ VISUALLY — the coding-agent CLI's file
# reader can display images — instead of being rejected as a NUL-byte binary. Set
# CITADEL_IMAGE_SUPPORT=0 to keep images out of the wiki (they then log as unreadable, as before).
IMAGE_SUPPORT: bool = _bool_env("CITADEL_IMAGE_SUPPORT", True)

# Audio/video sources (transcript ingest). OPT-IN, default OFF — unlike images this needs an extra
# LOCAL binary and real CPU time per recording. When on, a recognized audio/video file
# (.mp3/.wav/.m4a/.mp4/…, extension AND magic) is transcribed ONCE through the whisper-class CLI
# below (citadel.transcribe — a shell-out seam exactly like the agent CLI: no SDK, no API key),
# the [HH:MM:SS]-stamped transcript is cached content-addressed in a dotdir SIBLING of the wiki
# (<wiki>/../.citadel_transcripts/<sha256>.md), and the agent session reads THAT text while citing
# the ORIGINAL media file — so `lines A-B` locators into audio sources stay offline-verifiable
# (lint and wiki_raw resolve them against the same cached transcript). Off (the default),
# audio/video files log as unreadable, exactly like images with CITADEL_IMAGE_SUPPORT=0.
AUDIO_SUPPORT: bool = _bool_env("CITADEL_AUDIO_SUPPORT", False)
# The whisper-class binary transcription shells out to: a name looked up on PATH or an absolute
# path (this ONE knob is both selector and override — no separate *_CLI_PATH var). It must speak
# the openai-whisper flag convention (`<file> --output_format srt --output_dir <dir>`) —
# whisper-ctranslate2 and mlx_whisper are drop-in compatible; whisper.cpp needs a small wrapper.
WHISPER_CLI: str = os.environ.get("CITADEL_WHISPER_CLI", "").strip() or "whisper"
# Optional model passed as `--model <value>` (e.g. turbo | small | large-v3); empty = the CLI's
# own default. Recorded nowhere — the transcript cache is keyed by source content, so switching
# models re-transcribes only after the cache entry is deleted (see docs/troubleshooting.md).
WHISPER_MODEL: str = os.environ.get("CITADEL_WHISPER_MODEL", "").strip()
# Per-FILE transcription timeout in seconds — deliberately separate from (and larger than)
# CITADEL_LLM_TIMEOUT: CPU transcription of a long recording takes minutes, not seconds.
WHISPER_TIMEOUT: int = _int_env("CITADEL_WHISPER_TIMEOUT", 3600)

# The wiki's TARGET LANGUAGE (BCP 47-ish code, default "en"): all wiki prose, titles, headings,
# descriptions, and tags are written in it REGARDLESS of the raw sources' languages (including a
# mixed-language corpus). Two provenance-honoring exceptions live in the rules (schema.md § Wiki
# language): verbatim quotes stay in their original language, and proper nouns are never
# translated. Passed to the agent as a run-instruction bullet; purely advisory to lint (no
# offline language detection).
WIKI_LANG: str = os.environ.get("CITADEL_WIKI_LANG", "").strip() or "en"

# Free-text operator guidance for the sessions of ONE ingest run ("create one machine registry
# from the maintenance lists", "focus on the 2026 figures"): appended to each source-reading
# session's prompt as its own bullet. Steering only — the rules still bind; guidance can never
# override the citation/grounding/off-limits rules, and the prompt says so. Usually set per run
# via `citadel ingest --guidance` (which overrides this env value; the flag caps it at
# GUIDANCE_MAX_CHARS to keep the prompt argv-safe). PERMANENT house rules belong in the
# workspace rules/local.md instead — this knob is the one-shot steer. Whitespace (newlines
# included) is collapsed to single spaces at every entry point — env here, the CLI flag, the MCP
# argument — because the prompt bullet the steer lands on is line-shaped.
INGEST_GUIDANCE: str = " ".join(os.environ.get("CITADEL_INGEST_GUIDANCE", "").split())

# Free-text operator guidance for ONE curate run ("the machine pages are named inconsistently —
# retitle them '<machine no> <model>'", "merge the three fault-code pages into one registry"): the
# twin of INGEST_GUIDANCE for the SECOND lifecycle, set per run via `citadel curate --guidance`.
# It travels in the per-cluster FINDINGS file (curate's designated instruction channel — the
# checklist the agent reads by path), not in the prompt frame, so the improve-or-NOOP rule sees the
# steer as a listed finding instead of a second, competing instruction. Deliberately SEPARATE from
# INGEST_GUIDANCE: one steers how new sources are folded in, the other how existing pages are
# reorganized, and inheriting either into the other lifecycle would apply a stale steer.
# Structure only — a steer can never change, add, or re-attribute a fact (curate.md § Operator
# guidance says so), and naming pages with it plans them even when no detector flagged them.
CURATE_GUIDANCE: str = " ".join(os.environ.get("CITADEL_CURATE_GUIDANCE", "").split())

# The --guidance length cap (characters), shared by both steers. The prompt must stay tiny (it
# travels on argv for the copilot/agy backends — the WinError 206 fix), so a run steer is a couple
# of sentences, not a document; anything longer belongs in rules/local.md.
GUIDANCE_MAX_CHARS: int = 2000

# How many OTHER raw sources one source-reading session may consult to resolve something its own
# source does not explain (an undefined term, code, abbreviation, or reference that only makes
# sense beside a file the wiki already holds). Sources arrive one at a time but knowledge does not,
# so a small, explicit budget lets a session close that gap — cited to the file it came from
# (core.md § Related sources) — while keeping a session from sweeping the whole raw tree: the
# number is named in the run instruction, and the rules make it a per-session ceiling on a
# last-resort lookup, not an invitation. 0 turns it off (read only your own source, the
# pre-budget behavior). Negatives clamp to 0.
RELATED_SOURCES: int = max(0, _int_env("CITADEL_RELATED_SOURCES", 3))

# PDF reading mode (formats/pdf.md): "text" (default) ingests the body text only; "images"
# additionally has the agent LOOK AT the pages' figures/diagrams/charts and capture what they
# show. Anything unrecognized falls back to text. The knob only selects which mode the run
# instruction names — the behavior itself lives in the rules file.
PDF_MODE: str = "images" if os.environ.get("CITADEL_PDF_MODE", "").strip().lower() == "images" else "text"


def _pdf_text_mode() -> str:
    """Resolve ``CITADEL_PDF_TEXT`` to one of ``auto``/``on``/``off``. Blank/unset and anything
    unrecognized mean ``auto``."""
    raw = os.environ.get("CITADEL_PDF_TEXT", "").strip().lower()
    if raw in ("1", "true", "yes", "on"):
        return "on"
    if raw in ("0", "false", "no", "off"):
        return "off"
    return "auto"


# PDF text-layer pre-pass (citadel/pdftext.py): pypdf is a bundled runtime dependency (PDFs are a
# common raw/ class), so a PDF's embedded text layer is extracted once per content and the agent
# reads the extraction — giving real, offline-verifiable `lines A-B` locators (lint/wiki_raw/viewer
# check them against the same cached text) and correct large-PDF chunking. Modes: "auto" (default)
# — on when pypdf imports, which it does unless it was deliberately uninstalled; "1" — force on (a
# force-removed pypdf then falls back per source, with a doctor warning); "0" — force off (the
# agent opens the PDF itself; `p. N` page locators, agent-verified — the pre-pre-pass behavior).
# Orthogonal to CITADEL_PDF_MODE: "images" still has the agent open the ORIGINAL PDF for its
# figures on top of the extraction.
PDF_TEXT: str = _pdf_text_mode()

# Persona/style capture (genres/first-person.md) — OPT-IN, default OFF. When on, first-person
# sources (interviews, memos, letters, diaries) additionally yield the person's opinions/
# preferences as attributed, dated, cited statements and a per-person style profile backed by
# verbatim cited examples. Off (the default), such sources still yield facts and load-bearing
# attributed positions, but no style profiling — in a many-person corpus every second document
# has a different, irrelevant style.
STYLE_PROFILES: bool = _bool_env("CITADEL_STYLE_PROFILES", False)

# Same-basename document dedup. When on (default), if two or more raw files in the SAME folder
# share a basename and are all document-export formats (a deck saved as both report.pptx AND
# report.pdf, say — set below), only ONE is ingested and the rest are skipped as duplicates (the
# skip is recorded in the failures report, pointing at the file that was kept). Preference order is
# PDF first, then modern Office, then legacy. Re-evaluated every run, so deleting the kept file
# promotes another. Set CITADEL_DEDUP_BY_BASENAME=0 to ingest every format separately.
DEDUP_BY_BASENAME: bool = _bool_env("CITADEL_DEDUP_BY_BASENAME", True)

# Large-source chunking. A single raw source whose extractable/readable text exceeds this many
# characters is ingested in several sequential agent passes (segments split on paragraph
# boundaries), so a file too big for one context window still folds in fully — each pass MERGES
# into the pages the earlier passes created. 0 disables chunking (every source is one pass, the old
# behavior). Images are never chunked (there is no text to split). A PDF is chunked the same way any
# other source is — only once its text exceeds the threshold — but as line windows over the
# extraction the text-layer pre-pass produced (CITADEL_PDF_TEXT), never as sliced files; a PDF
# without a usable text layer has no extractable text here and is read whole by the agent. The
# default (~75k tokens) is generous — modern models rarely have less context — so only genuinely
# large sources are split; lower it for a small-context backend, raise it (or set 0 to disable) for
# a very large one. CITADEL_MODEL_CONTEXT_TOKENS (below) tightens it without touching this value.
MAX_SOURCE_CHARS: int = _int_env("CITADEL_MAX_SOURCE_CHARS", 300000)


def _model_context_tokens() -> int:
    """Resolve ``CITADEL_MODEL_CONTEXT_TOKENS`` to a token count >= 0, where 0 means "not stated"
    (the default — nothing about chunking changes). A negative value is a misconfiguration, not a
    spelling of "unlimited": it falls back to 0 and records a :data:`CONFIG_WARNINGS` entry, so
    ``citadel doctor`` names it. A value that is merely *small* is not rejected here — that
    judgement is advisory and belongs to doctor's chunk-budget check, not to import time."""
    value = _int_env("CITADEL_MODEL_CONTEXT_TOKENS", 0)
    if value < 0:
        CONFIG_WARNINGS.append(f"CITADEL_MODEL_CONTEXT_TOKENS={value} is not a token count (>= 0) - using 0 (unset)")
        return 0
    return value


# The ingest model's usable context, in TOKENS, as stated by the operator — 0 (the default) means
# "not stated" and leaves chunking exactly as MAX_SOURCE_CHARS defines it.
#
# Why a second knob rather than "just lower MAX_SOURCE_CHARS": the char threshold is the right
# ceiling but the wrong UNIT for the question a local-model operator is actually answering. They
# know their `n_ctx` (llama.cpp/ollama print it); they do not know what citadel spends before a byte
# of source is read — every session reads schema.md + core.md + a task brief + a format brief + the
# genre briefs, ~12-13k tokens of rulebook, and then reads and writes wiki pages and tool output on
# top. That asymmetry is the whole point: the operator states the context, citadel owns the headroom
# math. Handing them a source-window budget instead would invite exactly the value that fails — the
# full prompt budget, with no room for anything else.
_SOURCE_WINDOW_FRACTION = 0.10
_CHARS_PER_TOKEN = 4

# The segment floor, in characters. Every segment is a full agent session that re-pays the ~13k-token
# rulebook read, so below this the fixed overhead dwarfs the payload and no segment carries enough
# context to be worth a page edit. Clamping here also makes a segment explosion impossible: any
# stated context under ~20k tokens resolves to this same window rather than to an unbounded pass
# count. Public because doctor's chunk-budget check compares against it.
MIN_CHUNK_CHARS = 8000

MODEL_CONTEXT_TOKENS: int = _model_context_tokens()


def context_budget_chars() -> int:
    """The source-window budget the stated model context implies, in characters — 0 when
    :data:`MODEL_CONTEXT_TOKENS` is unset. Unclamped on purpose: :func:`source_chunk_chars` applies
    the floor and the ceiling, and ``citadel doctor`` needs the raw figure to say WHY the floor
    engaged. The one home for this arithmetic, so doctor never re-derives it."""
    if MODEL_CONTEXT_TOKENS <= 0:
        return 0
    return int(MODEL_CONTEXT_TOKENS * _SOURCE_WINDOW_FRACTION * _CHARS_PER_TOKEN)


def source_chunk_chars() -> int:
    """The EFFECTIVE large-source chunking threshold — what ingest actually splits on.

    ``MAX_SOURCE_CHARS`` when no model context is stated (the default, so nothing changes for an
    existing workspace) or when chunking is switched off outright with ``CITADEL_MAX_SOURCE_CHARS=0``
    — a 0 there is an explicit "never chunk", and a stated context is a BUDGET, never an override.
    Otherwise the smaller of the two, floored at :data:`MIN_CHUNK_CHARS`.

    Read at call time (``config.MAX_SOURCE_CHARS`` is what tests monkeypatch), never cached."""
    if MAX_SOURCE_CHARS <= 0:
        return MAX_SOURCE_CHARS
    budget = context_budget_chars()
    if budget <= 0:
        return MAX_SOURCE_CHARS
    return min(MAX_SOURCE_CHARS, max(MIN_CHUNK_CHARS, budget))


def _max_source_bytes() -> int:
    """Resolve ``CITADEL_MAX_SOURCE_BYTES`` to a byte ceiling >= 0. A negative value is a
    misconfiguration, not "unlimited" spelled oddly: it falls back to 0 (no limit — the default)
    and records a :data:`CONFIG_WARNINGS` entry, so ``citadel doctor`` names it."""
    value = _int_env("CITADEL_MAX_SOURCE_BYTES", 0)
    if value < 0:
        CONFIG_WARNINGS.append(f"CITADEL_MAX_SOURCE_BYTES={value} is not a byte count (>= 0) - using 0 (no limit)")
        return 0
    return value


# Discovery SIZE ceiling, in bytes. A raw file bigger than this is skipped at discovery — before it
# is hashed, before it is sniffed — and reported as oversized; 0 (the default) means no limit, so
# the behavior is exactly what it has always been until this is deliberately set.
#
# The complement to CITADEL_IGNORE_PATTERNS: those match names, this matches SIZE, and size is what
# distinguishes a knowledge corpus from the machine-data that tends to sit beside it (a folder of
# 617 raw sensor dumps, 10 GB of `.tdms`, is useless to a wiki but expensive to scan — every
# untracked candidate is stream-hashed in full before anything can classify it as unreadable
# binary). Set it just above your largest real document to keep such a folder out of the scan.
#
# Deliberately OFF by default: silently dropping a large-but-legitimate source (a 2 GB lecture
# recording under CITADEL_AUDIO_SUPPORT, a scanned archive PDF) would be a data-loss-shaped
# surprise, and citadel's contract is that everything under a raw root is either ingested or
# reported. Skips ARE reported (the run report's "Oversized" section, `citadel status`), so the
# ceiling never hides work silently. Explicitly-named paths (`citadel ingest big.tdms`) bypass it —
# explicit always wins, exactly as it does for ignore patterns and hidden files. An already-ingested
# source that later crosses the ceiling stays in the wiki: it simply stops being re-checked.
MAX_SOURCE_BYTES: int = _max_source_bytes()

# Resume checkpoints for CHUNKED sources (citadel/resume.py). Promotion stays all-or-nothing — the
# live wiki only ever holds fully-imported sources — but when ON (default) each completed segment
# records the delta it produced in a dotdir sibling of the wiki (.citadel_resume/), so a run that
# died at segment N does not throw away segments 1..N-1's paid agent work: the next run replays
# that delta into a fresh staging copy, re-validates it, and continues at segment N. Every reuse is
# guarded (source sha, model, rules version, segment content, prompt knobs, per-page base state,
# re-validation) and every guard failure falls back to a full restart at segment 1 in the same run.
# Default ON like the PDF text pre-pass — it needs no extra binary and changes no wiki content, it
# only stops paying twice; 0 turns it off (nothing is written or read) if the plaintext page
# sidecar is unwanted. Only chunked sources ever create one.
RESUME: bool = _bool_env("CITADEL_RESUME", True)


def _jobs_setting() -> int:
    """Resolve ``CITADEL_JOBS`` to a worker count >= 1. A value below 1 is a misconfiguration, not
    an "unlimited" request: it clamps to 1 (strictly serial — the default) and records a
    :data:`CONFIG_WARNINGS` entry, so ``citadel doctor`` names it instead of silently ingesting
    serially while the user believes otherwise."""
    value = _int_env("CITADEL_JOBS", 1)
    if value < 1:
        CONFIG_WARNINGS.append(f"CITADEL_JOBS={value} is not a worker count (>= 1) - using 1 (serial)")
        return 1
    return value


# How many raw sources ingest may fold in CONCURRENTLY (`citadel ingest --jobs N` overrides it per
# run). 1 — the default — is the strictly serial behavior citadel has always had. Higher values run
# that many agent sessions at once, each against its OWN staging copy of the wiki (the isolation
# primitive was already there); promotion onto the live wiki stays serialized and base-aware, and a
# source whose session raced another one's promote is re-run serially before the run ends, so the
# wiki is never the sum of two sessions that could not see each other.
#
# The trade-off is cross-linking, not safety: concurrent sessions each read the wiki as it was when
# they started, so a page one of them creates is invisible to the others (a later `citadel curate`
# pass is the designed cleanup for that, and the serial re-run resolves the collisions). Keep it at
# 1 for the richest cross-linking; raise it for a large backlog of unrelated sources, where the run
# is dominated by per-session latency. Sensible ceiling: your agent CLI's own rate limits, which is
# what you will hit first — citadel imposes no maximum.
JOBS: int = _jobs_setting()


def _stall_limit() -> int:
    """Resolve ``CITADEL_STALL_LIMIT`` to a count >= 0 (0 = the check is off). A negative value is a
    misconfiguration, not "off" spelled oddly: it falls back to the default and records a
    :data:`CONFIG_WARNINGS` entry, so ``citadel doctor`` names it."""
    value = _int_env("CITADEL_STALL_LIMIT", 3)
    if value < 0:
        CONFIG_WARNINGS.append(f"CITADEL_STALL_LIMIT={value} is not a count (>= 0) - using 3")
        return 3
    return value


# How many CONSECUTIVE sources may come back from the agent having changed nothing at all before
# ingest stops the run (0 disables the check). See ingest._StallGuard: an agent CLI can fail in a
# way that does not look like failure — a self-update mid-run that leaves the new binary unable to
# launch its own tools, a revoked permission, a sandbox denying every subprocess — and then every
# session exits 0, reports its tokens, and returns an empty diff. Without a circuit breaker the run
# marches through the whole corpus at full price and produces nothing.
#
# Only sources that were EXPECTED to change something count (a fresh source, or a deletion cleanup
# planned because something still cites the source); a reconcile's "nothing changed" is a real
# verdict and is neither counted nor treated as a reset. 3 is deliberately small: the failure this
# guards against is total, not intermittent, so a healthy run essentially never reaches it, while a
# broken one is stopped after three sessions instead of after the corpus.
STALL_LIMIT: int = _stall_limit()


def _page_cache_mode() -> str:
    """Resolve ``CITADEL_PAGE_CACHE`` to one of ``auto``/``on``/``off``, mirroring
    :func:`_pdf_text_mode`. Blank/unset and anything unrecognized mean ``auto``."""
    raw = os.environ.get("CITADEL_PAGE_CACHE", "").strip().lower()
    if raw in ("1", "true", "yes", "on"):
        return "on"
    if raw in ("0", "false", "no", "off"):
        return "off"
    return "auto"


# In-memory page-snapshot cache (citadel/pagecache.py). A long-lived reader — `citadel serve` —
# otherwise re-walks and re-parses the entire wiki on EVERY MCP call; the cache serves the previous
# load while a stat-only walk proves nothing changed (~4 ms vs ~700 ms at 1000 pages), and search
# additionally reuses that snapshot's per-page term-frequency tables. Nothing is persisted: the
# wiki stays the database, and the filesystem is re-consulted on every single call.
#   auto (default) — on only where a process opts in, which today means `citadel serve`. One-shot
#                    CLI commands load once anyway; the mutating lifecycles (ingest, curate) run
#                    with the cache paused, so their diff-by-hash always reads truth from disk.
#   1/on           — additionally cache in any process that consults it (a scripted read loop).
#   0/off          — never cache; every load() re-walks and re-parses, as before this existed.
PAGE_CACHE: str = _page_cache_mode()

# --- Serving over HTTP (the opt-in Streamable HTTP transport, citadel/httpserve.py) -----------
# `citadel serve` speaks stdio by default — one client, one process, no network surface at all.
# `citadel serve --http` additionally binds a local HTTP port speaking MCP's Streamable HTTP
# transport, which is what a hosted client (claude.ai, a phone) can reach through a tunnel. That
# turns a local-only tool into a network service, so the knobs below are deliberately strict:
#
#   CITADEL_HTTP_TOKEN — the shared bearer token EVERY request must present. There is NO default
#                        and no "unauthenticated" mode: serving without it is refused, because the
#                        surface includes wiki_ingest (which spawns your coding-agent CLI) and your
#                        whole wiki. Generate one with:
#                            python -c "import secrets; print(secrets.token_urlsafe(32))"
#   CITADEL_HTTP_HOST  — bind address, default loopback. A non-loopback bind (0.0.0.0) is allowed
#                        but warns loudly: prefer a tunnel (cloudflared/tailscale/ssh -R) that
#                        terminates TLS, since this transport is plain HTTP.
#   CITADEL_HTTP_PORT  — bind port, default 8765.
#   CITADEL_HTTP_PATH  — the MCP endpoint path, default /mcp.
#   CITADEL_HTTP_READ_ONLY — when 1, the two mutating tools (wiki_capture, wiki_ingest) refuse over
#                        this server and only the eleven readers work. Off by default (a token
#                        holder is you), on for a share-the-read-surface setup.
#   CITADEL_HTTP_ALLOWED_HOSTS — the `Host` header values accepted (anti-DNS-rebinding). Unset =
#                        derived from the bind address, which is right for a DIRECT loopback or
#                        single-address bind and WRONG the moment a name is in front of it: a
#                        tunnel forwards `Host: your-tunnel.example.com`, and a wildcard bind
#                        (0.0.0.0 / ::) is a name no client ever sends. Name those hosts here (a
#                        wildcard bind refuses to start without it, instead of rejecting every
#                        request); `*` disables Host checking entirely, for when a proxy in front
#                        already filters it.
#   CITADEL_HTTP_ALLOWED_ORIGINS — the browser `Origin` values accepted. Unset = NONE: a request
#                        carrying any Origin is refused, which is exactly right for the non-browser
#                        MCP clients this serves. Name an origin (or `*`) only to admit a
#                        browser-based client.
HTTP_TOKEN: str = os.environ.get("CITADEL_HTTP_TOKEN", "").strip()
HTTP_HOST: str = os.environ.get("CITADEL_HTTP_HOST", "").strip() or "127.0.0.1"
HTTP_PORT: int = _int_env("CITADEL_HTTP_PORT", 8765)
HTTP_PATH: str = os.environ.get("CITADEL_HTTP_PATH", "").strip() or "/mcp"
HTTP_READ_ONLY: bool = _bool_env("CITADEL_HTTP_READ_ONLY", False)
HTTP_ALLOWED_HOSTS: list[str] = _split_list_env(os.environ.get("CITADEL_HTTP_ALLOWED_HOSTS", ""))
HTTP_ALLOWED_ORIGINS: list[str] = _split_list_env(os.environ.get("CITADEL_HTTP_ALLOWED_ORIGINS", ""))

# Wiki history (git). After every run that CHANGED the wiki (ingest or curate), citadel can commit
# the whole wiki directory so every change is a reviewable diff — the long-term audit trail
# log.md cannot give you. Modes (CITADEL_WIKI_GIT):
#   auto (default) — commit only when the wiki directory is already ITS OWN git repository (holds a
#                    .git); otherwise do nothing, silently. Citadel never creates repositories
#                    behind the user's back — `git init` the wiki dir (or set the knob below) to
#                    opt in.
#   1/init         — additionally `git init` the wiki dir on first use when it is not a repo yet.
#                    Refused (with a warning, never an error) when the wiki dir sits INSIDE another
#                    git working tree — an embedded repo would confuse the outer checkout (the dev
#                    workspace, a corpus wiki committed to this repo, a wiki inside a project
#                    repo). `git init` it yourself to overrule.
#   0/off          — never touch git.
# Commits are best-effort: any git failure becomes a one-line note on the report, never a failed
# run — the wiki itself is already promoted by then.


def _wiki_git_mode() -> str:
    """Resolve ``CITADEL_WIKI_GIT`` to one of ``off``/``init``/``auto``. Blank/unset and the
    explicit ``auto`` mean auto; an UNRECOGNIZED value also falls back to auto (the safe,
    never-creates-a-repo default) but records a :data:`CONFIG_WARNINGS` entry — a typo of "off"
    (``of``, ``fasle``) must surface in ``citadel doctor`` instead of silently keeping
    auto-commits (and pushes) on."""
    raw = os.environ.get("CITADEL_WIKI_GIT", "").strip().lower()
    if raw in _FALSY_TOKENS:
        return "off"
    if raw in (*_TRUTHY_TOKENS, "init"):
        return "init"
    if raw and raw != "auto":
        CONFIG_WARNINGS.append(
            f"CITADEL_WIKI_GIT={raw!r} is not a recognized mode (0/off, 1/init, auto) - using 'auto'"
        )
    return "auto"


WIKI_GIT: str = _wiki_git_mode()
# Optional push target for the wiki-history commits: a remote NAME (e.g. `origin`) or URL (GitHub,
# GitLab, any git host) passed verbatim to `git push <value> HEAD` after each commit. Empty
# (default) = commit locally only. A failed push is a report note, never a failed run.
WIKI_GIT_REMOTE: str = os.environ.get("CITADEL_WIKI_GIT_REMOTE", "").strip()

# Curate page-length thresholds, measured in BODY lines. `citadel lint`
# only WARNS at the soft threshold; `citadel curate` ACTS (plans a topic split, every fact keeping
# its citation) at the hard one. Deliberately module constants, not env knobs — one KISS default the
# rules-tree page-shape guidance is written around, not a per-workspace dial.
CURATE_PAGE_SOFT_LINES: int = 400
CURATE_PAGE_HARD_LINES: int = 800

# OS/system junk files & folders to IGNORE entirely during a raw/ scan — never ingested, never
# recorded in the manifest or the failures catalog. These are noise a file manager or the OS drops
# into folders (Windows thumbnail caches / desktop.ini, WSL-surfaced NTFS Zone.Identifier streams,
# macOS .DS_Store / resource forks, Office ~$ lock files, editor swap & backup files) that carry no
# wiki-worthy content — folding each into a
# "could not ingest" entry only clutters wiki/sources/index.md. Each pattern is a shell-style glob
# matched case-insensitively against a file OR directory BASENAME (fnmatch), so `*.tmp` and `~$*`
# work. Hidden dotfiles/dirs are already skipped separately, so these mainly cover the NON-hidden
# junk (a few common hidden ones are listed too, as documentation).
_DEFAULT_IGNORE_PATTERNS: tuple[str, ...] = (
    # Windows Explorer / system noise
    "Thumbs.db",
    "Thumbs.db:encryptable",
    "ehthumbs.db",
    "ehthumbs_vista.db",
    "desktop.ini",
    "$RECYCLE.BIN",
    "System Volume Information",
    # NTFS Alternate-Data-Stream sidecars surfaced as real files under WSL: copying a file in from
    # Windows leaves a `<name>:Zone.Identifier` mark-of-the-web stream (content: `[ZoneTransfer]`),
    # which WSL exposes as its own file. Colon-specific (a legit Linux name like `notes:v2.md` must
    # NOT be swallowed, so we do NOT use the generic `*:*`).
    "*:Zone.Identifier",
    # macOS Finder noise
    ".DS_Store",
    "._*",
    ".Spotlight-V100",
    ".Trashes",
    ".TemporaryItems",
    ".fseventsd",
    ".apdisk",
    # Office / editor lock, swap & backup files
    "~$*",
    "*.tmp",
    "*.temp",
    "*.swp",
    "*.swo",
    "*~",
    ".~lock.*#",
)


def _split_pattern_env(value: str) -> tuple[bool, list[str]]:
    """Split a glob-list env value into ``(extend, patterns)``, absorbing the ``+`` marker.

    The marker says "ADD to the built-in defaults instead of replacing them", and it is written
    ONCE in front of the whole value (``+*.bak,~backup*`` — the documented spelling). Users
    reasonably read the list as N independent additions and write a ``+`` on every entry
    (``+*.bak,+~backup*``), or spread the setting over several lines each carrying one; both are
    accepted here and mean exactly the same thing, because the alternative is silent breakage — a
    stray ``+`` would otherwise survive into the glob and only ever match a file whose name
    literally starts with ``+``.

    So: the marker counts wherever it appears, and it is stripped from every entry that carries it.
    A pattern that must match a real leading ``+`` writes it as a character class (``[+]draft.md``),
    which fnmatch reads as a literal and this splitter leaves alone."""
    entries = _split_list_env(value)
    extend = any(entry.startswith("+") for entry in entries)
    cleaned = [entry[1:].strip() if entry.startswith("+") else entry for entry in entries]
    return extend, [entry for entry in cleaned if entry]


def _resolve_ignore_patterns() -> list[str]:
    """Build the effective ignore list (the BLOCKLIST) from the built-in defaults and
    ``CITADEL_IGNORE_PATTERNS`` (split by the shared :func:`_split_pattern_env`): unset/blank keeps
    the defaults; a value carrying a ``+`` marker is ADDED to them (e.g. ``+*.bak,~backup*``); any
    other value REPLACES them (set it to a pattern that matches nothing to effectively disable —
    though ignoring these is almost always wanted).

    Its allowlist counterpart is :func:`_resolve_include_patterns`; when both are configured the
    blocklist wins (deny beats allow — see :func:`ingest_scan._is_included_name`)."""
    raw = os.environ.get("CITADEL_IGNORE_PATTERNS", "").strip()
    if not raw:
        return list(_DEFAULT_IGNORE_PATTERNS)
    extend, patterns = _split_pattern_env(raw)
    return list(_DEFAULT_IGNORE_PATTERNS) + patterns if extend else patterns


# Read at call time by ingest's discovery walk (tests monkeypatch this list directly).
IGNORE_PATTERNS: list[str] = _resolve_ignore_patterns()

_GLOB_CHARS = ("*", "?", "[")


def _normalize_include_pattern(entry: str) -> list[str]:
    """One ``CITADEL_INCLUDE_PATTERNS`` entry as the basename glob(s) it actually means.

    The knob exists to be written the way people say it out loud — "only read PDFs and text files"
    — so the two spellings that are not globs at all are translated instead of silently matching
    nothing:

    - ``.pdf`` (a bare extension) -> ``*.pdf``;
    - ``pdf`` (an extension without its dot) -> ``*.pdf`` **and** the literal name ``pdf``, since a
      bare word is genuinely ambiguous (``Makefile`` is a real, extensionless filename) and
      admitting one file too many is the harmless direction for an allowlist.

    Anything already carrying a glob character, or holding a dot inside it (``notes.md``), is taken
    verbatim as an exact/globbed basename match."""
    if any(ch in entry for ch in _GLOB_CHARS):
        return [entry]
    if entry.startswith(".") and entry.count(".") == 1:
        return ["*" + entry]
    if "." not in entry:
        return [f"*.{entry}", entry]
    return [entry]


def _resolve_include_patterns() -> list[str]:
    """The discovery ALLOWLIST from ``CITADEL_INCLUDE_PATTERNS``: when set, a raw file is only
    discovered if its NAME matches one of these globs ("in raw/, read only .pdf and .txt").
    Unset/blank (the default) returns an empty list, which admits everything — the behavior citadel
    has always had.

    Entries are normalized by :func:`_normalize_include_pattern` and deduped; a leading ``+`` is
    tolerated and ignored (this knob has no built-in defaults to extend, and a user who just wrote
    an ignore list should not be punished for carrying the habit over). A pattern holding a path
    separator can never match — matching is per FILE NAME, exactly like the ignore globs — so it is
    recorded as a config warning rather than silently admitting nothing."""
    raw = os.environ.get("CITADEL_INCLUDE_PATTERNS", "").strip()
    if not raw:
        return []
    _extend, entries = _split_pattern_env(raw)
    patterns: list[str] = []
    for entry in entries:
        if "/" in entry or "\\" in entry:
            CONFIG_WARNINGS.append(
                f"CITADEL_INCLUDE_PATTERNS entry {entry!r} contains a path separator - patterns are "
                "matched against file NAMES only, so this one can never match (use e.g. '*.pdf')"
            )
        for pattern in _normalize_include_pattern(entry):
            if pattern not in patterns:
                patterns.append(pattern)
    return patterns


# Read at call time by ingest's discovery walk (tests monkeypatch this list directly). Empty =
# no allowlist = every file is a candidate.
INCLUDE_PATTERNS: list[str] = _resolve_include_patterns()


# The model a source was ingested with is recorded in two layers, and only one of them is a fact:
#   - ingest_model_label() — what was REQUESTED (the backend plus the configured CITADEL_INGEST_MODEL).
#   - model_label_for(reported) — what the backend said ACTUALLY ran, which is what ingest stamps.
# The second wins wherever it exists; the first is only the fallback for a backend/session that
# named nothing. Recording a configured id as if it were fact is exactly the lie this split
# removes: `auto` selections, backend fallbacks and stale .env values would all be misreported.


def ingest_model_label() -> str:
    """The model/backend an ingest was ASKED to run on: ``"claude:opus"``, ``"copilot"``,
    ``"agy:gemini-3.1-pro-high"``. Resolved at call time so tests (and curate's model swap) can
    monkeypatch the inputs.

    ``<cli>:<model>`` when :data:`INGEST_MODEL` is configured, a bare ``<cli>`` when it is not
    (the knob has no default, so "unset" honestly means "the backend's own default model").
    This is the FALLBACK label: :func:`model_label_for` prefers what the session actually
    reported."""
    cli = (LLM_CLI or "claude").strip().lower()
    model = (INGEST_MODEL or "").strip()
    return f"{cli}:{model}" if model else cli


def model_label_for(reported: str | None) -> str:
    """The label to RECORD for a finished session: the backend's own report of the model that
    served it (``"copilot:claude-sonnet-4.5"``, ``"claude:claude-opus-5"``), falling back to
    :func:`ingest_model_label` when the backend named nothing (an older CLI, an agy session left
    on its default model, a parse miss). Prefixed with the CLI so the manifest keeps saying WHICH
    backend produced a page's facts — two backends can serve the same underlying model."""
    name = (reported or "").strip()
    if not name:
        return ingest_model_label()
    return f"{(LLM_CLI or 'claude').strip().lower()}:{name}"
