"""ORCH-057 D2/D4/D6: ownership-detect leaf (src/fs_normalize.py) unit tests.

TC-03..TC-09 (04-test-plan.yaml). All FS-dependent tests use ``tmp_path`` and vary
``target_uid`` (a uid no tmp file actually has -> mismatch; the runner's own uid ->
clean) so NO real chown / privilege is needed. ``os.geteuid`` is monkeypatched for
the privilege-gated normalize test (TC-08). Never touches /repos.
"""
import os
import tempfile

import pytest

_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_fsn.db")
os.environ["ORCH_DB_PATH"] = _test_db
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
os.environ["ORCH_GITEA_TOKEN"] = "test-token"
os.environ["ORCH_PLANE_API_TOKEN"] = "test-token"

from src import fs_normalize


_NONEXISTENT_UID = 999999  # no tmp file is owned by this uid -> deterministic mismatch


@pytest.fixture(autouse=True)
def _reset(monkeypatch):
    fs_normalize.reset_cache()
    monkeypatch.setattr(fs_normalize.settings, "fs_normalize_enabled", True)
    monkeypatch.setattr(fs_normalize.settings, "fs_normalize_repos", "")
    monkeypatch.setattr(fs_normalize.settings, "fs_normalize_auto", False)
    monkeypatch.setattr(fs_normalize.settings, "fs_scan_cache_ttl_s", 300)
    yield
    fs_normalize.reset_cache()


@pytest.fixture
def tree(tmp_path):
    """A small dir tree with a file, owned by the test runner's own uid."""
    d = tmp_path / "root"
    (d / "sub").mkdir(parents=True)
    (d / "a.txt").write_text("a")
    (d / "sub" / "b.txt").write_text("b")
    return d


# ---------------------------------------------------------------------------
# TC-03 / TC-04 — scan verdict
# ---------------------------------------------------------------------------
def test_tc03_scan_detects_mismatch(tree):
    """TC-03: a tree whose files are not owned by target_uid -> mismatch=True with the
    affected root listed and a sample path set."""
    scan = fs_normalize.scan_ownership(roots=[str(tree)], target_uid=_NONEXISTENT_UID)
    assert scan.mismatch is True
    assert str(tree) in scan.roots_mismatch
    assert scan.sample_path is not None
    assert scan.target_uid == _NONEXISTENT_UID


def test_tc04_clean_tree_no_mismatch(tree):
    """TC-04: a clean tree (all files owned by target_uid == the runner) -> idempotent
    mismatch=False no-op."""
    scan = fs_normalize.scan_ownership(roots=[str(tree)], target_uid=os.getuid())
    assert scan.mismatch is False
    assert scan.roots_mismatch == []
    assert scan.sample_path is None


# ---------------------------------------------------------------------------
# TC-05 — never-raise on bad/missing root
# ---------------------------------------------------------------------------
def test_tc05_never_raise_on_missing_root(tmp_path):
    """TC-05: a non-existent root degrades to mismatch=False, never raises."""
    missing = str(tmp_path / "does-not-exist")
    scan = fs_normalize.scan_ownership(roots=[missing], target_uid=_NONEXISTENT_UID)
    assert scan.mismatch is False
    assert scan.roots_checked == []  # the missing root is skipped


def test_tc05_never_raise_on_walk_error(tree, monkeypatch):
    """TC-05: an os.walk explosion mid-scan degrades to a conservative verdict."""
    def boom(*a, **k):
        raise OSError("simulated walk failure")

    monkeypatch.setattr(fs_normalize.os, "walk", boom)
    scan = fs_normalize.scan_ownership(roots=[str(tree)], target_uid=_NONEXISTENT_UID)
    # The root dir itself is owned by the runner (not _NONEXISTENT_UID was checked via
    # lstat which still works) -> walk error swallowed, no exception escapes.
    assert isinstance(scan, fs_normalize.OwnershipScan)


# ---------------------------------------------------------------------------
# TC-06 — applies() scope
# ---------------------------------------------------------------------------
def test_tc06_applies_empty_csv_self_hosting_only(monkeypatch):
    """TC-06: empty ORCH_FS_NORMALIZE_REPOS -> True only for the self-hosting repo
    (orchestrator), False for enduro-trails."""
    monkeypatch.setattr(fs_normalize.settings, "fs_normalize_repos", "")
    assert fs_normalize.applies("orchestrator") is True
    assert fs_normalize.applies("enduro-trails") is False


def test_tc06_applies_explicit_csv(monkeypatch):
    """TC-06: a non-empty CSV scopes by list (case-insensitive)."""
    monkeypatch.setattr(fs_normalize.settings, "fs_normalize_repos", "enduro-trails")
    assert fs_normalize.applies("enduro-trails") is True
    assert fs_normalize.applies("orchestrator") is False


