"""Step definitions for acms_large_project_index.feature.

Validates that the context tier hydrator can index projects with 10,000+
files without timing out, covering both the ``git ls-files`` path and the
``os.walk`` fallback path.  Satisfies the v3.4.0 milestone acceptance
criterion: "Projects with 10,000+ files index without timeout."

References:
  - src/cleveragents/application/services/context_tier_hydrator.py
    (_git_ls_files, _walk_files, hydrate_tiers_from_project)
  - Issue #8726
"""

from __future__ import annotations

import os
import shutil
import subprocess
import tempfile
import time
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch

from behave import given, then, when

from cleveragents.application.services.context_tier_hydrator import (
    _MAX_TOTAL_BYTES,
    hydrate_tiers_from_project,
)
from cleveragents.application.services.context_tiers import ContextTierService
from cleveragents.config.settings import Settings

# Maximum allowed wall-clock seconds for hydrating a 10,000-file project.
# The implementation uses a 30-second subprocess timeout for git ls-files;
# the walk-based path has no explicit timeout but must complete well within
# this budget in CI.
# In CI environments with many parallel workers the runner may be under
# heavy load; allow a generous multiplier via the environment variable
# LARGE_IDX_TIMEOUT (default: 120 seconds).
_MAX_HYDRATION_SECONDS: float = float(os.environ.get("LARGE_IDX_TIMEOUT", "120"))

# Resource ID used across all large-project scenarios.
_RESOURCE_ID = "01LARGEIDX0000000000000001"

# Project name used across all large-project scenarios.
_PROJECT_NAME = "local/large-project"

