"""Step definitions for features/repo_indexing.feature.

Tests for the repository indexing service (issue #195).  All context
attributes use the ``repo_index_`` prefix to avoid collisions with
other step files.
"""

from __future__ import annotations

import shutil
import tempfile
import time
from pathlib import Path
from unittest.mock import patch

from behave import given, when  # type: ignore[import-untyped]
from behave.runner import Context  # type: ignore[import-untyped]
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

from cleveragents.application.services.repo_indexing_service import (
    RepoIndexingService,
)
from cleveragents.domain.models.core.repo_index import (
    FileRecord,
    IndexMetadata,
    IndexStatus,
)
from cleveragents.infrastructure.database.models import Base

_RESOURCE_ID = "01KK0D8WNATFNEX2JMG5GKF6FM"


# ── Helpers ──────────────────────────────────────────────────────────


def _make_service(context: Context) -> RepoIndexingService:
    """Create an in-memory RepoIndexingService and store on context."""
    engine = create_engine("sqlite:///:memory:", echo=False)
    Base.metadata.create_all(engine)
    factory = sessionmaker(bind=engine, expire_on_commit=False)
    service = RepoIndexingService(session_factory=factory)
    context.repo_index_service = service  # type: ignore[attr-defined]
    return service


def _make_sample_project(context: Context) -> Path:
    """Create a temp directory with sample project files."""
    tmpdir = Path(tempfile.mkdtemp())
    (tmpdir / "main.py").write_text("print('hello world')\ndef main():\n    pass\n")
    (tmpdir / "utils.py").write_text("def helper():\n    return 42\n")
    (tmpdir / "index.ts").write_text("export const app = () => {};\n")
    (tmpdir / "README.md").write_text("# Sample Project\n\nA test project.\n")
    (tmpdir / "config.json").write_text('{"key": "value", "debug": true}\n')
    (tmpdir / "Makefile").write_text("all:\n\t@echo hello\n")
    context.repo_index_tmpdir = str(tmpdir)  # type: ignore[attr-defined]
    context.add_cleanup(_cleanup_tmpdir, context)
    return tmpdir


def _cleanup_tmpdir(context: Context) -> None:
    """Remove the temporary directory."""
    tmpdir = getattr(context, "repo_index_tmpdir", None)
    if tmpdir:
        shutil.rmtree(tmpdir, ignore_errors=True)


# ── Given steps ──────────────────────────────────────────────────────


@given("a repo-index fresh in-memory indexing service")
def step_fresh_service(context: Context) -> None:
    """Set up a fresh in-memory RepoIndexingService."""
    _make_service(context)
    context.repo_index_result = None  # type: ignore[attr-defined]
    context.repo_index_original_result = None  # type: ignore[attr-defined]
    context.repo_index_error = None  # type: ignore[attr-defined]
    context.repo_index_removal_result = None  # type: ignore[attr-defined]


@given("a repo-index temporary directory with sample project files")
def step_sample_project(context: Context) -> None:
    """Create a temporary directory with sample files."""
    _make_sample_project(context)


@given("a repo-index temporary directory with sample project files and a 2MB file")
def step_sample_project_with_big_file(context: Context) -> None:
    """Create a temporary directory with sample files plus a 2MB file."""
    tmpdir = _make_sample_project(context)
    (tmpdir / "big_file.dat").write_bytes(b"x" * 2_000_000)


@given("a repo-index temporary directory with 10000 python files")
def step_large_directory_10000_files(context: Context) -> None:
    """Create a temporary directory containing 10,000 small Python files."""
    tmpdir = Path(tempfile.mkdtemp())
    created = 0
    for i in range(100):
        module_dir = tmpdir / f"module_{i:03d}"
        module_dir.mkdir(parents=True, exist_ok=True)
        for j in range(100):
            (module_dir / f"file_{j:03d}.py").write_text(
                f"def f_{i}_{j}():\n    return {i + j}\n"
            )
            created += 1

    assert created == 10_000, f"Expected 10000 files, created {created}"
    context.repo_index_tmpdir = str(tmpdir)  # type: ignore[attr-defined]
    context.add_cleanup(_cleanup_tmpdir, context)


