"""The per-run feature workspace — one FLAT versioned folder per run, one artifact per producing
stage.

Every lifecycle stage's artifact for a run lies flat in that run's feature folder
``specs-src/<NNN>-<slug>/``::

    specs-src/<NNN>-<slug>/
      discovery.md             # the Discovery stage's problem-framing note
      spec.md                  # the specification (the Specify stage's artifact)
      plan.md                  # the Plan stage's markdown, flat
      implementation-plan.md   # the Tasks stage's artifact (legacy name: tasks.md)
      oracle.md                # a *record* linking the authored oracle tests
      changelog.md             # a *record* of the implementation changes (legacy: implement.md)

Legacy layouts stay resolvable and runnable for existing features: the legacy base folder
``specs/`` (read-resolved after ``specs-src/``, never written), the legacy stage filenames
(``tasks.md`` for the tasks step, ``implement.md`` for the implement step — read-resolved after
the canonical names, never written), the older flat layout inside a feature folder (identical to
the new canonical one), and the legacy split layout (``spec/spec.md`` + ``artifacts/<step>.md``).
Resolution prefers the canonical location and falls back to the legacy one — never yielding two
paths for one stage or one feature.

The engine auto-allocates the ``<NNN>-<slug>`` run folder: ``<NNN>`` is the maximum
existing ``NNN-`` prefix under ``specs-src/`` plus one, zero-padded to three digits, and ``<slug>``
is derived deterministically from the intent. All functions here are deterministic, offline path
logic — no network, no model, no ledger.
"""

from __future__ import annotations

import re
from collections.abc import Iterable
from pathlib import Path

# The run-artifact base folder every new run writes under, and the legacy base folder name —
# still read-resolvable (after the canonical one), never written.
SPECS_DIR = "specs-src"
LEGACY_SPECS_DIR = "specs"

# The legacy split-layout workspace subfolders — still resolvable, never written.
SPEC_DIR = "spec"
ARTIFACTS_DIR = "artifacts"

# The producing lifecycle steps — exactly the steps that declare a flat markdown artifact in the
# feature folder. Pure gate / verdict / sign-off / advance steps stay ledger-only.
PRODUCING_STEPS: tuple[str, ...] = ("discovery", "specify", "plan", "tasks", "oracle", "implement")

# The canonical artifact filename per producing step where it differs from ``<step>.md``: the
# tasks step writes the detail-level implementation plan, and the implement step leaves an
# engine-generated changelog record. Legacy filenames stay read-resolvable, never written.
_STEP_FILENAMES: dict[str, str] = {
    "specify": "spec.md",
    "tasks": "implementation-plan.md",
    "implement": "changelog.md",
}
_LEGACY_STEP_FILENAMES: dict[str, str] = {"tasks": "tasks.md", "implement": "implement.md"}


def step_filename(step: str) -> str:
    """The canonical flat artifact filename a producing step WRITES (``<step>.md`` by default)."""
    return _STEP_FILENAMES.get(step, f"{step}.md")


# Slug bounds: a fixed maximum length, and a fixed fallback token when the intent
# slugifies to empty (e.g. all punctuation).
SLUG_MAX_LEN = 48
SLUG_FALLBACK = "feature"


def spec_path(feature_dir: Path) -> Path | None:
    """The feature's single specification file, whichever layout.

    The canonical flat layout (``<feature>/spec.md`` — identical to the older legacy layout) wins
    over the legacy split layout (``<feature>/spec/spec.md``) when both exist, so resolution always
    yields exactly one spec per feature folder; ``None`` when the folder holds no specification."""
    flat = feature_dir / "spec.md"
    if flat.is_file():
        return flat
    split = feature_dir / SPEC_DIR / "spec.md"
    if split.is_file():
        return split
    return None


def feature_dir_of(spec: Path) -> Path:
    """The feature workspace folder a resolved spec file belongs to (both layouts)."""
    parent = spec.parent
    return parent.parent if parent.name == SPEC_DIR else parent


def artifacts_dir(feature_dir: Path) -> Path:
    """The legacy split layout's artifact subfolder — resolvable for legacy features only."""
    return feature_dir / ARTIFACTS_DIR