# ---------------------------------------------------------------------------
# TC-07 — kill-switch
# ---------------------------------------------------------------------------
def test_tc07_killswitch_off_scan_inert(tree, monkeypatch):
    """TC-07: fs_normalize_enabled=False -> scan is inert (mismatch=False, enabled
    flag exposes the off state); applies() False for everyone."""
    monkeypatch.setattr(fs_normalize.settings, "fs_normalize_enabled", False)
    scan = fs_normalize.scan_ownership(roots=[str(tree)], target_uid=_NONEXISTENT_UID)
    assert scan.mismatch is False
    assert scan.enabled is False
    assert fs_normalize.applies("orchestrator") is False


def test_tc07_killswitch_off_normalize_inert(tree, monkeypatch):
    """TC-07: normalize is a documented no-op when the kill-switch is off."""
    monkeypatch.setattr(fs_normalize.settings, "fs_normalize_enabled", False)
    res = fs_normalize.normalize(roots=[str(tree)], target_uid=_NONEXISTENT_UID)
    assert res["attempted"] is False
    assert res["changed"] == 0
    assert "disabled" in res["note"]


# ---------------------------------------------------------------------------
# TC-08 — normalize without privilege
# ---------------------------------------------------------------------------
def test_tc08_normalize_without_rights_is_noop_not_error(tree, monkeypatch):
    """TC-08: under a non-root euid with auto=True and foreign files, normalize is a
    no-op + honest log ('operator procedure required'), NOT an exception."""
    monkeypatch.setattr(fs_normalize.settings, "fs_normalize_auto", True)
    monkeypatch.setattr(fs_normalize.os, "geteuid", lambda: 1000)  # non-root
    res = fs_normalize.normalize(roots=[str(tree)], target_uid=_NONEXISTENT_UID)
    assert res["privileged"] is False
    assert res["attempted"] is False
    assert res["changed"] == 0
    assert "INFRA.md" in res["note"]


# ---------------------------------------------------------------------------
# TC-09 — TTL cache
# ---------------------------------------------------------------------------
def test_tc09_ttl_cache_avoids_rescan(tree, monkeypatch):
    """TC-09: a repeat call inside the TTL window does NOT re-walk; force/reset
    invalidates (mirrors preflight._cache)."""
    calls = {"n": 0}
    real_scan = fs_normalize._scan

    def counting_scan(roots, target_uid):
        calls["n"] += 1
        return real_scan(roots, target_uid)

    monkeypatch.setattr(fs_normalize, "_scan", counting_scan)

    fs_normalize.scan_ownership(roots=[str(tree)], target_uid=_NONEXISTENT_UID)
    fs_normalize.scan_ownership(roots=[str(tree)], target_uid=_NONEXISTENT_UID)
    assert calls["n"] == 1  # second call served from cache

    fs_normalize.scan_ownership(roots=[str(tree)], target_uid=_NONEXISTENT_UID, force=True)
    assert calls["n"] == 2  # force bypasses the cache

    fs_normalize.reset_cache()
    fs_normalize.scan_ownership(roots=[str(tree)], target_uid=_NONEXISTENT_UID)
    assert calls["n"] == 3  # reset invalidates


def test_tc09_cache_keyed_by_roots_and_uid(tree, monkeypatch):
    """A different (roots, target_uid) key is not served from another key's cache."""
    calls = {"n": 0}
    real_scan = fs_normalize._scan

    def counting_scan(roots, target_uid):
        calls["n"] += 1
        return real_scan(roots, target_uid)

    monkeypatch.setattr(fs_normalize, "_scan", counting_scan)
    fs_normalize.scan_ownership(roots=[str(tree)], target_uid=_NONEXISTENT_UID)
    fs_normalize.scan_ownership(roots=[str(tree)], target_uid=os.getuid())  # different uid
    assert calls["n"] == 2


# ---------------------------------------------------------------------------
# classifier (pure) + snapshot
# ---------------------------------------------------------------------------
def test_classify_worktree_error_markers():
    assert fs_normalize.classify_worktree_error("fatal: ...: Permission denied") is True
    assert fs_normalize.classify_worktree_error("could not create leading directories") is True
    assert fs_normalize.classify_worktree_error("insufficient permission for adding an object") is True
    assert fs_normalize.classify_worktree_error("fatal: branch already checked out") is False
    assert fs_normalize.classify_worktree_error("") is False
    assert fs_normalize.classify_worktree_error(None) is False


def test_is_permission_failure_from_exc():
    assert fs_normalize.is_permission_failure(exc=PermissionError(13, "denied")) is True
    import errno as _errno
    assert fs_normalize.is_permission_failure(exc=OSError(_errno.EACCES, "x")) is True
    assert fs_normalize.is_permission_failure(exc=OSError(_errno.ENOENT, "x")) is False


def test_snapshot_shape(tree, monkeypatch):
    """snapshot() returns the additive fs_ownership block and never raises."""
    monkeypatch.setattr(fs_normalize.settings, "fs_scan_roots", str(tree))
    snap = fs_normalize.snapshot()
    for k in ("enabled", "auto", "repos", "target_uid", "mismatch",
              "roots_checked", "roots_mismatch", "sample_path", "checked_at"):
        assert k in snap
    assert snap["enabled"] is True