@given("a repo-index empty temporary directory")
def step_empty_directory(context: Context) -> None:
    """Create an empty temporary directory."""
    tmpdir = Path(tempfile.mkdtemp())
    context.repo_index_tmpdir = str(tmpdir)  # type: ignore[attr-defined]
    context.add_cleanup(_cleanup_tmpdir, context)


@given("a repo-index temporary directory with a file that fails hashing")
def step_dir_with_unhashable_file(context: Context) -> None:
    """Create a temp directory with a file that will fail during hashing.

    We create a normal file here; the actual OSError is injected via
    ``unittest.mock.patch`` in the When step.  This avoids reliance on
    ``chmod 0o000`` which is ineffective when running as root.
    """
    tmpdir = Path(tempfile.mkdtemp())
    (tmpdir / "visible.py").write_text("x = 1\n")
    (tmpdir / "secret.dat").write_text("top secret\n")
    context.repo_index_tmpdir = str(tmpdir)  # type: ignore[attr-defined]
    context.add_cleanup(_cleanup_tmpdir, context)


@given("I run repo-index full index on the sample directory")
def step_run_full_index_given(context: Context) -> None:
    """Run a full index (as a Given precondition)."""
    service: RepoIndexingService = context.repo_index_service  # type: ignore[attr-defined]
    result = service.index_resource(_RESOURCE_ID, context.repo_index_tmpdir)  # type: ignore[attr-defined]
    context.repo_index_result = result  # type: ignore[attr-defined]
    context.repo_index_original_result = result  # type: ignore[attr-defined]


# ── When steps ───────────────────────────────────────────────────────


@when("I run repo-index full index on the sample directory")
def step_run_full_index(context: Context) -> None:
    """Run a full index."""
    service: RepoIndexingService = context.repo_index_service  # type: ignore[attr-defined]
    result = service.index_resource(_RESOURCE_ID, context.repo_index_tmpdir)  # type: ignore[attr-defined]
    context.repo_index_result = result  # type: ignore[attr-defined]
    context.repo_index_original_result = result  # type: ignore[attr-defined]


@when('I run repo-index full index with hash failure on "{filename}"')
def step_run_full_index_with_hash_failure(context: Context, filename: str) -> None:
    """Run a full index where ``read_and_hash`` raises OSError for *filename*.

    This simulates an unreadable file without relying on ``chmod 0o000``
    (which is ineffective when the test runner is root).
    We patch ``read_and_hash`` in the utils module where
    ``walk_and_index`` actually calls it.
    """
    import cleveragents.application.services.repo_indexing_utils as utils_mod

    service: RepoIndexingService = context.repo_index_service  # type: ignore[attr-defined]
    original_read_and_hash = utils_mod.read_and_hash

    def _failing_read(path: Path) -> tuple[str, int]:
        if Path(path).name == filename:
            raise OSError(f"Simulated read failure for {filename}")
        return original_read_and_hash(path)

    with patch.object(utils_mod, "read_and_hash", _failing_read):
        result = service.index_resource(
            _RESOURCE_ID,
            context.repo_index_tmpdir,  # type: ignore[attr-defined]
        )
    context.repo_index_result = result  # type: ignore[attr-defined]
    context.repo_index_original_result = result  # type: ignore[attr-defined]


@when("I query repo-index for the indexed resource")
def step_query_index(context: Context) -> None:
    """Query the persisted index."""
    service: RepoIndexingService = context.repo_index_service  # type: ignore[attr-defined]
    context.repo_index_retrieved = service.get_index(_RESOURCE_ID)  # type: ignore[attr-defined]


