"""Step definitions for resource_handler_content_hash.feature."""

from __future__ import annotations

import os
import subprocess
import tempfile
from pathlib import Path
from typing import Any

from behave import given, then, when

from cleveragents.domain.models.core.resource import PhysVirt, Resource
from cleveragents.resource.handlers._base import EMPTY_CONTENT_HASH, BaseResourceHandler
from cleveragents.resource.handlers.fs_directory import FsDirectoryHandler
from cleveragents.resource.handlers.git_checkout import GitCheckoutHandler


def _make_resource(location: str | None = None) -> Resource:
    """Create a minimal Resource for testing."""
    from ulid import ULID

    return Resource(
        resource_id=str(ULID()),
        resource_type_name="fs-directory",
        name="test-resource",
        classification=PhysVirt.PHYSICAL,
        location=location,
    )


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


@given("a resource with no location for content_hash")
def step_resource_no_location(context: object) -> None:
    """Create a resource with no location."""
    context.ch_resource = _make_resource(location=None)  # type: ignore[attr-defined]


@given('a resource with location "{path}" for content_hash')
def step_resource_with_location(context: object, path: str) -> None:
    """Create a resource with a specific location."""
    context.ch_resource = _make_resource(location=path)  # type: ignore[attr-defined]


@given('a temporary file with content "{content}" for content_hash')
def step_temp_file(context: object, content: str) -> None:
    """Create a temporary file with given content."""
    fd, path = tempfile.mkstemp(suffix=".txt")
    os.write(fd, content.encode())
    os.close(fd)
    context.ch_resource = _make_resource(location=path)  # type: ignore[attr-defined]
    context.ch_temp_path = path  # type: ignore[attr-defined]


@given('a second temporary file with content "{content}" for content_hash')
def step_second_temp_file(context: object, content: str) -> None:
    """Create a second temporary file."""
    fd, path = tempfile.mkstemp(suffix=".txt")
    os.write(fd, content.encode())
    os.close(fd)
    context.ch_resource2 = _make_resource(location=path)  # type: ignore[attr-defined]


@given("a temporary directory with files for content_hash")
def step_temp_dir_with_files(context: object) -> None:
    """Create a temp directory with test files."""
    tmpdir = tempfile.mkdtemp(prefix="ch-test-")
    Path(tmpdir, "file_a.txt").write_text("alpha")
    Path(tmpdir, "file_b.txt").write_text("beta")
    Path(tmpdir, "sub").mkdir()
    Path(tmpdir, "sub", "file_c.txt").write_text("gamma")
    context.ch_resource = _make_resource(location=tmpdir)  # type: ignore[attr-defined]
    context.ch_temp_dir = tmpdir  # type: ignore[attr-defined]


@given("a temporary git repository for content_hash")
def step_temp_git_repo(context: object) -> None:
    """Create a temp git repo with a commit."""
    tmpdir = tempfile.mkdtemp(prefix="ch-git-")
    subprocess.run(
        ["git", "init"],
        cwd=tmpdir,
        capture_output=True,
        check=True,
    )
    subprocess.run(
        ["git", "config", "user.email", "test@test.com"],
        cwd=tmpdir,
        capture_output=True,
        check=True,
    )
    subprocess.run(
        ["git", "config", "user.name", "Test"],
        cwd=tmpdir,
        capture_output=True,
        check=True,
    )
    Path(tmpdir, "README.md").write_text("# Test")
    subprocess.run(
        ["git", "add", "."],
        cwd=tmpdir,
        capture_output=True,
        check=True,
    )
    subprocess.run(
        ["git", "commit", "-m", "init"],
        cwd=tmpdir,
        capture_output=True,
        check=True,
    )
    context.ch_resource = _make_resource(location=tmpdir)  # type: ignore[attr-defined]


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


@when("I compute the content_hash via base handler")
def step_compute_base(context: object) -> None:
    """Compute hash via BaseResourceHandler."""
    handler = BaseResourceHandler()
    handler._default_strategy = "none"  # type: ignore[assignment]
    handler._type_label = "test"  # type: ignore[assignment]
    context.ch_result = handler.content_hash(context.ch_resource)  # type: ignore[attr-defined]


