r"""Layout guard (audit F4, 2026-06-10): migrated files may not rebuild v1
'output/' or v2 'sequences/' project paths through simple Pathlib slash,
os.path.join, joinpath, or f-string path construction. **Scan contract: run
the patterns against the FULL FILE TEXT with newline-tolerant regexes
(re.DOTALL-style `\s*` between the call open-paren and the literal, e.g.
`os\.path\.join\([^)]*?["']output["']` with DOTALL), never line-by-line -- a
multiline `os.path.join(\n    root,\n    "output", ...)` must be caught.** The
v3 layout (assets/ prep/ renders/ _pipeline/state/) is the only sanctioned
write surface for these files.

Known-red modules outside this build are documented in OFF_SCOPE_ALLOWLIST.
They are not scanned here; each entry has a retirement vehicle.
"""
import re
from pathlib import Path

REPO = Path(__file__).resolve().parents[3]  # recoil/core/tests/ -> repo root

GUARDED_FILES = {
    "recoil/pipeline/orchestrator/coverage_planner.py",
    "recoil/pipeline/_lib/client_bridge.py",
}

OFF_SCOPE_ALLOWLIST = {
    # Deprecated-path shim and resolver docs/messages; retire after all callers
    # use ProjectPaths methods directly and the old free functions are removed.
    "recoil/core/paths.py": "remove deprecated path APIs after v3 caller migration",
    # Dead ProvenanceWriter, zero live constructor call sites; retire or migrate
    # if provenance writing is revived.
    "recoil/pipeline/orchestrator/take_provenance.py": "delete dead ProvenanceWriter or migrate when revived",
    # Consumed only by deprecated 8430 review server; remove with that console.
    "recoil/pipeline/_lib/fs_watcher/events.py": "delete with deprecated 8430 review_server",
    # Legacy dict compatibility exposes output_dir for old API callers.
    "recoil/pipeline/api/deps.py": "remove legacy dict view after API routes use ProjectPaths directly",
    # Location-id route still reads v1 refs/locations.
    "recoil/pipeline/api/routes/files.py": "migrate /api/files/location-ids to ProjectPaths.asset_class_dir('loc')",
    # Experimental prompt comparison tool reads legacy frames/previs.
    "recoil/pipeline/tools/prompt_ab_test.py": "retire or migrate AB tool to prep/renders paths",
    # Required dispatch-audit tool checks legacy output/video sidecars. This file
    # must never be scanned or blocked by this guard.
    "recoil/pipeline/tools/audit_dispatch.py": "migrate audit sidecar discovery after dispatch audit supports v3 renders",
    # Calibration helper is tied to afterimage v1 refs.
    "recoil/pipeline/tools/calibrate_models.py": "retire afterimage calibration helper or migrate to assets taxonomy",
    # Import helper writes legacy video/manifests.
    "recoil/pipeline/tools/ingest_cli.py": "migrate ingest outputs to renders and _pipeline/state manifests",
    # Seedance/Kling experiment harnesses write v1 ab_tests/frames.
    "recoil/pipeline/tools/seedance_vs_kling_v2v_ab.py": "retire or migrate experiment output under _pipeline/tests",
    "recoil/pipeline/tools/seedance_zh_ab_test.py": "retire or migrate experiment output under _pipeline/tests",
    "recoil/pipeline/tools/seedance_json_ab_test.py": "retire or migrate experiment output under _pipeline/tests",
    # Camera-ref generator defaults to v1 output/refs/camera_refs.
    "recoil/pipeline/tools/generate_camera_refs.py": "migrate generated camera refs into assets/loc or _pipeline/visual",
    # Batch gate utility reads legacy frames/location refs.
    "recoil/pipeline/tools/batch_gate2_test.py": "retire or migrate batch gate utility to prep/assets",
    # Asset population script targets legacy output/refs.
    "recoil/pipeline/tools/populate_assets.py": "replace with v3 asset taxonomy importer",
    # One-off sequence runner writes v1 output/video.
    "recoil/pipeline/tools/run_seq11_call911.py": "delete one-off runner after archival",
    # Dispatch CLI still has legacy frame fallback and Veo refs lookup.
    "recoil/pipeline/tools/dispatch_cli.py": "migrate frame fallback to prep and Veo refs to ProjectPaths.resolve_ref",
    # Seedream probe writes legacy frames.
    "recoil/pipeline/tools/probe_seedream_edit.py": "retire probe or migrate output under _pipeline/tests",
    # Workspace server keeps legacy output/video compatibility for orphan and
    # playback flows during v3 transition.
    "recoil/workspace/server.py": "migrate workspace video/orphan flows fully to renders/ep_NNN",
    # Sidecar restore accepts old output/_archive paths for historical restores.
    "recoil/workspace/sidecar.py": "remove legacy archive restore fallback after archive migration",
}

PATHLIB_SLASH_FORBIDDEN = re.compile(
    r"""["'](?:output|sequences)["']\s*/|/\s*["'](?:output|sequences)["']""",
    re.DOTALL,
)
OS_PATH_JOIN_FORBIDDEN = re.compile(
    r"""os\.path\.join\([^)]*?["'](?:output|sequences)["']""",
    re.DOTALL,
)
JOINPATH_FORBIDDEN = re.compile(
    r"""\.joinpath\([^)]*?["'](?:output|sequences)["']""",
    re.DOTALL,
)
FSTRING_FORBIDDEN = re.compile(
    r"""f["'][^"']*(?:output|sequences)/|f["'][^"']*/(?:output|sequences)(?:/|["'])""",
    re.DOTALL,
)

FORBIDDEN_PATTERNS = (
    ("pathlib slash", PATHLIB_SLASH_FORBIDDEN),
    ("os.path.join", OS_PATH_JOIN_FORBIDDEN),
    ("joinpath", JOINPATH_FORBIDDEN),
    ("f-string path", FSTRING_FORBIDDEN),
)


def _line_number(text: str, offset: int) -> int:
    return text.count("\n", 0, offset) + 1


def _line_text(text: str, offset: int) -> str:
    line_start = text.rfind("\n", 0, offset) + 1
    line_end = text.find("\n", offset)
    if line_end == -1:
        line_end = len(text)
    return text[line_start:line_end].strip()


def test_no_v1_or_v2_path_construction_in_migrated_files():
    offenders = []
    for rel in sorted(GUARDED_FILES):
        py = REPO / rel
        text = py.read_text(encoding="utf-8")
        for label, pattern in FORBIDDEN_PATTERNS:
            for match in pattern.finditer(text):
                line = _line_number(text, match.start())
                offenders.append(f"{rel}:{line}: {label}: {_line_text(text, match.start())}")
    assert not offenders, (
        "v1/v2 project-path construction in migrated files (use ProjectPaths):\n"
        + "\n".join(offenders)
    )


def test_off_scope_allowlist_does_not_gate_audit_dispatch():
    assert "recoil/pipeline/tools/audit_dispatch.py" in OFF_SCOPE_ALLOWLIST
    assert "recoil/pipeline/tools/audit_dispatch.py" not in GUARDED_FILES