def stage_artifact_path(feature_dir: Path, step: str) -> Path:
    """Where a producing step's artifact is WRITTEN — flat in the feature folder.

    ``spec.md`` for ``specify``, ``implementation-plan.md`` for ``tasks``, ``changelog.md`` for
    ``implement``, and ``<step>.md`` for every other step. No ``spec/`` or ``artifacts/``
    subfolder is ever part of a write location; the legacy filenames are never written."""
    return feature_dir / step_filename(step)


def find_artifact(feature_dir: Path, step: str) -> Path | None:
    """An existing stage artifact — the canonical flat path first, then the legacy fallbacks.

    Never returns two paths for one stage: the canonical flat filename wins; the legacy flat
    filename (``tasks.md`` / ``implement.md``) and the legacy split location
    (``artifacts/<step>.md``) stay readable for existing features, in that order."""
    if step == "specify":
        return spec_path(feature_dir)
    flat = feature_dir / step_filename(step)
    if flat.is_file():
        return flat
    legacy_name = _LEGACY_STEP_FILENAMES.get(step)
    if legacy_name is not None:
        legacy = feature_dir / legacy_name
        if legacy.is_file():
            return legacy
    split = artifacts_dir(feature_dir) / f"{step}.md"
    if split.is_file():
        return split
    return None


def find_specs(root: Path) -> list[Path]:
    """Every feature's resolved specification under ``<root>/specs-src`` — one per feature folder.

    Read-tolerant of the legacy base: features under the legacy ``<root>/specs`` are included
    after the canonical ones, so existing repositories keep resolving without a rewrite.
    Deduplicates by feature folder so a feature never yields two specs (the flat layout wins),
    keeping the exactly-one property across a mixed-layout tree."""
    seen: dict[Path, Path] = {}
    for base in (SPECS_DIR, LEGACY_SPECS_DIR):
        specs_root = root / base
        if not specs_root.is_dir():
            continue
        for candidate in sorted(specs_root.glob("**/spec.md")):
            feature = feature_dir_of(candidate)
            resolved = spec_path(feature)
            if resolved is not None:
                seen[feature] = resolved
    return sorted(set(seen.values()))


def resolve_feature_dir(root: Path, nnn: str) -> Path:
    """Resolve a feature workspace folder from its number: ``specs-src/<nnn>-*/``.

    ``nnn`` is the folder-name prefix before the first ``-`` (usually the zero-padded run number the
    engine allocated, e.g. ``030``). Read-tolerant of the legacy base: the canonical ``specs-src/``
    is searched first, then the legacy ``specs/`` — the canonical base wins when both hold a match,
    mirroring the flat-wins layout resolution. Exactly one directory must match within the resolved
    base; the two failure modes carry user-facing messages naming the fix:

    Raises:
        FileNotFoundError: no ``specs-src/<nnn>-*/`` (or legacy ``specs/<nnn>-*/``) directory
            exists under ``root``.
        LookupError: more than one directory matches — the prefix is ambiguous.
    """
    for base in (SPECS_DIR, LEGACY_SPECS_DIR):
        matches = sorted(p for p in (root / base).glob(f"{nnn}-*") if p.is_dir())
        if len(matches) > 1:
            names = ", ".join(f"{base}/{p.name}" for p in matches)
            raise LookupError(
                f"'{nnn}' is ambiguous — {len(matches)} feature folders match ({names}); pass "
                "--spec <path/to/spec.md>"
            )
        if matches:
            return matches[0]
    raise FileNotFoundError(
        f"no feature folder matches {SPECS_DIR}/{nnn}-*/ (or legacy {LEGACY_SPECS_DIR}/) — "
        "check the number, or pass --spec <path/to/spec.md>"
    )