@when('I modify repo-index file "{filename}" with new content')
def step_modify_file(context: Context, filename: str) -> None:
    """Modify a file in the temp directory."""
    filepath = Path(context.repo_index_tmpdir) / filename  # type: ignore[attr-defined]
    filepath.write_text("# Modified content\nprint('updated')\n" * 5)


@when("I run repo-index incremental refresh")
def step_run_refresh(context: Context) -> None:
    """Run incremental refresh."""
    service: RepoIndexingService = context.repo_index_service  # type: ignore[attr-defined]
    result = service.refresh_index(_RESOURCE_ID, context.repo_index_tmpdir)  # type: ignore[attr-defined]
    context.repo_index_result = result  # type: ignore[attr-defined]


@when("I run repo-index full index with max file size {size:d} bytes")
def step_run_index_max_file_size(context: Context, size: int) -> None:
    """Run index with max_file_size policy."""
    service: RepoIndexingService = context.repo_index_service  # type: ignore[attr-defined]
    result = service.index_resource(
        _RESOURCE_ID,
        context.repo_index_tmpdir,  # type: ignore[attr-defined]
        max_file_size=size,
    )
    context.repo_index_result = result  # type: ignore[attr-defined]


@when('I run repo-index full index with include globs "{globs}"')
def step_run_index_include_globs(context: Context, globs: str) -> None:
    """Run index with include globs."""
    service: RepoIndexingService = context.repo_index_service  # type: ignore[attr-defined]
    glob_tuple = tuple(g.strip() for g in globs.split(","))
    result = service.index_resource(
        _RESOURCE_ID,
        context.repo_index_tmpdir,  # type: ignore[attr-defined]
        include_globs=glob_tuple,
    )
    context.repo_index_result = result  # type: ignore[attr-defined]


@when('I run repo-index full index with exclude globs "{globs}"')
def step_run_index_exclude_globs(context: Context, globs: str) -> None:
    """Run index with exclude globs."""
    service: RepoIndexingService = context.repo_index_service  # type: ignore[attr-defined]
    glob_tuple = tuple(g.strip() for g in globs.split(","))
    result = service.index_resource(
        _RESOURCE_ID,
        context.repo_index_tmpdir,  # type: ignore[attr-defined]
        exclude_globs=glob_tuple,
    )
    context.repo_index_result = result  # type: ignore[attr-defined]


@when("I run repo-index full index with max total size {size:d} bytes")
def step_run_index_max_total_size(context: Context, size: int) -> None:
    """Run index with max_total_size policy."""
    service: RepoIndexingService = context.repo_index_service  # type: ignore[attr-defined]
    result = service.index_resource(
        _RESOURCE_ID,
        context.repo_index_tmpdir,  # type: ignore[attr-defined]
        max_total_size=size,
    )
    context.repo_index_result = result  # type: ignore[attr-defined]


@when("I run repo-index full index with timeout of {seconds:d} seconds")
def step_run_index_with_timeout(context: Context, seconds: int) -> None:
    """Run index with an explicit timeout and capture elapsed time."""
    service: RepoIndexingService = context.repo_index_service  # type: ignore[attr-defined]
    started = time.monotonic()
    result = service.index_resource(
        _RESOURCE_ID,
        context.repo_index_tmpdir,  # type: ignore[attr-defined]
        timeout_seconds=float(seconds),
    )
    context.repo_index_result = result  # type: ignore[attr-defined]
    context.repo_index_elapsed_seconds = time.monotonic() - started  # type: ignore[attr-defined]


@when("I remove the repo-index for the resource")
def step_remove_index(context: Context) -> None:
    """Remove the index."""
    service: RepoIndexingService = context.repo_index_service  # type: ignore[attr-defined]
    context.repo_index_removal_result = service.remove_index(_RESOURCE_ID)  # type: ignore[attr-defined]


