"""Step definitions for project database table migration tests.

Tests the NamespacedProjectModel and ProjectResourceLinkModel ORM models
and their corresponding database tables (projects, project_resource_links).
Uses an in-memory SQLite database with schema created via
``Base.metadata.create_all``.
"""

from __future__ import annotations

import json
from datetime import UTC, datetime
from typing import Any

from behave import given, then, when  # type: ignore[import-untyped]
from sqlalchemy import create_engine, event, inspect, text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, sessionmaker

from cleveragents.infrastructure.database.models import (
    Base,
    NamespacedProjectModel,
    ProjectResourceLinkModel,
    ResourceModel,
    ResourceTypeModel,
)


def _now_iso() -> str:
    """Return current UTC time as ISO-8601 string."""
    return datetime.now(tz=UTC).isoformat()


def _make_ulid(suffix: str = "0") -> str:
    """Create a deterministic 26-char fake ULID for testing."""
    return ("0" * (26 - len(suffix))) + suffix


def _set_sqlite_pragma_projects(dbapi_conn: Any, _connection_record: Any) -> None:
    """Enable FK enforcement on every new SQLite connection."""
    cursor = dbapi_conn.cursor()
    cursor.execute("PRAGMA foreign_keys = ON")
    cursor.close()


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


@given("a project tables in-memory database is initialized")
def step_project_tables_init_db(context: Any) -> None:
    """Create an in-memory SQLite database with all tables."""
    engine = create_engine("sqlite:///:memory:")
    event.listen(engine, "connect", _set_sqlite_pragma_projects)
    with engine.connect() as conn:
        conn.execute(text("PRAGMA foreign_keys = ON"))
        conn.commit()
    Base.metadata.create_all(engine)
    context.pt_engine = engine
    context.pt_session_factory = sessionmaker(bind=engine)
    context.pt_session = context.pt_session_factory()
    # Storage for cross-step references
    context.pt_project = None
    context.pt_link = None
    context.pt_resource = None
    context.pt_resource_id = None
    context.pt_error = None
    context.pt_domain_project = None
    context.pt_model_from_domain = None


# ---------------------------------------------------------------------------
# Table existence
# ---------------------------------------------------------------------------


@then('the project database should contain table "{table_name}"')
def step_project_table_exists(context: Any, table_name: str) -> None:
    inspector = inspect(context.pt_engine)
    tables = inspector.get_table_names()
    assert table_name in tables, f"Table {table_name} not found. Tables: {tables}"


# ---------------------------------------------------------------------------
# Index existence
# ---------------------------------------------------------------------------


@then(
    'the project database should have an index "{index_name}" on table "{table_name}"'
)
def step_project_index_exists(context: Any, index_name: str, table_name: str) -> None:
    inspector = inspect(context.pt_engine)
    indexes = inspector.get_indexes(table_name)
    index_names = [idx["name"] for idx in indexes]
    assert index_name in index_names, (
        f"Index {index_name} not found on {table_name}. Indexes: {index_names}"
    )


# ---------------------------------------------------------------------------
# Project CRUD
# ---------------------------------------------------------------------------


@when('I insert a project "{name}" with namespace "{namespace}"')
def step_insert_project(context: Any, name: str, namespace: str) -> None:
    session: Session = context.pt_session
    proj = NamespacedProjectModel(
        namespaced_name=name,
        namespace=namespace,
        tags_json="[]",
        created_at=_now_iso(),
        updated_at=_now_iso(),
    )
    session.add(proj)
    session.commit()
    context.pt_project = proj


@when(
    'I insert a described project "{name}" with namespace "{namespace}"'
    ' and description "{desc}"'
)
def step_insert_project_with_desc(
    context: Any, name: str, namespace: str, desc: str
) -> None:
    session: Session = context.pt_session
    proj = NamespacedProjectModel(
        namespaced_name=name,
        namespace=namespace,
        description=desc,
        tags_json="[]",
        created_at=_now_iso(),
        updated_at=_now_iso(),
    )
    session.add(proj)
    session.commit()
    context.pt_project = proj