# --------------------------------------------------------------------------- run-folder allocation
def slugify(text: str, max_len: int = SLUG_MAX_LEN) -> str:
    """Derive the run folder's slug from the intent — deterministic, pure, idempotent.

    Lowercased; runs of non-alphanumeric characters collapse to a single hyphen; leading/trailing
    hyphens are trimmed; the result is bounded to ``max_len`` with no trailing hyphen; an empty
    result falls back to the fixed token ``feature``. ``slug(slug(x)) == slug(x)``."""
    slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
    if len(slug) > max_len:
        slug = slug[:max_len].rstrip("-")
    return slug or SLUG_FALLBACK


def _ondisk_numbers(specs_root: Path) -> list[int]:
    """Every ``NNN-`` prefix number of a folder directly under ``specs_root`` (empty when absent)."""
    nums: list[int] = []
    if specs_root.is_dir():
        for d in specs_root.iterdir():
            m = re.match(r"(\d+)-", d.name)
            if m:
                nums.append(int(m.group(1)))
    return nums


def next_run_number(
    specs_root: Path,
    *,
    branch_numbers: Iterable[int] = (),
    ledger_numbers: Iterable[int] = (),
) -> int:
    """The next-free ``<NNN>`` over the UNION of on-disk folders, git branches, and the ledger.

    A fresh run's number is ``max(union) + 1`` over three sources: the on-disk ``NNN-`` prefixes
    directly under ``specs_root``, plus the caller-supplied ``branch_numbers`` (git run-branch
    numbers) and ``ledger_numbers`` (signed-ledger run ids). Taking the union means a fresh run
    always clears a prior run that lives ONLY on an unmerged branch or ONLY in the ledger — never
    reusing its number, so it never adopts the stale run's folder or branch.

    Purity is load-bearing (the caller gathers the git and ledger inputs): this function imports no
    git and no ledger, so it stays deterministic and unit-testable with plain lists. An empty union
    of ``branch_numbers`` and ``ledger_numbers`` degrades to the on-disk-only number, keeping the
    historical single-source behaviour (``next_feature_number``) exact.
    """
    nums = [0, *_ondisk_numbers(specs_root), *branch_numbers, *ledger_numbers]
    return max(nums) + 1


def next_feature_number(specs_root: Path) -> int:
    """The next ``<NNN>`` under the base folder: the maximum existing ``NNN-`` prefix plus one.

    The on-disk-only case of :func:`next_run_number` (empty git/ledger union), kept for callers
    that only see the working tree."""
    return next_run_number(specs_root)


def feature_folder_name(
    specs_root: Path,
    intent: str,
    *,
    branch_numbers: Iterable[int] = (),
    ledger_numbers: Iterable[int] = (),
) -> str:
    """The ``<NNN>-<slug>`` folder name a new run allocates.

    A pure function of the ``specs-src/`` directory listing, the caller-supplied git/ledger union
    inputs, and the intent string — byte-identical on any machine for the same inputs. With an
    empty union it reproduces the on-disk-only number (back-compat)."""
    number = next_run_number(
        specs_root, branch_numbers=branch_numbers, ledger_numbers=ledger_numbers
    )
    return f"{number:03d}-{slugify(intent)}"


def allocate_feature_dir(
    root: Path,
    intent: str,
    *,
    branch_numbers: Iterable[int] = (),
    ledger_numbers: Iterable[int] = (),
) -> Path:
    """Allocate the new run's feature folder ``specs-src/<NNN>-<slug>/``.

    New runs always write under the canonical base (``specs-src/``); the legacy ``specs/`` base is
    read-only. The ``<NNN>`` is the next-free over the union of on-disk folders and the
    caller-supplied ``branch_numbers`` / ``ledger_numbers`` (see :func:`next_run_number`), so a
    fresh run never reuses a number that survives only on a branch or in the ledger. Creates the
    folder, failing fast with :class:`FileExistsError` when the target already exists (e.g. two
    concurrent runs picked the same number) — the final race backstop; a folder allocated for a
    different run is never overwritten. Cross-process locking is an explicit non-goal."""
    specs_root = root / SPECS_DIR
    target = specs_root / feature_folder_name(
        specs_root, intent, branch_numbers=branch_numbers, ledger_numbers=ledger_numbers
    )
    specs_root.mkdir(parents=True, exist_ok=True)
    target.mkdir(exist_ok=False)
    return target
