"""Tests for filesystem scanner."""

import os
from pathlib import Path
from unittest.mock import patch

import pytest

from certisigma_census.scanner import (
    MAX_WORKERS,
    _default_workers,
    scan_directory,
    walk_files,
)


# ─── Hidden file handling ──────────────────────────────────────────────────

def test_walk_files_skips_hidden(tmp_path: Path):
    (tmp_path / "visible.txt").write_text("hello")
    (tmp_path / ".hidden").write_text("secret")
    (tmp_path / ".git").mkdir()
    (tmp_path / ".git" / "config").write_text("x")

    files = list(walk_files(tmp_path, skip_hidden=True))
    names = [f.name for f in files]
    assert "visible.txt" in names
    assert ".hidden" not in names
    assert "config" not in names


def test_walk_files_includes_hidden(tmp_path: Path):
    (tmp_path / "visible.txt").write_text("hello")
    (tmp_path / ".dotfile").write_text("dot")

    files = list(walk_files(tmp_path, skip_hidden=False, skip_dirs=frozenset()))
    names = [f.name for f in files]
    assert "visible.txt" in names
    assert ".dotfile" in names


# ─── Size filters ──────────────────────────────────────────────────────────

def test_scan_directory_produces_manifest(tmp_path: Path):
    for i in range(3):
        (tmp_path / f"file_{i}.txt").write_text(f"content {i}")

    manifest = scan_directory(tmp_path)
    assert manifest.total == 3
    assert manifest.root_dir == str(tmp_path)
    for entry in manifest.entries.values():
        assert len(entry.hash_hex) == 64
        assert entry.size > 0


def test_scan_directory_skips_oversized(tmp_path: Path):
    (tmp_path / "small.txt").write_text("ok")
    big = tmp_path / "big.bin"
    big.write_bytes(b"x" * 200)

    manifest = scan_directory(tmp_path, max_file_size=100)
    assert manifest.total == 1
    assert list(manifest.entries.values())[0].path == "small.txt"


def test_walk_files_min_size(tmp_path: Path):
    (tmp_path / "tiny.txt").write_bytes(b"x")
    (tmp_path / "medium.txt").write_bytes(b"x" * 500)
    (tmp_path / "large.dat").write_bytes(b"x" * 2000)

    files = list(walk_files(tmp_path, min_file_size=100))
    names = [f.name for f in files]
    assert "tiny.txt" not in names
    assert "medium.txt" in names
    assert "large.dat" in names


# ─── Include / exclude globs ──────────────────────────────────────────────

def test_walk_files_include_glob(tmp_path: Path):
    (tmp_path / "report.pdf").write_bytes(b"pdf")
    (tmp_path / "data.csv").write_bytes(b"csv")
    (tmp_path / "notes.txt").write_bytes(b"txt")
    (tmp_path / "image.png").write_bytes(b"png")

    files = list(walk_files(tmp_path, include_globs=["*.pdf", "*.csv"]))
    names = sorted(f.name for f in files)
    assert names == ["data.csv", "report.pdf"]


def test_walk_files_exclude_glob(tmp_path: Path):
    (tmp_path / "report.pdf").write_bytes(b"pdf")
    (tmp_path / "debug.log").write_bytes(b"log")
    (tmp_path / "access.log").write_bytes(b"log")
    (tmp_path / "data.csv").write_bytes(b"csv")

    files = list(walk_files(tmp_path, exclude_globs=["*.log"]))
    names = sorted(f.name for f in files)
    assert names == ["data.csv", "report.pdf"]


def test_walk_files_include_and_exclude_combined(tmp_path: Path):
    """Include narrows, then exclude removes from that set."""
    (tmp_path / "report.pdf").write_bytes(b"pdf")
    (tmp_path / "draft-report.pdf").write_bytes(b"pdf")
    (tmp_path / "data.csv").write_bytes(b"csv")
    (tmp_path / "notes.txt").write_bytes(b"txt")

    files = list(walk_files(
        tmp_path,
        include_globs=["*.pdf"],
        exclude_globs=["draft-*"],
    ))
    names = [f.name for f in files]
    assert names == ["report.pdf"]


def test_walk_files_combined_filters(tmp_path: Path):
    """All filter dimensions work together: include + exclude + size."""
    (tmp_path / "small.pdf").write_bytes(b"x")
    (tmp_path / "good.pdf").write_bytes(b"x" * 500)
    (tmp_path / "big.pdf").write_bytes(b"x" * 5000)
    (tmp_path / "good.txt").write_bytes(b"x" * 500)
    (tmp_path / "temp.pdf").write_bytes(b"x" * 500)

    files = list(walk_files(
        tmp_path,
        include_globs=["*.pdf"],
        exclude_globs=["temp*"],
        min_file_size=10,
        max_file_size=2000,
    ))
    names = [f.name for f in files]
    assert names == ["good.pdf"]


