"""Step definitions for project_cli_commands.feature.

Exercises the *actual* CLI command functions in
``cleveragents.cli.commands.project`` (create, list, show, link-resource,
unlink-resource, delete) via monkey-patched helpers so that the command
bodies are executed under coverage.

Each scenario gets a fresh in-memory SQLite database.  The DI-container
look-up helpers are monkey-patched to return repositories backed by that
database, then restored after every step that needs them.
"""

from __future__ import annotations

import json
from io import StringIO
from typing import Any

from behave import given, then, when  # type: ignore[import-untyped]
from rich.console import Console
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

# ---------------------------------------------------------------------------
# Database / service bootstrap
# ---------------------------------------------------------------------------


class _SharedSession:
    """Wraps a real SQLAlchemy Session but makes ``close()`` a no-op.

    This ensures every repository call within the same scenario uses the
    same session (and therefore sees the same uncommitted data), which is
    critical for in-memory SQLite where transaction isolation would otherwise
    hide data between sessions.
    """

    def __init__(self, real: Any) -> None:
        object.__setattr__(self, "_real", real)

    def close(self) -> None:
        """No-op so the shared session stays usable across calls."""

    def __getattr__(self, name: str) -> Any:
        return getattr(object.__getattribute__(self, "_real"), name)

    def __setattr__(self, name: str, value: Any) -> None:
        setattr(object.__getattribute__(self, "_real"), name, value)


def _init_commands_db(context: Any) -> None:
    """Create a fresh in-memory database and wire up repos + service."""
    from cleveragents.application.services.resource_registry_service import (
        ResourceRegistryService,
    )
    from cleveragents.infrastructure.database.models import Base
    from cleveragents.infrastructure.database.repositories import (
        NamespacedProjectRepository,
        ProjectResourceLinkRepository,
    )

    engine = create_engine(
        "sqlite:///:memory:",
        echo=False,
        connect_args={"check_same_thread": False},
    )
    Base.metadata.create_all(engine)

    # Use a single shared session to avoid transaction isolation issues
    # with :memory: SQLite when multiple repos are involved.
    real_session = sessionmaker(
        bind=engine, expire_on_commit=False, autoflush=True, autocommit=False
    )()
    wrapper = _SharedSession(real_session)

    def _shared_factory() -> Any:
        return wrapper

    context._cmd_engine = engine
    context._cmd_factory = _shared_factory
    context._cmd_project_repo = NamespacedProjectRepository(
        session_factory=_shared_factory
    )
    context._cmd_link_repo = ProjectResourceLinkRepository(
        session_factory=_shared_factory
    )
    context._cmd_resource_svc = ResourceRegistryService(session_factory=_shared_factory)
    context._cmd_resource_svc.bootstrap_builtin_types()
    context._cmd_output = ""
    context._cmd_failed = False
    context._cmd_resource_map = {}
    context._cmd_last_resource_id = None


# ---------------------------------------------------------------------------
# Monkey-patching helpers
# ---------------------------------------------------------------------------

_ORIG_FNS: dict[str, Any] = {}


def _patch_project_mod(context: Any) -> None:
    """Monkey-patch the four DI look-up helpers in project module."""
    import cleveragents.cli.commands.project as project_mod

    _ORIG_FNS["repo"] = project_mod._get_namespaced_project_repo
    _ORIG_FNS["link"] = project_mod._get_resource_link_repo
    _ORIG_FNS["svc"] = project_mod._get_resource_registry_service

    project_mod._get_namespaced_project_repo = lambda: context._cmd_project_repo
    project_mod._get_resource_link_repo = lambda: context._cmd_link_repo
    project_mod._get_resource_registry_service = lambda: context._cmd_resource_svc

    # Patch _store_project_extras to a no-op since it needs a shared
    # file-based DB (the real helper is tested separately)
    _ORIG_FNS["store_extras"] = project_mod._store_project_extras
    project_mod._store_project_extras = lambda *a, **kw: None


def _unpatch_project_mod() -> None:
    """Restore original helpers."""
    import cleveragents.cli.commands.project as project_mod

    project_mod._get_namespaced_project_repo = _ORIG_FNS["repo"]
    project_mod._get_resource_link_repo = _ORIG_FNS["link"]
    project_mod._get_resource_registry_service = _ORIG_FNS["svc"]
    if _ORIG_FNS.get("store_extras"):
        project_mod._store_project_extras = _ORIG_FNS["store_extras"]