@when("I compute the content_hash twice via base handler")
def step_compute_twice(context: object) -> None:
    """Compute hash twice to verify determinism."""
    handler = BaseResourceHandler()
    handler._default_strategy = "none"  # type: ignore[assignment]
    handler._type_label = "test"  # type: ignore[assignment]
    res = context.ch_resource  # type: ignore[attr-defined]
    context.ch_result = handler.content_hash(res)  # type: ignore[attr-defined]
    context.ch_result2 = handler.content_hash(res)  # type: ignore[attr-defined]


@when("I compute content_hash for both files")
def step_compute_both(context: object) -> None:
    """Compute hashes for two different resources."""
    handler = BaseResourceHandler()
    handler._default_strategy = "none"  # type: ignore[assignment]
    handler._type_label = "test"  # type: ignore[assignment]
    context.ch_result = handler.content_hash(context.ch_resource)  # type: ignore[attr-defined]
    context.ch_result2 = handler.content_hash(context.ch_resource2)  # type: ignore[attr-defined]


@when("I compute the content_hash via FsDirectoryHandler")
def step_compute_fsdir(context: object) -> None:
    """Compute hash via FsDirectoryHandler."""
    handler = FsDirectoryHandler()
    context.ch_result = handler.content_hash(context.ch_resource)  # type: ignore[attr-defined]


@when("I modify a file in the directory")
def step_modify_file(context: object) -> None:
    """Modify a file in the temp directory."""
    context.ch_prev_result = context.ch_result  # type: ignore[attr-defined]
    tmpdir = context.ch_temp_dir  # type: ignore[attr-defined]
    Path(tmpdir, "file_a.txt").write_text("alpha-modified")


@when("I compute the content_hash via FsDirectoryHandler again")
def step_compute_fsdir_again(context: object) -> None:
    """Re-compute hash after modification."""
    handler = FsDirectoryHandler()
    context.ch_result = handler.content_hash(context.ch_resource)  # type: ignore[attr-defined]


@when("I compute the content_hash via GitCheckoutHandler")
def step_compute_git(context: object) -> None:
    """Compute hash via GitCheckoutHandler."""
    handler = GitCheckoutHandler()
    context.ch_result = handler.content_hash(context.ch_resource)  # type: ignore[attr-defined]


@when('I compute the content_hash with algorithm "{algo}"')
def step_compute_with_algo(context: object, algo: str) -> None:
    """Compute hash with a specific algorithm."""
    handler = BaseResourceHandler()
    handler._default_strategy = "none"  # type: ignore[assignment]
    handler._type_label = "test"  # type: ignore[assignment]
    context.ch_result = handler.content_hash(  # type: ignore[attr-defined]
        context.ch_resource,
        algorithm=algo,  # type: ignore[attr-defined]
    )


# ── Then steps ───────────────────────────────────────────────


@then("the content_hash result should be the EMPTY sentinel")
def step_is_sentinel(context: object) -> None:
    """Assert the result equals EMPTY_CONTENT_HASH."""
    assert context.ch_result == EMPTY_CONTENT_HASH, (  # type: ignore[attr-defined]
        f"Expected sentinel, got {context.ch_result}"  # type: ignore[attr-defined]
    )


@then("the content_hash result should not be the EMPTY sentinel")
def step_not_sentinel(context: object) -> None:
    """Assert the result is not the sentinel."""
    assert context.ch_result != EMPTY_CONTENT_HASH, (  # type: ignore[attr-defined]
        "Expected non-sentinel hash"
    )


@then("the two content_hash results should be identical")
def step_results_identical(context: object) -> None:
    """Assert two hash results are the same."""
    assert context.ch_result == context.ch_result2, (  # type: ignore[attr-defined]
        f"Hashes differ: {context.ch_result} vs {context.ch_result2}"  # type: ignore[attr-defined]
    )


@then("the two content_hash results should differ")
def step_results_differ(context: object) -> None:
    """Assert two hash results are different."""
    assert context.ch_result != context.ch_result2, (  # type: ignore[attr-defined]
        f"Hashes should differ but both are {context.ch_result}"  # type: ignore[attr-defined]
    )