@when("I remove the repo-index for a non-existent resource")
def step_remove_nonexistent(context: Context) -> None:
    """Attempt to remove an index that doesn't exist."""
    service: RepoIndexingService = context.repo_index_service  # type: ignore[attr-defined]
    context.repo_index_removal_result = service.remove_index(
        "01KK0D8WNATFNEX2JMG5GKF6FN"
    )  # type: ignore[attr-defined]


@when("I query repo-index status for the indexed resource")
def step_query_status(context: Context) -> None:
    """Query the index status (metadata only, no file records)."""
    service: RepoIndexingService = context.repo_index_service  # type: ignore[attr-defined]
    context.repo_index_status = service.get_index_status(_RESOURCE_ID)  # type: ignore[attr-defined]


@when("I attempt repo-index with a file path as root")
def step_index_file_root(context: Context) -> None:
    """Attempt indexing with a file path (not a directory)."""
    service: RepoIndexingService = context.repo_index_service  # type: ignore[attr-defined]
    file_path = Path(context.repo_index_tmpdir) / "main.py"  # type: ignore[attr-defined]
    try:
        service.index_resource(_RESOURCE_ID, str(file_path))
    except (ValueError, FileNotFoundError) as exc:
        context.repo_index_error = exc  # type: ignore[attr-defined]


@when('I attempt repo-index refresh with non-existent path "{path}"')
def step_refresh_bad_path(context: Context, path: str) -> None:
    """Attempt refresh with a non-existent path."""
    service: RepoIndexingService = context.repo_index_service  # type: ignore[attr-defined]
    try:
        service.refresh_index(_RESOURCE_ID, path)
    except FileNotFoundError as exc:
        context.repo_index_error = exc  # type: ignore[attr-defined]


@when("I attempt repo-index refresh with a file path as root")
def step_refresh_file_root(context: Context) -> None:
    """Attempt refresh with a file path (not a directory)."""
    service: RepoIndexingService = context.repo_index_service  # type: ignore[attr-defined]
    file_path = Path(context.repo_index_tmpdir) / "main.py"  # type: ignore[attr-defined]
    try:
        service.refresh_index(_RESOURCE_ID, str(file_path))
    except (ValueError, FileNotFoundError) as exc:
        context.repo_index_error = exc  # type: ignore[attr-defined]


@when("repo-index walk is mocked to fail and I index a fresh resource")
def step_index_mock_walk_fail_fresh(context: Context) -> None:
    """Mock ``walk_and_index`` to raise OSError and attempt index.

    This tests the fault handler at lines 300-325 of the service: when a
    walk failure occurs on a *fresh* resource (no prior index), the
    service should persist an ERROR status row.

    We use ``unittest.mock.patch`` because ``os.walk`` silently skips
    unreadable directories on Linux rather than raising.
    """
    service: RepoIndexingService = context.repo_index_service  # type: ignore[attr-defined]
    with patch(
        "cleveragents.application.services.repo_indexing_service.walk_and_index",
        side_effect=OSError("Simulated walk failure"),
    ):
        try:
            service.index_resource(_RESOURCE_ID, context.repo_index_tmpdir)  # type: ignore[attr-defined]
        except Exception as exc:
            context.repo_index_error = exc  # type: ignore[attr-defined]


@when("repo-index walk is mocked to fail and I re-index")
def step_reindex_mock_walk_fail(context: Context) -> None:
    """Mock ``walk_and_index`` to raise OSError and attempt re-index.

    Tests the fault handler when a prior index exists: the service should
    preserve the previous good index and re-raise.
    """
    service: RepoIndexingService = context.repo_index_service  # type: ignore[attr-defined]
    with patch(
        "cleveragents.application.services.repo_indexing_service.walk_and_index",
        side_effect=OSError("Simulated walk failure"),
    ):
        try:
            service.index_resource(_RESOURCE_ID, context.repo_index_tmpdir)  # type: ignore[attr-defined]
        except Exception as exc:
            context.repo_index_error = exc  # type: ignore[attr-defined]