def _capture(context: Any, func: Any, *args: Any, **kwargs: Any) -> None:
    """Call a CLI function, capturing console output and success/failure."""
    import contextlib

    import typer

    import cleveragents.cli.commands.project as project_mod

    buf = StringIO()
    fake_console = Console(file=buf, width=200, no_color=True)

    orig_console = project_mod.console
    orig_err = project_mod.err_console
    project_mod.console = fake_console
    project_mod.err_console = fake_console

    _patch_project_mod(context)
    failed = False
    try:
        with contextlib.redirect_stdout(buf):
            func(*args, **kwargs)
    except (SystemExit, typer.Exit, typer.Abort):
        failed = True
    except Exception:
        failed = True
    finally:
        project_mod.console = orig_console
        project_mod.err_console = orig_err
        _unpatch_project_mod()

    context._cmd_output = buf.getvalue()
    context._cmd_failed = failed


# ---------------------------------------------------------------------------
# Reusable helpers
# ---------------------------------------------------------------------------


def _create_project(context: Any, name: str, description: str | None = None) -> Any:
    from cleveragents.domain.models.core.project import (
        NamespacedProject,
        parse_namespaced_name,
    )

    parsed = parse_namespaced_name(name)
    proj = NamespacedProject(
        name=parsed.name,
        namespace=parsed.namespace,
        server=parsed.server,
        description=description,
    )
    context._cmd_project_repo.create(proj)
    return proj


def _register_resource(context: Any, alias: str) -> str:
    res = context._cmd_resource_svc.register_resource(
        type_name="git-checkout",
        name=f"local/{alias}",
        location="/tmp/test-resource",
        description=f"Test resource {alias}",
    )
    context._cmd_resource_map[alias] = res.resource_id
    context._cmd_last_resource_id = res.resource_id
    return res.resource_id


# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------


@given("a project CLI commands test database is initialized")
def step_init_commands_db(context: Any) -> None:
    _init_commands_db(context)


# ---------------------------------------------------------------------------
# Given steps
# ---------------------------------------------------------------------------


@given('a project "{name}" is created in the commands DB')
def step_project_exists_cmd(context: Any, name: str) -> None:
    _create_project(context, name)


@given('a resource "{alias}" is registered in the commands DB')
def step_resource_registered_cmd(context: Any, alias: str) -> None:
    _register_resource(context, alias)


@given('a resource is linked to project "{project_name}" in the commands DB')
def step_resource_linked_cmd(context: Any, project_name: str) -> None:
    safe = project_name.replace("/", "-")
    rid = _register_resource(context, f"linked-{safe}")
    context._cmd_link_repo.create_link(
        project_name=project_name,
        resource_id=rid,
    )


# ---------------------------------------------------------------------------
# Helper function coverage
# ---------------------------------------------------------------------------


@when("I call _get_namespaced_project_repo via the patched container")
def step_call_get_repo(context: Any) -> None:
    import cleveragents.cli.commands.project as project_mod

    _patch_project_mod(context)
    try:
        context._cmd_helper_result = project_mod._get_namespaced_project_repo()
    finally:
        _unpatch_project_mod()


@when("I call _get_resource_link_repo via the patched container")
def step_call_get_link_repo(context: Any) -> None:
    import cleveragents.cli.commands.project as project_mod

    _patch_project_mod(context)
    try:
        context._cmd_helper_result = project_mod._get_resource_link_repo()
    finally:
        _unpatch_project_mod()


@when("I call _get_resource_registry_service via the patched container")
def step_call_get_svc(context: Any) -> None:
    import cleveragents.cli.commands.project as project_mod

    _patch_project_mod(context)
    try:
        context._cmd_helper_result = project_mod._get_resource_registry_service()
    finally:
        _unpatch_project_mod()