@then("the two directory hashes should differ")
def step_dir_hashes_differ(context: object) -> None:
    """Assert directory hash changed after modification."""
    assert context.ch_prev_result != context.ch_result, (  # type: ignore[attr-defined]
        "Directory hash should change after file modification"
    )


@then("the content_hash length should be {length:d}")
def step_hash_length(context: object, length: int) -> None:
    """Assert the hash has the expected hex-string length."""
    actual = len(context.ch_result)  # type: ignore[attr-defined]
    assert actual == length, f"Expected length {length}, got {actual}"


@then("{handler_name} should have a content_hash method")
def step_has_method(context: object, handler_name: str) -> None:
    """Assert the handler class has a content_hash method."""
    handlers = {
        "GitCheckoutHandler": GitCheckoutHandler,
        "FsDirectoryHandler": FsDirectoryHandler,
    }
    # Lazy imports for handlers not already imported
    if handler_name == "DevcontainerHandler":
        from cleveragents.resource.handlers.devcontainer import DevcontainerHandler

        handlers["DevcontainerHandler"] = DevcontainerHandler
    elif handler_name == "DatabaseResourceHandler":
        from cleveragents.resource.handlers.database import DatabaseResourceHandler

        handlers["DatabaseResourceHandler"] = DatabaseResourceHandler

    cls = handlers.get(handler_name)
    assert cls is not None, f"Unknown handler: {handler_name}"
    assert hasattr(cls, "content_hash"), f"{handler_name} missing content_hash method"


# ---------------------------------------------------------------------------
# DevcontainerHandler content_hash steps
# ---------------------------------------------------------------------------


@given("a temporary devcontainer project for content_hash")
def step_devcontainer_project(context: Any) -> None:
    tmpdir = tempfile.mkdtemp()
    dc_dir = os.path.join(tmpdir, ".devcontainer")
    os.makedirs(dc_dir)
    config = os.path.join(dc_dir, "devcontainer.json")
    with open(config, "w") as f:
        f.write(
            '{"name": "test", "image": "mcr.microsoft.com/devcontainers/base:ubuntu"}\n'
        )
    context.ch_resource = Resource(
        resource_id="01DCTEST00000000000000DC01",
        resource_type_name="devcontainer-file",
        name="test-devcontainer",
        classification=PhysVirt.PHYSICAL,
        location=tmpdir,
    )
    context._ch_tmpdir = tmpdir


@when("I compute the content_hash via DevcontainerHandler")
def step_devcontainer_hash(context: Any) -> None:
    from cleveragents.resource.handlers.devcontainer import DevcontainerHandler

    handler = DevcontainerHandler()
    context.ch_result = handler.content_hash(context.ch_resource)


@when("I compute the content_hash via DevcontainerHandler for missing")
def step_devcontainer_hash_missing(context: Any) -> None:
    from cleveragents.resource.handlers.devcontainer import DevcontainerHandler

    handler = DevcontainerHandler()
    context.ch_result = handler.content_hash(context.ch_resource)


@then("the devcontainer hash should be from config file fallback")
def step_devcontainer_config_fallback(context: Any) -> None:
    # If no running container, the hash comes from config file
    # Verify it's not empty (config file was hashed)
    assert context.ch_result != EMPTY_CONTENT_HASH
    assert len(context.ch_result) == 64  # SHA-256


# ---------------------------------------------------------------------------
# DatabaseResourceHandler content_hash steps
# ---------------------------------------------------------------------------


@given("a temporary SQLite database for content_hash")
def step_sqlite_db(context: Any) -> None:
    import sqlite3

    fd, db_path = tempfile.mkstemp(suffix=".db")
    os.close(fd)
    conn = sqlite3.connect(db_path)
    conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
    conn.execute("INSERT INTO users VALUES (1, 'alice')")
    conn.commit()
    conn.close()
    context.ch_resource = Resource(
        resource_id="01DBTEST00000000000000DB01",
        resource_type_name="sqlite",
        name="test-db",
        classification=PhysVirt.PHYSICAL,
        location=db_path,
    )
    context._ch_db_path = db_path