def test_scan_directory_with_filters(tmp_path: Path):
    (tmp_path / "report.pdf").write_bytes(b"pdf content here")
    (tmp_path / "debug.log").write_bytes(b"log content here")
    (tmp_path / "data.csv").write_bytes(b"csv content here")

    manifest = scan_directory(
        tmp_path,
        include_globs=["*.pdf", "*.csv"],
        exclude_globs=["*.log"],
    )
    assert manifest.total == 2
    paths = sorted(e.path for e in manifest.entries.values())
    assert paths == ["data.csv", "report.pdf"]


# ─── Resume mode ──────────────────────────────────────────────────────────

def test_scan_resume_skips_unchanged(tmp_path: Path):
    """Files already in manifest with same size/mtime are not re-hashed."""
    (tmp_path / "existing.txt").write_bytes(b"already scanned")
    (tmp_path / "new.txt").write_bytes(b"fresh file")

    first = scan_directory(tmp_path)
    assert first.total == 2

    second = scan_directory(tmp_path, existing_manifest=first)
    assert second.total == 2
    for entry in second.entries.values():
        assert len(entry.hash_hex) == 64


def test_scan_resume_rehashes_modified(tmp_path: Path):
    """Changed mtime/size triggers re-hash even if file was in manifest."""
    f = tmp_path / "changing.txt"
    f.write_bytes(b"version 1")

    first = scan_directory(tmp_path)
    old_hash = list(first.entries.keys())[0]

    import time
    time.sleep(0.05)
    f.write_bytes(b"version 2 with more content")

    second = scan_directory(tmp_path, existing_manifest=first)
    new_hash = list(second.entries.keys())[0]
    assert new_hash != old_hash


def test_scan_resume_picks_up_new_files(tmp_path: Path):
    """New files added after first scan are hashed on resume."""
    (tmp_path / "original.txt").write_bytes(b"original")
    first = scan_directory(tmp_path)
    assert first.total == 1

    (tmp_path / "added.txt").write_bytes(b"added later")
    second = scan_directory(tmp_path, existing_manifest=first)
    assert second.total == 2


# ─── Parallel hashing ─────────────────────────────────────────────────────

def test_scan_parallel_produces_same_manifest(tmp_path: Path):
    """Parallel scan (workers=2) yields the same hashes as sequential."""
    for i in range(5):
        (tmp_path / f"file_{i}.txt").write_text(f"content {i}")

    serial = scan_directory(tmp_path, workers=1)
    parallel = scan_directory(tmp_path, workers=2)

    assert serial.total == parallel.total
    assert set(serial.entries.keys()) == set(parallel.entries.keys())


def test_scan_parallel_single_file(tmp_path: Path):
    """Workers > 1 with a single file degrades to sequential safely."""
    (tmp_path / "only.txt").write_text("alone")
    manifest = scan_directory(tmp_path, workers=4)
    assert manifest.total == 1


def test_scan_parallel_resume_mode(tmp_path: Path):
    """Parallel scan with resume skips unchanged files."""
    (tmp_path / "a.txt").write_bytes(b"aaa")
    (tmp_path / "b.txt").write_bytes(b"bbb")

    first = scan_directory(tmp_path, workers=2)
    assert first.total == 2

    (tmp_path / "c.txt").write_bytes(b"ccc")
    second = scan_directory(tmp_path, workers=2, existing_manifest=first)
    assert second.total == 3


# ─── Edge cases and error paths ───────────────────────────────────────────

def test_walk_files_empty_directory(tmp_path: Path):
    files = list(walk_files(tmp_path))
    assert files == []


def test_scan_empty_directory(tmp_path: Path):
    manifest = scan_directory(tmp_path)
    assert manifest.total == 0
    assert manifest.root_dir == str(tmp_path)


def test_walk_files_permission_denied(tmp_path: Path):
    """Files that can't be stat'd are skipped silently."""
    f = tmp_path / "readable.txt"
    f.write_bytes(b"ok")
    unreadable = tmp_path / "noperm.txt"
    unreadable.write_bytes(b"secret")
    unreadable.chmod(0o000)

    try:
        files = list(walk_files(tmp_path))
        names = [p.name for p in files]
        assert "readable.txt" in names
    finally:
        unreadable.chmod(0o644)