@when('I call _store_project_extras with invariants "{inv}" and actor "{actor}"')
def step_call_store_extras(context: Any, inv: str, actor: str) -> None:
    """Call the real _store_project_extras against a temp file-based DB.

    ``_store_project_extras`` opens its own engine from ``get_database_url()``,
    so we create a file-based temp DB, seed a project, and patch the URL.
    """
    import tempfile
    from pathlib import Path

    from sqlalchemy import create_engine as _ce
    from sqlalchemy.orm import sessionmaker as _sm

    from cleveragents.domain.models.core.project import (
        NamespacedProject,
        parse_namespaced_name,
    )
    from cleveragents.infrastructure.database.models import Base as _Base
    from cleveragents.infrastructure.database.repositories import (
        NamespacedProjectRepository,
    )

    tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)  # noqa: SIM115
    tmp.close()
    db_url = f"sqlite:///{tmp.name}"
    engine = _ce(db_url, echo=False)
    _Base.metadata.create_all(engine)
    # Use a single session so we can commit after repo.create()
    session = _sm(bind=engine, expire_on_commit=False)()

    def _factory() -> Any:
        return session

    repo = NamespacedProjectRepository(session_factory=_factory)
    parsed = parse_namespaced_name("local/extras-proj")
    proj = NamespacedProject(name=parsed.name, namespace=parsed.namespace)
    repo.create(proj)
    session.commit()

    import cleveragents.application.container as container_mod
    import cleveragents.cli.commands.project as project_mod

    orig = container_mod.get_database_url
    container_mod.get_database_url = lambda: db_url
    try:
        project_mod._store_project_extras(
            "local/extras-proj",
            invariant_texts=[inv],
            inv_actor=actor,
        )
        context._cmd_failed = False
    except Exception:
        context._cmd_failed = True
    finally:
        container_mod.get_database_url = orig
        engine.dispose()
        Path(tmp.name).unlink(missing_ok=True)


@then("the helper should return a NamespacedProjectRepository")
def step_check_repo(context: Any) -> None:
    from cleveragents.infrastructure.database.repositories import (
        NamespacedProjectRepository,
    )

    assert isinstance(context._cmd_helper_result, NamespacedProjectRepository)


@then("the helper should return a ProjectResourceLinkRepository")
def step_check_link_repo(context: Any) -> None:
    from cleveragents.infrastructure.database.repositories import (
        ProjectResourceLinkRepository,
    )

    assert isinstance(context._cmd_helper_result, ProjectResourceLinkRepository)


@then("the helper should return a ResourceRegistryService")
def step_check_svc(context: Any) -> None:
    from cleveragents.application.services.resource_registry_service import (
        ResourceRegistryService,
    )

    assert isinstance(context._cmd_helper_result, ResourceRegistryService)


@then("the extras call should complete without error")
def step_extras_ok(context: Any) -> None:
    assert not context._cmd_failed, "Expected _store_project_extras to succeed"


# ---------------------------------------------------------------------------
# Create command
# ---------------------------------------------------------------------------


@when("I invoke project-create triggering a database error")
def step_invoke_create_db_error(context: Any) -> None:
    """Exercise the ``except DatabaseError`` branch in the create command."""
    from unittest.mock import MagicMock

    from cleveragents.cli.commands.project import create
    from cleveragents.core.exceptions import DatabaseError

    # Temporarily replace the repo with one whose create() raises DatabaseError
    orig_repo = context._cmd_project_repo
    mock_repo = MagicMock()
    mock_repo.create.side_effect = DatabaseError("simulated DB failure")
    context._cmd_project_repo = mock_repo
    try:
        _capture(context, create, name="db-err-proj")
    finally:
        context._cmd_project_repo = orig_repo


@when('I invoke project-create with bare name "{name}"')
def step_invoke_create(context: Any, name: str) -> None:
    from cleveragents.cli.commands.project import create

    _capture(context, create, name=name)


@when('I invoke project-create with name "{name}" description "{desc}"')
def step_invoke_create_desc(context: Any, name: str, desc: str) -> None:
    from cleveragents.cli.commands.project import create

    _capture(context, create, name=name, description=desc)


@when('I invoke project-create with name "{name}" format "{fmt}"')
def step_invoke_create_fmt(context: Any, name: str, fmt: str) -> None:
    from cleveragents.cli.commands.project import create

    _capture(context, create, name=name, output_format=fmt)