@when("I compute the content_hash via DatabaseResourceHandler")
def step_database_hash(context: Any) -> None:
    from cleveragents.resource.handlers.database import DatabaseResourceHandler

    handler = DatabaseResourceHandler()
    context.ch_result = handler.content_hash(context.ch_resource)


@when("I add a table to the database")
def step_add_table(context: Any) -> None:
    import sqlite3

    conn = sqlite3.connect(context._ch_db_path)
    conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, total REAL)")
    conn.commit()
    conn.close()


@when("I compute the content_hash via DatabaseResourceHandler again")
def step_database_hash_again(context: Any) -> None:
    from cleveragents.resource.handlers.database import DatabaseResourceHandler

    handler = DatabaseResourceHandler()
    context.ch_result2 = handler.content_hash(context.ch_resource)


@then("the two database hashes should differ")
def step_database_hashes_differ(context: Any) -> None:
    assert context.ch_result != context.ch_result2, (
        f"Expected different hashes after schema change, both are {context.ch_result}"
    )


@given("a remote database resource for content_hash")
def step_remote_db_resource(context: Any) -> None:
    context.ch_resource = Resource(
        resource_id="01DBTEST00000000000000DB02",
        resource_type_name="postgres",
        name="test-remote-db",
        classification=PhysVirt.PHYSICAL,
        location="postgresql://host/db",
    )


@when("I compute the content_hash via DatabaseResourceHandler for remote")
def step_database_hash_remote(context: Any) -> None:
    from cleveragents.resource.handlers.database import DatabaseResourceHandler

    handler = DatabaseResourceHandler()
    context.ch_result = handler.content_hash(context.ch_resource)


# ---------------------------------------------------------------------------
# CloudResourceHandler content_hash steps
# ---------------------------------------------------------------------------


@given("a cloud resource with ARN location for content_hash")
def step_cloud_resource_with_location(context: Any) -> None:
    context.ch_resource = Resource(
        resource_id="01AATEST0000000000000CCC01",
        resource_type_name="aws-s3",
        name="test-bucket",
        classification=PhysVirt.PHYSICAL,
        location="arn:aws:s3:::my-bucket",
    )


@given("a cloud resource without location for content_hash")
def step_cloud_resource_no_location(context: Any) -> None:
    context.ch_resource = Resource(
        resource_id="01AATEST0000000000000CCC02",
        resource_type_name="aws-s3",
        name="test-bucket-noloc",
        classification=PhysVirt.PHYSICAL,
    )


@when("I compute the content_hash via CloudResourceHandler")
def step_cloud_hash(context: Any) -> None:
    from cleveragents.resource.handlers.cloud import CloudResourceHandler

    handler = CloudResourceHandler()
    context.ch_result = handler.content_hash(context.ch_resource)


@when("I compute the content_hash via CloudResourceHandler for empty")
def step_cloud_hash_empty(context: Any) -> None:
    from cleveragents.resource.handlers.cloud import CloudResourceHandler

    handler = CloudResourceHandler()
    context.ch_result = handler.content_hash(context.ch_resource)


# ---------------------------------------------------------------------------
# Edge case and error path coverage steps
# ---------------------------------------------------------------------------


@given("a temporary directory with only subdirectories for content_hash")
def step_dir_only_subdirs(context: Any) -> None:
    tmpdir = tempfile.mkdtemp()
    os.makedirs(os.path.join(tmpdir, "subdir1"))
    os.makedirs(os.path.join(tmpdir, "subdir2"))
    context.ch_resource = Resource(
        resource_id="01EDGE00000000000000000001",
        resource_type_name="fs-directory",
        name="empty-subdirs",
        classification=PhysVirt.PHYSICAL,
        location=tmpdir,
    )


@when("I compute the content_hash via base handler for dir")
def step_base_hash_dir(context: Any) -> None:
    handler = BaseResourceHandler()
    context.ch_result = handler.content_hash(context.ch_resource)


@given("a temporary file pretending to be a directory for content_hash")
def step_file_as_dir(context: Any) -> None:
    fd, fpath = tempfile.mkstemp(suffix=".txt")
    os.close(fd)
    context.ch_resource = Resource(
        resource_id="01EDGE00000000000000000002",
        resource_type_name="fs-directory",
        name="not-a-dir",
        classification=PhysVirt.PHYSICAL,
        location=fpath,
    )