@when('I insert a project "{name}" with JSON fields')
def step_insert_project_with_json(context: Any, name: str) -> None:
    session: Session = context.pt_session
    proj = NamespacedProjectModel(
        namespaced_name=name,
        namespace="local",
        invariants_json=json.dumps(["invariant-1", "invariant-2"]),
        context_policy_json=json.dumps({"max_file_size": 500000}),
        tags_json=json.dumps(["tag-a", "tag-b"]),
        created_at=_now_iso(),
        updated_at=_now_iso(),
    )
    session.add(proj)
    session.commit()
    context.pt_project = proj


@then('I can retrieve project "{name}"')
def step_retrieve_project(context: Any, name: str) -> None:
    session: Session = context.pt_session
    proj = session.get(NamespacedProjectModel, name)
    assert proj is not None, f"Project {name} not found"
    context.pt_project = proj


@then('the retrieved project namespace is "{expected}"')
def step_project_namespace(context: Any, expected: str) -> None:
    assert context.pt_project.namespace == expected


@then("the retrieved project description is empty")
def step_project_description_empty(context: Any) -> None:
    assert context.pt_project.description is None


@then('the retrieved project description is "{expected}"')
def step_project_description(context: Any, expected: str) -> None:
    assert context.pt_project.description == expected


@then('the retrieved project tags_json is "{expected}"')
def step_project_tags_json(context: Any, expected: str) -> None:
    assert context.pt_project.tags_json == expected


@then("the project invariants_json round-trips correctly")
def step_project_invariants_roundtrip(context: Any) -> None:
    data = json.loads(context.pt_project.invariants_json)
    assert data == ["invariant-1", "invariant-2"]


@then("the project context_policy_json round-trips correctly")
def step_project_context_policy_roundtrip(context: Any) -> None:
    data = json.loads(context.pt_project.context_policy_json)
    assert data == {"max_file_size": 500000}


@then("the project tags_json round-trips as a list")
def step_project_tags_roundtrip(context: Any) -> None:
    data = json.loads(context.pt_project.tags_json)
    assert data == ["tag-a", "tag-b"]


@then('inserting a duplicate project "{name}" raises an integrity error')
def step_duplicate_project_error(context: Any, name: str) -> None:
    session: Session = context.pt_session
    dup = NamespacedProjectModel(
        namespaced_name=name,
        namespace="local",
        tags_json="[]",
        created_at=_now_iso(),
        updated_at=_now_iso(),
    )
    session.add(dup)
    try:
        session.commit()
        raise AssertionError("Expected IntegrityError for duplicate project")
    except IntegrityError:
        session.rollback()


# ---------------------------------------------------------------------------
# Resource + type setup helpers for link tests
# ---------------------------------------------------------------------------


@given('a resource type "{name}" exists in project db')
def step_resource_type_exists_project(context: Any, name: str) -> None:
    session: Session = context.pt_session
    existing = session.get(ResourceTypeModel, name)
    if existing is not None:
        return
    namespace = name.split("/")[0]
    rt = ResourceTypeModel()
    rt.name = name
    rt.namespace = namespace
    rt.resource_kind = "physical"
    rt.user_addable = True
    rt.created_at = _now_iso()
    rt.updated_at = _now_iso()
    session.add(rt)
    session.commit()


@given('a resource "{name}" of type "{type_name}" exists in project db')
def step_resource_exists_project(context: Any, name: str, type_name: str) -> None:
    session: Session = context.pt_session
    # Generate a deterministic ULID from the resource name
    ulid = _make_ulid(str(abs(hash(name)) % 10**20).zfill(20))
    existing = session.get(ResourceModel, ulid)
    if existing is not None:
        context.pt_resource_id = ulid
        return
    r = ResourceModel()
    r.resource_id = ulid
    r.namespaced_name = name
    r.namespace = name.split("/")[0]
    r.type_name = type_name
    r.resource_kind = "physical"
    r.location = f"/tmp/{name}"
    r.created_at = _now_iso()
    r.updated_at = _now_iso()
    session.add(r)
    session.commit()
    context.pt_resource_id = ulid