@when('I invoke project-create with name "{name}" invariant "{inv}"')
def step_invoke_create_inv(context: Any, name: str, inv: str) -> None:
    from cleveragents.cli.commands.project import create

    _capture(context, create, name=name, invariant=[inv])


@when('I invoke project-create with name "{name}" linking resource "{res}"')
def step_invoke_create_res(context: Any, name: str, res: str) -> None:
    from cleveragents.cli.commands.project import create

    _capture(context, create, name=name, resource=[f"local/{res}"])


# ---------------------------------------------------------------------------
# List command
# ---------------------------------------------------------------------------


@when("I invoke project-list with default options")
def step_invoke_list(context: Any) -> None:
    from cleveragents.cli.commands.project import list_projects

    _capture(context, list_projects)


@when('I invoke project-list with namespace "{ns}"')
def step_invoke_list_ns(context: Any, ns: str) -> None:
    from cleveragents.cli.commands.project import list_projects

    _capture(context, list_projects, namespace=ns)


@when('I invoke project-list with regex "{regex}"')
def step_invoke_list_regex(context: Any, regex: str) -> None:
    from cleveragents.cli.commands.project import list_projects

    _capture(context, list_projects, regex=regex)


@when('I invoke project-list with invalid regex "{regex}"')
def step_invoke_list_bad_regex(context: Any, regex: str) -> None:
    from cleveragents.cli.commands.project import list_projects

    _capture(context, list_projects, regex=regex)


@when('I invoke project-list with format "{fmt}"')
def step_invoke_list_fmt(context: Any, fmt: str) -> None:
    from cleveragents.cli.commands.project import list_projects

    _capture(context, list_projects, output_format=fmt)


# ---------------------------------------------------------------------------
# Show command
# ---------------------------------------------------------------------------


@when('I invoke project-show for "{name}" default format')
def step_invoke_show(context: Any, name: str) -> None:
    from cleveragents.cli.commands.project import show

    _capture(context, show, project=name)


@when('I invoke project-show for "{name}" format "{fmt}"')
def step_invoke_show_fmt(context: Any, name: str, fmt: str) -> None:
    from cleveragents.cli.commands.project import show

    _capture(context, show, project=name, output_format=fmt)


# ---------------------------------------------------------------------------
# Link-resource command
# ---------------------------------------------------------------------------


@when('I invoke project-link for "{proj}" resource "{res}" default format')
def step_invoke_link(context: Any, proj: str, res: str) -> None:
    from cleveragents.cli.commands.project import link_resource

    resource_name = f"local/{res}" if "/" not in res else res
    _capture(context, link_resource, project=proj, resource_name=resource_name)


@when('I invoke project-link for "{proj}" resource "{res}" read-only')
def step_invoke_link_ro(context: Any, proj: str, res: str) -> None:
    from cleveragents.cli.commands.project import link_resource

    resource_name = f"local/{res}" if "/" not in res else res
    _capture(
        context,
        link_resource,
        project=proj,
        resource_name=resource_name,
        read_only=True,
    )


@when('I invoke project-link for "{proj}" resource "{res}" format "{fmt}"')
def step_invoke_link_fmt(context: Any, proj: str, res: str, fmt: str) -> None:
    from cleveragents.cli.commands.project import link_resource

    resource_name = f"local/{res}" if "/" not in res else res
    _capture(
        context,
        link_resource,
        project=proj,
        resource_name=resource_name,
        output_format=fmt,
    )


# ---------------------------------------------------------------------------
# Unlink-resource command
# ---------------------------------------------------------------------------


@when('I invoke project-unlink for "{proj}" with yes default format')
def step_invoke_unlink(context: Any, proj: str) -> None:
    from cleveragents.cli.commands.project import unlink_resource

    links = context._cmd_link_repo.list_links(proj)
    if links:
        res_id = str(links[0].resource_id)
        try:
            res = context._cmd_resource_svc.show_resource(res_id)
            res_name = res.name or res_id
        except Exception:
            res_name = res_id
        _capture(
            context,
            unlink_resource,
            project=proj,
            resource_name=res_name,
            yes=True,
        )
    else:
        _capture(
            context,
            unlink_resource,
            project=proj,
            resource_name="none",
            yes=True,
        )