@when("I compute the content_hash via FsDirectoryHandler for non-dir")
def step_fsdir_hash_nondir(context: Any) -> None:
    handler = FsDirectoryHandler()
    context.ch_result = handler.content_hash(context.ch_resource)


@given("a temporary directory with an unreadable file for content_hash")
def step_dir_unreadable(context: Any) -> None:
    tmpdir = tempfile.mkdtemp()
    good = os.path.join(tmpdir, "good.txt")
    bad = os.path.join(tmpdir, "bad.txt")
    with open(good, "w") as f:
        f.write("readable content")
    with open(bad, "w") as f:
        f.write("secret")
    os.chmod(bad, 0o000)
    context.ch_resource = Resource(
        resource_id="01EDGE00000000000000000003",
        resource_type_name="fs-directory",
        name="has-unreadable",
        classification=PhysVirt.PHYSICAL,
        location=tmpdir,
    )
    context._ch_bad_file = bad


@when("I compute the content_hash via FsDirectoryHandler for unreadable")
def step_fsdir_hash_unreadable(context: Any) -> None:
    handler = FsDirectoryHandler()
    context.ch_result = handler.content_hash(context.ch_resource)
    # Restore permissions for cleanup
    os.chmod(context._ch_bad_file, 0o644)


@given("a temporary non-git directory for content_hash")
def step_nongit_dir(context: Any) -> None:
    tmpdir = tempfile.mkdtemp()
    # Create a corrupt .git file so git rev-parse HEAD fails even if
    # the temp dir is inside a git worktree (CI environments).
    with open(os.path.join(tmpdir, ".git"), "w") as f:
        f.write("not a valid git dir\n")
    with open(os.path.join(tmpdir, "file.txt"), "w") as f:
        f.write("not a git repo")
    context.ch_resource = Resource(
        resource_id="01EDGE00000000000000000004",
        resource_type_name="git-checkout",
        name="not-git",
        classification=PhysVirt.PHYSICAL,
        location=tmpdir,
    )


@when("I compute the content_hash via GitCheckoutHandler for non-git")
def step_git_hash_nongit(context: Any) -> None:
    handler = GitCheckoutHandler()
    context.ch_result = handler.content_hash(context.ch_resource)


@given("a temporary file as git location for content_hash")
def step_file_as_git(context: Any) -> None:
    fd, fpath = tempfile.mkstemp()
    os.close(fd)
    context.ch_resource = Resource(
        resource_id="01EDGE00000000000000000005",
        resource_type_name="git-checkout",
        name="file-not-dir",
        classification=PhysVirt.PHYSICAL,
        location=fpath,
    )


@when("I compute the content_hash via GitCheckoutHandler for file")
def step_git_hash_file(context: Any) -> None:
    handler = GitCheckoutHandler()
    context.ch_result = handler.content_hash(context.ch_resource)


@given("a database resource with no location for content_hash")
def step_db_no_location(context: Any) -> None:
    context.ch_resource = Resource(
        resource_id="01EDGE00000000000000000006",
        resource_type_name="sqlite",
        name="db-noloc",
        classification=PhysVirt.PHYSICAL,
    )


@when("I compute the content_hash via DatabaseResourceHandler for noloc")
def step_db_hash_noloc(context: Any) -> None:
    from cleveragents.resource.handlers.database import DatabaseResourceHandler

    handler = DatabaseResourceHandler()
    context.ch_result = handler.content_hash(context.ch_resource)


@given("a SQLite database with non-standard extension for content_hash")
def step_sqlite_nonext(context: Any) -> None:
    import sqlite3

    fd, db_path = tempfile.mkstemp(suffix=".data")
    os.close(fd)
    conn = sqlite3.connect(db_path)
    conn.execute("CREATE TABLE t (id INTEGER)")
    conn.commit()
    conn.close()
    context.ch_resource = Resource(
        resource_id="01EDGE00000000000000000007",
        resource_type_name="sqlite",
        name="nonext-db",
        classification=PhysVirt.PHYSICAL,
        location=db_path,
    )