@given('a project "{name}" exists in project db')
def step_project_exists_project(context: Any, name: str) -> None:
    session: Session = context.pt_session
    existing = session.get(NamespacedProjectModel, name)
    if existing is not None:
        return
    namespace = name.split("/")[0]
    proj = NamespacedProjectModel(
        namespaced_name=name,
        namespace=namespace,
        tags_json="[]",
        created_at=_now_iso(),
        updated_at=_now_iso(),
    )
    session.add(proj)
    session.commit()


# ---------------------------------------------------------------------------
# Project resource links
# ---------------------------------------------------------------------------


@when(
    'I create a project resource link for project "{project}" to resource "{resource}"'
)
def step_create_link(context: Any, project: str, resource: str) -> None:
    session: Session = context.pt_session
    # Resolve resource_id from namespaced_name
    res = (
        session.query(ResourceModel)
        .filter(ResourceModel.namespaced_name == resource)
        .first()
    )
    assert res is not None, f"Resource {resource} not found"
    link_id = _make_ulid(str(abs(hash(f"{project}:{resource}")) % 10**20).zfill(20))
    link = ProjectResourceLinkModel(
        link_id=link_id,
        project_name=project,
        resource_id=res.resource_id,
        read_only=False,
        created_at=_now_iso(),
    )
    session.add(link)
    session.commit()
    context.pt_link = link


@when('I create a project resource link with alias "{alias}" and read_only {ro}')
def step_create_link_with_alias(context: Any, alias: str, ro: str) -> None:
    session: Session = context.pt_session
    # Use the most recently created resource and project
    res = session.query(ResourceModel).first()
    proj = session.query(NamespacedProjectModel).first()
    assert res is not None and proj is not None
    link_id = _make_ulid(str(abs(hash(f"alias:{alias}")) % 10**20).zfill(20))
    link = ProjectResourceLinkModel(
        link_id=link_id,
        project_name=proj.namespaced_name,
        resource_id=res.resource_id,
        alias=alias,
        read_only=ro.lower() == "true",
        created_at=_now_iso(),
    )
    session.add(link)
    session.commit()
    context.pt_link = link


@then("the project resource link can be retrieved")
def step_link_retrievable(context: Any) -> None:
    session: Session = context.pt_session
    link = session.get(ProjectResourceLinkModel, context.pt_link.link_id)
    assert link is not None, "Link not found"
    context.pt_link = link


@then('the project resource link project_name is "{expected}"')
def step_link_project_name(context: Any, expected: str) -> None:
    assert context.pt_link.project_name == expected


@then("the project resource link read_only is false")
def step_link_read_only_false(context: Any) -> None:
    assert context.pt_link.read_only is False


@then("the project resource link read_only is true")
def step_link_read_only_true(context: Any) -> None:
    assert context.pt_link.read_only is True


@then('the project resource link alias is "{expected}"')
def step_link_alias(context: Any, expected: str) -> None:
    assert context.pt_link.alias == expected


@then(
    'creating a duplicate link for project "{project}" to resource "{resource}"'
    " raises an integrity error"
)
def step_duplicate_link_error(context: Any, project: str, resource: str) -> None:
    session: Session = context.pt_session
    res = (
        session.query(ResourceModel)
        .filter(ResourceModel.namespaced_name == resource)
        .first()
    )
    assert res is not None
    dup_link_id = _make_ulid("99999999999999999999999999"[:26])
    dup = ProjectResourceLinkModel(
        link_id=dup_link_id,
        project_name=project,
        resource_id=res.resource_id,
        read_only=False,
        created_at=_now_iso(),
    )
    session.add(dup)
    try:
        session.commit()
        raise AssertionError("Expected IntegrityError for duplicate link")
    except IntegrityError:
        session.rollback()