@when("repo-index walk is mocked to fail and I refresh")
def step_refresh_mock_walk_fail(context: Context) -> None:
    """Mock ``walk_and_index`` to raise OSError and attempt refresh.

    Tests the fault handler in ``refresh_index``: the service should
    preserve the previous good index and re-raise.
    """
    service: RepoIndexingService = context.repo_index_service  # type: ignore[attr-defined]
    with patch(
        "cleveragents.application.services.repo_indexing_service.walk_and_index",
        side_effect=OSError("Simulated walk failure"),
    ):
        try:
            service.refresh_index(_RESOURCE_ID, context.repo_index_tmpdir)  # type: ignore[attr-defined]
        except Exception as exc:
            context.repo_index_error = exc  # type: ignore[attr-defined]


@when('I add a new file "{filename}" and re-index')
def step_add_file_reindex(context: Context, filename: str) -> None:
    """Add a new file and re-index the same resource."""
    service: RepoIndexingService = context.repo_index_service  # type: ignore[attr-defined]
    filepath = Path(context.repo_index_tmpdir) / filename  # type: ignore[attr-defined]
    filepath.write_text("# New file\ndef new_func(): pass\n")
    result = service.index_resource(_RESOURCE_ID, context.repo_index_tmpdir)  # type: ignore[attr-defined]
    context.repo_index_result = result  # type: ignore[attr-defined]


@when("I create a FileRecord with a naive datetime")
def step_create_filerecord_naive(context: Context) -> None:
    """Create a FileRecord with a naive (no tzinfo) datetime."""
    from datetime import datetime as dt

    naive = dt(2025, 6, 15, 12, 0, 0)
    context.repo_index_filerecord = FileRecord(  # type: ignore[attr-defined]
        path="test.py",
        content_hash="a" * 64,
        token_count=10,
        language="python",
        size_bytes=40,
        last_modified=naive,
    )


@when("I create an IndexMetadata with a naive datetime")
def step_create_metadata_naive(context: Context) -> None:
    """Create an IndexMetadata with a naive (no tzinfo) datetime."""
    from datetime import datetime as dt

    naive = dt(2025, 6, 15, 12, 0, 0)
    context.repo_index_metadata_naive = IndexMetadata(  # type: ignore[attr-defined]
        index_id="01KK0D8WNATFNEX2JMG5GKF6FM",
        resource_id="01KK0D8WNATFNEX2JMG5GKF6FN",
        indexed_at=naive,
        file_count=0,
        token_estimate=0,
        primary_language="unknown",
        status=IndexStatus.READY,
    )


@when("I attempt repo-index with invalid ULID resource ID")
def step_index_invalid_ulid(context: Context) -> None:
    """Attempt indexing with an invalid ULID resource ID."""
    service: RepoIndexingService = context.repo_index_service  # type: ignore[attr-defined]
    try:
        service.index_resource("not-a-valid-ulid", tempfile.gettempdir())
    except ValueError as exc:
        context.repo_index_error = exc  # type: ignore[attr-defined]


@when("I attempt repo-index with empty resource ID")
def step_index_empty_id(context: Context) -> None:
    """Attempt indexing with empty resource ID."""
    service: RepoIndexingService = context.repo_index_service  # type: ignore[attr-defined]
    try:
        service.index_resource("", tempfile.gettempdir())
    except (ValueError, FileNotFoundError) as exc:
        context.repo_index_error = exc  # type: ignore[attr-defined]


@when('I attempt repo-index with non-existent path "{path}"')
def step_index_bad_path(context: Context, path: str) -> None:
    """Attempt indexing with a non-existent path."""
    service: RepoIndexingService = context.repo_index_service  # type: ignore[attr-defined]
    try:
        service.index_resource(_RESOURCE_ID, path)
    except FileNotFoundError as exc:
        context.repo_index_error = exc  # type: ignore[attr-defined]