@when("I compute the content_hash via DatabaseResourceHandler for nonext")
def step_db_hash_nonext(context: Any) -> None:
    from cleveragents.resource.handlers.database import DatabaseResourceHandler

    handler = DatabaseResourceHandler()
    context.ch_result = handler.content_hash(context.ch_resource)


@when("I check _find_running_container")
def step_check_find_container(context: Any) -> None:
    from cleveragents.resource.handlers.devcontainer import DevcontainerHandler

    handler = DevcontainerHandler()
    context._ch_container_id = handler._find_running_container(context.ch_resource)


@then("the container ID should be None")
def step_container_id_none(context: Any) -> None:
    assert context._ch_container_id is None


# ---------------------------------------------------------------------------
# Mock-based branch coverage steps
# ---------------------------------------------------------------------------


@when("I compute content_hash with mocked docker exec returning file hashes")
def step_devcontainer_mock_exec(context: Any) -> None:
    from unittest.mock import MagicMock, patch

    from cleveragents.resource.handlers.devcontainer import DevcontainerHandler

    handler = DevcontainerHandler()
    mock_result = MagicMock()
    mock_result.returncode = 0
    mock_result.stdout = (
        "abc123  /workspaces/project/file1.py\ndef456  /workspaces/project/file2.py\n"
    )

    with (
        patch("subprocess.run", return_value=mock_result),
        patch.object(handler, "_find_running_container", return_value="abc123def456"),
    ):
        context.ch_result = handler.content_hash(context.ch_resource)


@when("I compute content_hash with docker ps raising OSError")
def step_devcontainer_oserror(context: Any) -> None:
    from unittest.mock import patch

    from cleveragents.resource.handlers.devcontainer import DevcontainerHandler

    handler = DevcontainerHandler()

    with patch(
        "cleveragents.resource.handlers.devcontainer.subprocess.run",
        side_effect=OSError("docker not found"),
    ):
        context.ch_result = handler.content_hash(context.ch_resource)


@then("the devcontainer falls back to config hash")
def step_devcontainer_fallback(context: Any) -> None:
    # Should have fallen back to config file hash (not empty)
    assert context.ch_result != EMPTY_CONTENT_HASH
    assert len(context.ch_result) == 64


@given("a temporary git-checkout resource for content_hash timeout")
def step_git_timeout_resource(context: Any) -> None:
    tmpdir = tempfile.mkdtemp()
    context.ch_resource = Resource(
        resource_id="01EDGEMCK000000000000000MK",
        resource_type_name="git-checkout",
        name="mock-git",
        classification=PhysVirt.PHYSICAL,
        location=tmpdir,
    )


@when("I compute content_hash with git rev-parse timing out")
def step_git_timeout(context: Any) -> None:
    from unittest.mock import patch

    handler = GitCheckoutHandler()

    with patch(
        "subprocess.run",
        side_effect=subprocess.TimeoutExpired(cmd="git", timeout=10),
    ):
        context.ch_result = handler.content_hash(context.ch_resource)


@when("I compute content_hash with git rev-parse raising OSError")
def step_git_oserror(context: Any) -> None:
    from unittest.mock import patch

    handler = GitCheckoutHandler()

    with patch("subprocess.run", side_effect=OSError("git not found")):
        context.ch_result = handler.content_hash(context.ch_resource)


@given("a resource pointing to a non-file non-dir path for content_hash")
def step_special_file(context: Any) -> None:

    # Use a real temp path but mock os.path to make it exist but be neither file nor dir
    tmpdir = tempfile.mkdtemp()
    context.ch_resource = Resource(
        resource_id="01EDGE00000000000000SPEC01",
        resource_type_name="test",
        name="special",
        classification=PhysVirt.PHYSICAL,
        location=tmpdir,
    )
    context._ch_special_tmpdir = tmpdir


@when("I compute the content_hash via base handler for special")
def step_base_hash_special(context: Any) -> None:
    from unittest.mock import patch

    handler = BaseResourceHandler()

    # Mock: path exists, but is neither file nor dir (e.g., a socket)
    with (
        patch("os.path.exists", return_value=True),
        patch("os.path.isfile", return_value=False),
        patch("os.path.isdir", return_value=False),
    ):
        context.ch_result = handler.content_hash(context.ch_resource)