def test_walk_files_symlink_skip(tmp_path: Path):
    """Symlinks to directories are not followed."""
    real = tmp_path / "real.txt"
    real.write_bytes(b"real")
    sub = tmp_path / "subdir"
    sub.mkdir()
    (sub / "inner.txt").write_bytes(b"inner")
    link = tmp_path / "link"
    link.symlink_to(sub)

    files = list(walk_files(tmp_path))
    names = [p.name for p in files]
    assert "real.txt" in names
    assert "inner.txt" in names


def test_default_workers_capped():
    """_default_workers caps at MAX_WORKERS."""
    with patch("os.cpu_count", return_value=64):
        assert _default_workers() <= MAX_WORKERS


def test_default_workers_none_cpu():
    """_default_workers returns 1 when os.cpu_count() returns None."""
    with patch("os.cpu_count", return_value=None):
        assert _default_workers() == 1


def test_scan_workers_clamped(tmp_path: Path):
    """Workers value is clamped to [1, MAX_WORKERS]."""
    (tmp_path / "f.txt").write_bytes(b"test")
    m = scan_directory(tmp_path, workers=999)
    assert m.total == 1
    m2 = scan_directory(tmp_path, workers=0)
    assert m2.total == 1
    m3 = scan_directory(tmp_path, workers=-5)
    assert m3.total == 1


def test_file_meta_captures_owner_and_mode(tmp_path: Path):
    """_file_meta returns owner, group, mode from a stat result."""
    from certisigma_census.scanner import _file_meta

    f = tmp_path / "meta_test.txt"
    f.write_text("hello")
    stat = f.stat()
    meta = _file_meta(stat)

    assert isinstance(meta["owner"], str)
    assert len(meta["owner"]) > 0
    assert isinstance(meta["file_group"], str)
    assert len(meta["file_group"]) > 0
    assert isinstance(meta["file_mode"], int)
    assert meta["file_mode"] > 0


def test_file_meta_unknown_uid(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
    """_file_meta falls back to numeric UID when pwd.getpwuid raises KeyError."""
    import certisigma_census.scanner as scanner_mod

    f = tmp_path / "unknown_uid.txt"
    f.write_text("hello")
    stat = f.stat()

    if scanner_mod._pwd is not None:
        orig_getpwuid = scanner_mod._pwd.getpwuid

        def fail_getpwuid(uid):
            raise KeyError(uid)

        monkeypatch.setattr(scanner_mod._pwd, "getpwuid", fail_getpwuid)
        meta = scanner_mod._file_meta(stat)
        assert meta["owner"] == str(stat.st_uid)
        monkeypatch.setattr(scanner_mod._pwd, "getpwuid", orig_getpwuid)


def test_file_meta_unknown_gid(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
    """_file_meta falls back to numeric GID when grp.getgrgid raises KeyError."""
    import certisigma_census.scanner as scanner_mod

    f = tmp_path / "unknown_gid.txt"
    f.write_text("hello")
    stat = f.stat()

    if scanner_mod._grp is not None:
        orig_getgrgid = scanner_mod._grp.getgrgid

        def fail_getgrgid(gid):
            raise KeyError(gid)

        monkeypatch.setattr(scanner_mod._grp, "getgrgid", fail_getgrgid)
        meta = scanner_mod._file_meta(stat)
        assert meta["file_group"] == str(stat.st_gid)
        monkeypatch.setattr(scanner_mod._grp, "getgrgid", orig_getgrgid)


def test_file_meta_no_pwd_module(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
    """_file_meta falls back to numeric UID/GID when pwd/grp are unavailable (Windows)."""
    import certisigma_census.scanner as scanner_mod

    f = tmp_path / "no_pwd.txt"
    f.write_text("hello")
    stat = f.stat()

    monkeypatch.setattr(scanner_mod, "_pwd", None)
    monkeypatch.setattr(scanner_mod, "_grp", None)
    meta = scanner_mod._file_meta(stat)
    assert meta["owner"] == str(stat.st_uid)
    assert meta["file_group"] == str(stat.st_gid)
    assert isinstance(meta["file_mode"], int)


def test_scan_captures_file_metadata(tmp_path: Path):
    """scan_directory populates owner/group/mode in manifest entries."""
    (tmp_path / "owned.txt").write_text("test data")
    m = scan_directory(tmp_path)
    entries = list(m.iter_entries())
    assert len(entries) == 1
    assert entries[0].owner is not None
    assert entries[0].file_group is not None
    assert entries[0].file_mode is not None