# ---------------------------------------------------------------------------
# Cascade / restrict delete tests
# ---------------------------------------------------------------------------


@given('a project resource link exists for "{project}" to "{resource}"')
def step_link_exists(context: Any, project: str, resource: str) -> None:
    session: Session = context.pt_session
    res = (
        session.query(ResourceModel)
        .filter(ResourceModel.namespaced_name == resource)
        .first()
    )
    assert res is not None
    link_id = _make_ulid(
        str(abs(hash(f"exist:{project}:{resource}")) % 10**20).zfill(20)
    )
    link = ProjectResourceLinkModel(
        link_id=link_id,
        project_name=project,
        resource_id=res.resource_id,
        read_only=False,
        created_at=_now_iso(),
    )
    session.add(link)
    session.commit()
    context.pt_link = link


@when('I delete the project "{name}"')
def step_delete_project(context: Any, name: str) -> None:
    session: Session = context.pt_session
    proj = session.get(NamespacedProjectModel, name)
    assert proj is not None
    session.delete(proj)
    session.commit()


@then('the project resource link for "{project}" should not exist')
def step_link_should_not_exist(context: Any, project: str) -> None:
    session: Session = context.pt_session
    links = (
        session.query(ProjectResourceLinkModel)
        .filter(ProjectResourceLinkModel.project_name == project)
        .all()
    )
    assert len(links) == 0, f"Expected 0 links, found {len(links)}"


@then('deleting the linked resource "{resource}" raises an integrity error')
def step_delete_linked_resource_restricted(context: Any, resource: str) -> None:
    session: Session = context.pt_session
    res = (
        session.query(ResourceModel)
        .filter(ResourceModel.namespaced_name == resource)
        .first()
    )
    assert res is not None
    session.delete(res)
    try:
        session.commit()
        raise AssertionError("Expected IntegrityError for RESTRICT delete")
    except IntegrityError:
        session.rollback()


# ---------------------------------------------------------------------------
# Domain conversion
# ---------------------------------------------------------------------------


@then("converting the project to domain returns a NamespacedProject")
def step_to_domain(context: Any) -> None:
    from cleveragents.domain.models.core.project import NamespacedProject

    domain = context.pt_project.to_domain()
    assert isinstance(domain, NamespacedProject)
    context.pt_domain_project = domain


@then('the domain project name is "{expected}"')
def step_domain_project_name(context: Any, expected: str) -> None:
    assert context.pt_domain_project.name == expected


@then('the domain project namespace is "{expected}"')
def step_domain_project_namespace(context: Any, expected: str) -> None:
    assert context.pt_domain_project.namespace == expected


@then('the domain project description is "{expected}"')
def step_domain_project_description(context: Any, expected: str) -> None:
    assert context.pt_domain_project.description == expected


@when("I create a NamespacedProject domain model")
def step_create_domain_project(context: Any) -> None:
    from cleveragents.domain.models.core.project import NamespacedProject

    context.pt_domain_project = NamespacedProject(
        name="from-domain-test",
        namespace="local",
        description="Created from domain",
    )


@when("I convert it to a NamespacedProjectModel via from_domain")
def step_convert_from_domain(context: Any) -> None:
    model = NamespacedProjectModel.from_domain(context.pt_domain_project)
    context.pt_model_from_domain = model


@then('the model namespaced_name is "{expected}"')
def step_model_namespaced_name(context: Any, expected: str) -> None:
    assert context.pt_model_from_domain.namespaced_name == expected


@then('the model namespace is "{expected}"')
def step_model_namespace(context: Any, expected: str) -> None:
    assert context.pt_model_from_domain.namespace == expected