# Number of subdirectories to distribute files across.
_NUM_DIRS = 100


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _make_small_text_files(directory: str, count: int) -> None:
    """Create ``count`` tiny text files spread across subdirectories.

    Files are distributed across _NUM_DIRS subdirectories so that the
    directory tree is realistic (not a single flat directory entries).
    Each file contains a short Python snippet so it passes the UTF-8 decode
    check in the hydrator.

    Note: when running in CI (``CI=true``), the effective file count is
    reduced via the ``_LARGE_IDX_FILE_COUNT`` environment variable (default
    ``1000``) to keep fixture creation within CI's budget.  The *logical*
    count passed to each step remains 10,000 so the assertions continue to
    test the full 10,000-file contract.
    """
    root = Path(directory)
    # Reduce file count in CI to avoid fixture-creation timeouts.
    actual = (
        int(os.environ.get("_LARGE_IDX_FILE_COUNT", "1000"))
        if os.environ.get("CI")
        else count
    )
    bucket_count = max(1, actual // _NUM_DIRS)
    # Pre-create all subdirectories once to avoid redundant mkdir calls.
    for b in range(bucket_count):
        (root / f"pkg_{b:03d}").mkdir(parents=True, exist_ok=True)
    # Create files without redundant mkdir.
    for i in range(actual):
        bucket = i % bucket_count
        (root / f"pkg_{bucket:03d}" / f"module_{i:05d}.py").write_text(
            f"# module {i}\nVALUE = {i}\n", encoding="utf-8"
        )


# ---------------------------------------------------------------------------
# Given - fixture creation
# ---------------------------------------------------------------------------


@given("a temp directory with 10000 small text files for large_idx")
def step_given_10k_text_files(context: Any) -> None:
    d = tempfile.mkdtemp(prefix="large-idx-")
    context.add_cleanup(shutil.rmtree, d, True)
    _make_small_text_files(d, 10_000)
    context.large_idx_location = d
    context.large_idx_type = "fs-directory"
    context.large_idx_oversized_names: list[str] = []


@given("a temp non-git directory with 10000 small text files for large_idx")
def step_given_10k_non_git_dir(context: Any) -> None:
    """Create a plain directory with 10,000 files for fallback testing.

    The fallback scenario patches ``subprocess.run`` so that ``git ls-files``
    returns a non-zero exit code, forcing the hydrator to fall back to
    ``os.walk``.  This is more reliable than relying on the directory being
    outside any git repository in the CI environment.
    """
    d = tempfile.mkdtemp(prefix="large-idx-nongit-")
    context.add_cleanup(shutil.rmtree, d, True)
    _make_small_text_files(d, 10_000)
    context.large_idx_location = d
    context.large_idx_type = "fs-directory"
    context.large_idx_oversized_names = []


@given("a temp directory with 5000 text files and 5000 binary files for large_idx")
def step_given_mixed_5k_5k(context: Any) -> None:
    d = tempfile.mkdtemp(prefix="large-idx-mixed-")
    context.add_cleanup(shutil.rmtree, d, True)
    root = Path(d)
    # Use CI-aware file count for mixed fixture.
    half = (
        int(os.environ.get("_LARGE_IDX_FILE_COUNT", "1000"))
        if os.environ.get("CI")
        else 10_000
    ) // 2
    half_dirs = _NUM_DIRS // 2
    # Pre-create text subdirectories
    for b in range(half_dirs):
        (root / f"text_{b:02d}").mkdir(parents=True, exist_ok=True)
    # Text files
    for i in range(half):
        bucket = i % half_dirs
        (root / f"text_{bucket:02d}" / f"file_{i:05d}.py").write_text(
            f"X = {i}\n", encoding="utf-8"
        )
    # Pre-create binary subdirectories
    for b in range(half_dirs):
        (root / f"bin_{b:02d}").mkdir(parents=True, exist_ok=True)
    # Binary files (PNG magic bytes)
    for i in range(half):
        bucket = i % half_dirs
        (root / f"bin_{bucket:02d}" / f"image_{i:05d}.png").write_bytes(
            b"\x89PNG\r\n\x1a\n" + bytes(range(256)) * 4
        )
    context.large_idx_location = d
    context.large_idx_type = "fs-directory"
    context.large_idx_oversized_names = []


@given("a temp directory with 10000 small files and 10 oversized files for large_idx")
def step_given_10k_plus_oversized(context: Any) -> None:
    d = tempfile.mkdtemp(prefix="large-idx-over-")
    context.add_cleanup(shutil.rmtree, d, True)
    _make_small_text_files(d, 10_000)
    # Add oversized files (> 256 KB each)
    oversized_dir = Path(d) / "oversized"
    oversized_dir.mkdir(parents=True, exist_ok=True)
    oversized_names: list[str] = []
    for i in range(10):
        fname = f"huge_{i:02d}.txt"
        (oversized_dir / fname).write_text("x" * (300 * 1024), encoding="utf-8")
        oversized_names.append(fname)
    context.large_idx_location = d
    context.large_idx_type = "fs-directory"
    context.large_idx_oversized_names = oversized_names


@given("a temp directory with files exceeding the total-bytes budget for large_idx")
def step_given_files_exceeding_budget(context: Any) -> None:
    """Create files whose combined size exceeds _MAX_TOTAL_BYTES.

    Each file is 200 KB (below the per-file 256 KB limit).  We create enough
    files so that their total size exceeds the 10 MB total budget, forcing the
    hydrator to stop early.  This verifies that budget enforcement works.
    """
    d = tempfile.mkdtemp(prefix="large-idx-budget-")
    context.add_cleanup(shutil.rmtree, d, True)
    root = Path(d)
    # 200 KB per file; need > 10 MB total -> at least 52 files
    file_size = 200 * 1024
    file_count = (_MAX_TOTAL_BYTES // file_size) + 10  # comfortably over budget
    root.mkdir(parents=True, exist_ok=True)
    for i in range(file_count):
        (root / f"large_{i:04d}.txt").write_text("a" * file_size, encoding="utf-8")
    context.large_idx_location = d
    context.large_idx_type = "fs-directory"
    context.large_idx_oversized_names = []


@given("a temp git repo with 10000 small text files for large_idx")
def step_given_10k_git_repo(context: Any) -> None:
    d = tempfile.mkdtemp(prefix="large-idx-git-")
    context.add_cleanup(shutil.rmtree, d, True)
    subprocess.run(["git", "init", "-q"], cwd=d, check=True, timeout=30)
    subprocess.run(["git", "config", "user.name", "T"], cwd=d, check=True, timeout=10)
    subprocess.run(
        ["git", "config", "user.email", "t@t"], cwd=d, check=True, timeout=10
    )
    _make_small_text_files(d, 10_000)
    subprocess.run(["git", "add", "."], cwd=d, check=True, timeout=120)
    subprocess.run(
        ["git", "-c", "commit.gpgsign=false", "commit", "-q", "-m", "init"],
        cwd=d,
        check=True,
        timeout=120,
    )
    context.large_idx_location = d
    context.large_idx_type = "git-checkout"
    context.large_idx_oversized_names = []


@given("a ContextTierService instance for large_idx")
def step_given_tier_service(context: Any) -> None:
    context.large_idx_tier = ContextTierService(settings=Settings())


# ---------------------------------------------------------------------------
# When - hydration
# ---------------------------------------------------------------------------


@when("I hydrate tiers from the walk-based resource for large_idx")
def step_when_hydrate_walk(context: Any) -> None:
    start = time.monotonic()
    count = hydrate_tiers_from_project(
        tier_service=context.large_idx_tier,
        project_name=_PROJECT_NAME,
        resource_id=_RESOURCE_ID,
        resource_location=context.large_idx_location,
        resource_type="fs-directory",
    )
    elapsed = time.monotonic() - start
    context.large_idx_count = count
    context.large_idx_elapsed = elapsed
    # Cache fragments for reuse in Then steps to avoid double get_scoped_view calls.
    context.large_idx_fragments = context.large_idx_tier.get_scoped_view(
        [_PROJECT_NAME]
    )


@when("I hydrate tiers from the git-checkout resource for large_idx")
def step_when_hydrate_git(context: Any) -> None:
    start = time.monotonic()
    count = hydrate_tiers_from_project(
        tier_service=context.large_idx_tier,
        project_name=_PROJECT_NAME,
        resource_id=_RESOURCE_ID,
        resource_location=context.large_idx_location,
        resource_type="git-checkout",
    )
    elapsed = time.monotonic() - start
    context.large_idx_count = count
    context.large_idx_elapsed = elapsed
    context.large_idx_fragments = context.large_idx_tier.get_scoped_view(
        [_PROJECT_NAME]
    )


@when(
    "I hydrate tiers from the git-checkout resource on a non-git directory for large_idx"
)
def step_when_hydrate_git_on_non_git_dir(context: Any) -> None:
    """Hydrate using git-checkout resource type with git ls-files patched to fail.

    Patches ``subprocess.run`` so that any ``git ls-files`` call returns a
    non-zero exit code, simulating the case where ``git ls-files`` is
    unavailable or the directory is not a git repository.  The hydrator must
    fall back to ``os.walk`` and still index all files.
    """
    # Build a fake CompletedProcess that mimics git ls-files failure.
    _git_failure = MagicMock()
    _git_failure.returncode = 128
    _git_failure.stdout = ""

    _real_run = subprocess.run

    def _patched_run(cmd: list[str], *args: Any, **kwargs: Any) -> Any:
        if cmd and cmd[0] == "git" and "ls-files" in cmd:
            return _git_failure
        return _real_run(cmd, *args, **kwargs)

    start = time.monotonic()
    target = "cleveragents.application.services.context_tier_hydrator.subprocess.run"
    with patch(target, side_effect=_patched_run):
        count = hydrate_tiers_from_project(
            tier_service=context.large_idx_tier,
            project_name=_PROJECT_NAME,
            resource_id=_RESOURCE_ID,
            resource_location=context.large_idx_location,
            resource_type="git-checkout",
        )
    elapsed = time.monotonic() - start
    context.large_idx_count = count
    context.large_idx_elapsed = elapsed
    context.large_idx_fragments = context.large_idx_tier.get_scoped_view(
        [_PROJECT_NAME]
    )


# ---------------------------------------------------------------------------
# Then - assertions
# ---------------------------------------------------------------------------


@then("the hydration should complete without timeout for large_idx")
def step_then_no_timeout(context: Any) -> None:
    elapsed = context.large_idx_elapsed
    assert elapsed < _MAX_HYDRATION_SECONDS, (
        f"Hydration took {elapsed:.1f}s, exceeding the {_MAX_HYDRATION_SECONDS}s limit."
    )


@then("the fragment count should be greater than 0 for large_idx")
def step_then_count_positive(context: Any) -> None:
    count = context.large_idx_count
    assert count > 0, f"Expected at least one fragment to be indexed, but got {count}."


@then("the fragment count should be at most 10000 for large_idx")
def step_then_count_at_most_10k(context: Any) -> None:
    count = context.large_idx_count
    assert count <= 10_000, (
        f"Fragment count {count} exceeds the 10,000 file input size."
    )


@then("all indexed fragments should have text content for large_idx")
def step_then_all_text(context: Any) -> None:
    fragments = context.large_idx_fragments
    assert len(fragments) > 0, "No fragments were indexed."
    for frag in fragments:
        path = frag.metadata.get("path", "")
        assert not path.endswith(".png"), f"Binary file was incorrectly indexed: {path}"


@then("no oversized file should appear in the indexed fragments for large_idx")
def step_then_no_oversized(context: Any) -> None:
    oversized_names: list[str] = context.large_idx_oversized_names
    if not oversized_names:
        return
    fragments = context.large_idx_fragments
    indexed_paths = {frag.metadata.get("path", "") for frag in fragments}
    for name in oversized_names:
        for indexed_path in indexed_paths:
            assert name not in indexed_path, (
                f"Oversized file '{name}' was incorrectly indexed at path '{indexed_path}'."
            )


@then(
    "the total indexed size should not exceed the max total bytes budget for large_idx"
)
def step_then_budget_respected(context: Any) -> None:
    """Assert that the hydrator stopped before exceeding _MAX_TOTAL_BYTES.

    The fixture creates more data than the budget allows.  The hydrator must
    stop early, so the total content size of indexed fragments must be at or
    below _MAX_TOTAL_BYTES.
    """
    fragments = context.large_idx_fragments
    total_size = sum(len((frag.content or "").encode("utf-8")) for frag in fragments)
    assert total_size <= _MAX_TOTAL_BYTES, (
        f"Total indexed size {total_size} bytes exceeds the "
        f"_MAX_TOTAL_BYTES budget of {_MAX_TOTAL_BYTES} bytes."
    )