@when('I invoke project-unlink for "{proj}" with yes format "{fmt}"')
def step_invoke_unlink_fmt(context: Any, proj: str, fmt: str) -> None:
    from cleveragents.cli.commands.project import unlink_resource

    links = context._cmd_link_repo.list_links(proj)
    if links:
        res_id = str(links[0].resource_id)
        try:
            res = context._cmd_resource_svc.show_resource(res_id)
            res_name = res.name or res_id
        except Exception:
            res_name = res_id
        _capture(
            context,
            unlink_resource,
            project=proj,
            resource_name=res_name,
            yes=True,
            output_format=fmt,
        )
    else:
        _capture(
            context,
            unlink_resource,
            project=proj,
            resource_name="none",
            yes=True,
            output_format=fmt,
        )


@when('I invoke project-unlink for "{proj}" resource "{res}" with yes')
def step_invoke_unlink_named(context: Any, proj: str, res: str) -> None:
    from cleveragents.cli.commands.project import unlink_resource

    resource_name = f"local/{res}" if "/" not in res else res
    _capture(
        context,
        unlink_resource,
        project=proj,
        resource_name=resource_name,
        yes=True,
    )


# ---------------------------------------------------------------------------
# Delete command
# ---------------------------------------------------------------------------


@when('I invoke project-delete for "{name}" with yes')
def step_invoke_delete(context: Any, name: str) -> None:
    from cleveragents.cli.commands.project import delete

    _capture(context, delete, name=name, yes=True)


@when('I invoke project-delete for "{name}" with yes format "{fmt}"')
def step_invoke_delete_fmt(context: Any, name: str, fmt: str) -> None:
    from cleveragents.cli.commands.project import delete

    _capture(context, delete, name=name, yes=True, output_format=fmt)


@when('I invoke project-delete for "{name}" with yes no force')
def step_invoke_delete_no_force(context: Any, name: str) -> None:
    from cleveragents.cli.commands.project import delete

    _capture(context, delete, name=name, yes=True, force=False)


@when('I invoke project-delete for "{name}" with yes and force')
def step_invoke_delete_force(context: Any, name: str) -> None:
    from cleveragents.cli.commands.project import delete

    _capture(context, delete, name=name, yes=True, force=True)


@when('I invoke project-delete for "{name}" with yes and short force flag')
def step_invoke_delete_short_force(context: Any, name: str) -> None:
    """Invoke 'project delete' via CliRunner using the ``-f`` short flag.

    This validates that the CLI argument parser correctly maps ``-f`` to
    ``--force`` (not ``--format``), matching the specification.
    """
    from typer.testing import CliRunner

    from cleveragents.cli.commands.project import app as project_app

    _patch_project_mod(context)
    try:
        cli_runner = CliRunner()
        result = cli_runner.invoke(project_app, ["delete", name, "--yes", "-f"])
        context._cmd_output = result.output
        context._cmd_failed = result.exit_code != 0
    finally:
        _unpatch_project_mod()


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


@then('the project cmd output should contain "{text}"')
def step_cmd_output_contains(context: Any, text: str) -> None:
    assert text.lower() in context._cmd_output.lower(), (
        f"Expected output to contain '{text}', got:\n{context._cmd_output}"
    )


@then("the project cmd should succeed")
def step_cmd_success(context: Any) -> None:
    assert not context._cmd_failed, (
        f"Expected command to succeed but it failed. Output:\n{context._cmd_output}"
    )


@then("the project cmd should fail")
def step_cmd_fail(context: Any) -> None:
    assert context._cmd_failed, (
        f"Expected command to fail but it succeeded. Output:\n{context._cmd_output}"
    )


@then("the project cmd JSON data should include deleted_at")
def step_cmd_json_has_deleted_at(context: Any) -> None:
    output = context._cmd_output.strip()
    try:
        envelope = json.loads(output)
    except json.JSONDecodeError as exc:
        raise AssertionError(
            f"Command output is not valid JSON:\n{context._cmd_output}"
        ) from exc

    data = envelope.get("data")
    assert isinstance(data, dict), (
        f"Expected envelope data to be a dict, got {type(data).__name__}: {data}"
    )
    assert "deleted_at" in data, (
        f"Expected 'deleted_at' in command JSON data, got keys {list(data.keys())}"
    )
