from __future__ import annotations

from collections.abc import Callable, Generator
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import time

import pytest  # type: ignore

from revibe.core.autocompletion.file_indexer import FileIndexer

# This suite runs against the real filesystem and watcher. A faked store/watcher
# split would be faster to unit-test, but given time constraints and the low churn
# expected for this feature, integration coverage was chosen as a trade-off.


@pytest.fixture
def file_indexer() -> Generator[FileIndexer]:
    indexer = FileIndexer()
    yield indexer
    indexer.shutdown()


def _wait_for(condition: Callable[[], bool], timeout=3.0) -> bool:
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        if condition():
            return True
        time.sleep(0.05)
    return False


def test_updates_index_on_file_creation(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch, file_indexer: FileIndexer
) -> None:
    monkeypatch.chdir(tmp_path)
    file_indexer.get_index(Path("."))

    target = tmp_path / "new_file.py"
    target.write_text("", encoding="utf-8")

    assert _wait_for(
        lambda: any(
            entry.rel == target.name for entry in file_indexer.get_index(Path("."))
        )
    )


def test_updates_index_on_file_deletion(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch, file_indexer: FileIndexer
) -> None:
    monkeypatch.chdir(tmp_path)
    target = tmp_path / "new_file.py"
    target.write_text("", encoding="utf-8")
    file_indexer.get_index(Path("."))

    target.unlink()

    assert _wait_for(
        lambda: all(
            entry.rel != target.name for entry in file_indexer.get_index(Path("."))
        )
    )


def test_updates_index_on_file_rename(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch, file_indexer: FileIndexer
) -> None:
    monkeypatch.chdir(tmp_path)
    old_file = tmp_path / "old_name.py"
    old_file.write_text("", encoding="utf-8")
    file_indexer.get_index(Path("."))

    new_file = tmp_path / "new_name.py"
    old_file.rename(new_file)

    assert _wait_for(
        lambda: all(
            entry.rel != old_file.name for entry in file_indexer.get_index(Path("."))
        )
        and any(
            entry.rel == new_file.name for entry in file_indexer.get_index(Path("."))
        )
    )


def test_updates_index_on_folder_rename(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch, file_indexer: FileIndexer
) -> None:
    monkeypatch.chdir(tmp_path)
    old_folder = tmp_path / "old_folder"
    old_folder.mkdir()
    number_of_files = 5
    file_names = [f"file{i}.py" for i in range(1, number_of_files + 1)]
    old_file_paths = [old_folder / name for name in file_names]
    for file_path in old_file_paths:
        file_path.write_text("", encoding="utf-8")
    file_indexer.get_index(Path("."))

    new_folder = tmp_path / "new_folder"
    old_folder.rename(new_folder)

    assert _wait_for(
        lambda: (
            entries := file_indexer.get_index(Path(".")),
            all(not entry.rel.startswith("old_folder/") for entry in entries)
            and all(
                any(entry.rel == f"new_folder/{name}" for entry in entries)
                for name in file_names
            ),
        )[1]
    )


def test_updates_index_incrementally_by_default(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch, file_indexer: FileIndexer
) -> None:
    monkeypatch.chdir(tmp_path)
    file_indexer.get_index(Path("."))

    rebuilds_before = file_indexer.stats.rebuilds
    incremental_before = file_indexer.stats.incremental_updates

    target = tmp_path / "stats_file.py"
    target.write_text("", encoding="utf-8")

    assert _wait_for(
        lambda: any(
            entry.rel == target.name for entry in file_indexer.get_index(Path("."))
        )
    )

    assert file_indexer.stats.rebuilds == rebuilds_before
    assert file_indexer.stats.incremental_updates >= incremental_before + 1


def test_rebuilds_index_when_mass_change_threshold_is_exceeded(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    mass_change_threshold = 5
    # in an ideal world, we would use "threshold + 1", but in reality, we need to test with a
    # number of files important enough to MAKE SURE that a batch of >= threshold events will be
    # detected by the watcher
    number_of_files = mass_change_threshold * 3
    monkeypatch.chdir(tmp_path)
    indexer = FileIndexer(mass_change_threshold=mass_change_threshold)
    try:
        indexer.get_index(Path("."))
        rebuilds_before = indexer.stats.rebuilds

        ThreadPoolExecutor(max_workers=number_of_files).map(
            lambda i: (tmp_path / f"bulk{i}.py").write_text("", encoding="utf-8"),
            range(number_of_files),
        )

        assert _wait_for(lambda: len(indexer.get_index(Path("."))) == number_of_files)
        # we do not assert that "incremental_updates" did not change,
        # as the watcher potentially reported some batches of events that were
        # smaller than the threshold
        assert indexer.stats.rebuilds >= rebuilds_before + 1
    finally:
        indexer.shutdown()


def test_switching_between_roots_restarts_index(
    tmp_path: Path,
    tmp_path_factory: pytest.TempPathFactory,
    monkeypatch: pytest.MonkeyPatch,
    file_indexer: FileIndexer,
) -> None:
    first_root = tmp_path
    second_root = tmp_path_factory.mktemp("second-root")
    (first_root / "first.py").write_text("", encoding="utf-8")
    (second_root / "second.py").write_text("", encoding="utf-8")

    monkeypatch.chdir(first_root)
    assert _wait_for(
        lambda: any(
            entry.rel == "first.py" for entry in file_indexer.get_index(Path("."))
        )
    )

    monkeypatch.chdir(second_root)
    assert _wait_for(
        lambda: all(
            entry.rel != "first.py" for entry in file_indexer.get_index(Path("."))
        )
    )
    assert _wait_for(
        lambda: any(
            entry.rel == "second.py" for entry in file_indexer.get_index(Path("."))
        )
    )


def test_watcher_failure_does_not_break_existing_index(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch, file_indexer: FileIndexer
) -> None:
    monkeypatch.chdir(tmp_path)
    seed = tmp_path / "seed.py"
    seed.write_text("", encoding="utf-8")
    file_indexer.get_index(Path("."))

    def boom(*_: object, **__: object) -> None:
        raise RuntimeError("boom")

    monkeypatch.setattr(file_indexer._store, "apply_changes", boom)

    (tmp_path / "new_file.py").write_text("", encoding="utf-8")

    assert _wait_for(
        lambda: (
            entries := file_indexer.get_index(Path(".")),
            # new file was not added: watcher failed
            all(entry.rel != "new_file.py" for entry in entries)
            # but the existing index is still intact
            and all(entry.rel == "seed.py" for entry in entries),
        )[1]
    )


def test_shutdown_cleans_up_resources(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    monkeypatch.chdir(tmp_path)
    (tmp_path / "test.txt").write_text("", encoding="utf-8")
    file_indexer = FileIndexer()
    file_indexer.get_index(Path("."))

    file_indexer.shutdown()
    assert file_indexer.get_index(Path(".")) == []
