"""Async endpoint builders for CRUD operations."""

import inspect
from collections.abc import Callable
from types import GenericAlias
from typing import TYPE_CHECKING, Annotated, TypedDict, cast

from fastapi import Depends, Query, status
from pydantic import BaseModel
from sqlalchemy import inspect as sa_inspect
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import RelationshipDirection
from sqlmodel import SQLModel, select
from sqlmodel.ext.asyncio.session import AsyncSession

from auen._endpoints import (
    RouterContext,
    _build_filter_model,
    _build_identifier_schema,
    _build_page_model,
    _make_user_dep,
)
from auen._nested_mutations import apply_nested_creates, apply_nested_mutations
from auen._query import apply_filters, apply_sorting
from auen.config import FilterConfig, Operation
from auen.exceptions import (
    ConfigurationError,
    ConflictError,
    ForbiddenError,
    NotFoundError,
)
from auen.schemas import derive_schema_container
from auen.types import (
    ListResponse,
    PKValue,
    ResponseModel,
    User,
)


class OpKwargs(TypedDict):
    include_in_schema: bool
    operation_id: str


def _set_param_annotations(
    func: Callable[..., object], annotations: dict[str, object]
) -> None:
    """Update parameter annotations and rebuild __signature__ to match."""
    func.__annotations__.update(annotations)
    sig = inspect.signature(func)
    new_params = []
    for p in sig.parameters.values():
        if p.name in annotations:
            new_params.append(p.replace(annotation=annotations[p.name]))
        else:
            new_params.append(p)
    func.__signature__ = sig.replace(parameters=new_params)  # type: ignore[attr-defined]


def _rename_path_param(
    func: Callable[..., object], old_name: str, new_name: str, annotation: object
) -> None:
    """Rename a function parameter so FastAPI uses `new_name` as the path param.

    Replaces a VAR_KEYWORD (**kwargs) parameter with a named POSITIONAL_OR_KEYWORD
    parameter in the function's ``__signature__``. The actual function must use
    ``**kwargs`` to receive the value at runtime.
    """
    sig = getattr(func, "__signature__", None) or inspect.signature(func)
    new_params: list[inspect.Parameter] = []
    for p in sig.parameters.values():
        if p.name == old_name:
            new_params.append(
                inspect.Parameter(
                    new_name,
                    kind=inspect.Parameter.POSITIONAL_OR_KEYWORD,
                    annotation=annotation,
                )
            )
        else:
            new_params.append(p)
    func.__signature__ = sig.replace(parameters=new_params)  # type: ignore[attr-defined]
    func.__annotations__.pop(old_name, None)
    func.__annotations__[new_name] = annotation


def _op_kwargs(ctx: RouterContext, op: Operation) -> OpKwargs:
    """Build shared route kwargs for include_in_schema and operation_id."""
    return {
        "include_in_schema": op in ctx.include_in_schema,
        "operation_id": f"{ctx.operation_id_prefix}_{op.value}",
    }


def _resolve_relation_fk_field(
    model: type[SQLModel],
    relation_name: str,
    related_model: type[SQLModel],
) -> str:
    mapper = sa_inspect(model)
    if relation_name not in mapper.relationships:
        msg = f"Unknown relationship '{relation_name}' on model {model.__name__}"
        raise ConfigurationError(msg)
    relation = mapper.relationships[relation_name]
    if relation.direction != RelationshipDirection.MANYTOONE:
        msg = (
            f"Nested writes support only MANYTOONE relations. "
            f"{model.__name__}.{relation_name} is {relation.direction.name}"
        )
        raise ConfigurationError(msg)
    if relation.mapper.class_ is not related_model:
        msg = (
            f"Nested relation '{relation_name}' model mismatch: "
            f"{relation.mapper.class_.__name__} != {related_model.__name__}"
        )
        raise ConfigurationError(msg)
    if len(relation.local_columns) != 1:
        msg = (
            f"Nested writes require exactly one local FK column for "
            f"{model.__name__}.{relation_name}"
        )
        raise ConfigurationError(msg)
    return next(iter(relation.local_columns)).name


def _build_nested_update_schema(ctx: RouterContext) -> type[BaseModel]:
    if ctx.schemas.nested_update is not None:
        return ctx.schemas.nested_update
    return derive_schema_container(
        ctx.model,
        nested_writes=ctx.nested_writes,
    ).nested_update


def _build_nested_create_schema(ctx: RouterContext) -> type[BaseModel]:
    if ctx.schemas.nested_create is not None:
        return ctx.schemas.nested_create
    return derive_schema_container(
        ctx.model,
        nested_writes=ctx.nested_writes,
    ).nested_create


def add_async_nested_create(ctx: RouterContext) -> None:
    """Register the async NESTED CREATE endpoint."""
    create_schema = _build_nested_create_schema(ctx)
    read_schema = ctx.schemas.read
    user_dep = _make_user_dep(ctx.auth, Operation.NESTED_CREATE)
    model = ctx.model
    pol = ctx.policy

    @ctx.router.post(
        "/nested",
        response_model=read_schema,
        status_code=status.HTTP_201_CREATED,
        **_op_kwargs(ctx, Operation.NESTED_CREATE),
    )
    async def nested_create_resource(
        obj_in: BaseModel,
        session: Annotated[AsyncSession, Depends(ctx.session_dep)],
        user: Annotated[User, user_dep],
    ) -> SQLModel:
        if not pol.can_create(user, obj_in):
            raise ForbiddenError()

        hooks = ctx.hooks
        if hooks.before_create:
            await hooks.before_create(session, obj_in, user)

        create_data = obj_in.model_dump(exclude_unset=True)
        db_obj = await apply_nested_creates(
            session=session,
            user=user,
            model=model,
            create_data=cast(dict[str, object], create_data),
            nested_writes=ctx.nested_writes,
            nested_policy_registry=ctx.nested_policy_registry,
        )
        try:
            await session.commit()
        except IntegrityError as e:
            await session.rollback()
            raise ConflictError(detail="Integrity constraint violation") from e
        await session.refresh(db_obj)

        if hooks.after_create:
            await hooks.after_create(session, db_obj, user)
        return db_obj

    _set_param_annotations(nested_create_resource, {"obj_in": create_schema})


def add_async_create(ctx: RouterContext) -> None:
    """Register the async CREATE endpoint."""
    create_schema = ctx.schemas.create
    read_schema = ctx.schemas.read
    user_dep = _make_user_dep(ctx.auth, Operation.CREATE)
    model = ctx.model
    repo_factory = ctx.repository_factory

    @ctx.router.post(
        "/",
        response_model=read_schema,
        status_code=status.HTTP_201_CREATED,
        **_op_kwargs(ctx, Operation.CREATE),
    )
    async def create_resource(
        obj_in: BaseModel,
        session: Annotated[AsyncSession, Depends(ctx.session_dep)],
        user: Annotated[User, user_dep],
    ) -> SQLModel:
        pol = ctx.policy
        if not pol.can_create(user, obj_in):
            raise ForbiddenError()
        hooks = ctx.hooks
        if hooks.before_create:
            await hooks.before_create(session, obj_in, user)
        repo = repo_factory(model)
        db_obj = await repo.create(session, obj_in=obj_in)
        if hooks.after_create:
            await hooks.after_create(session, db_obj, user)
        return db_obj

    _set_param_annotations(create_resource, {"obj_in": create_schema})


async def _list_core(
    *,
    ctx: RouterContext,
    session: AsyncSession,
    user: User,
    offset: int,
    limit: int,
    sort: str | None,
    filter_params: BaseModel | None,
) -> ListResponse:
    """Shared LIST logic for all pagination styles."""
    model = ctx.model
    filters = ctx.filters
    pol = ctx.policy
    pg = ctx.pagination

    query = select(model)
    query = pol.filter_list_query(user, query)
    if filters and filter_params is not None:
        query = apply_filters(
            query,
            model,
            filters,
            filter_params.model_dump(exclude_none=True),
        )
    query = apply_sorting(query, model, filters, sort)
    repo = ctx.repository_factory(model)
    items = list(await repo.list(session, query=query, offset=offset, limit=limit))
    if pg.include_total:
        total = await repo.count(session, query=query)
        return {"items": items, "total": total, "limit": limit, "offset": offset}
    return items


def add_async_list(ctx: RouterContext) -> None:
    """Register the async LIST endpoint."""
    read_schema = ctx.schemas.read
    user_dep = _make_user_dep(ctx.auth, Operation.LIST)
    filters = ctx.filters
    pg = ctx.pagination

    response_model: ResponseModel
    if TYPE_CHECKING:
        response_model = cast(ResponseModel, list[BaseModel])
    else:
        response_model = cast(ResponseModel, list[read_schema])
    if pg.include_total:
        response_model = _build_page_model(read_schema)

    _sort_query = Query(default=None)

    # Build filter model: always present (empty if no filters configured)
    effective_filters = filters or FilterConfig()
    filter_model = _build_filter_model(ctx.model, effective_filters)
    filter_dep = Depends(filter_model)

    route_kwargs = _op_kwargs(ctx, Operation.LIST)

    if pg.style == "page":
        _page_query = Query(default=1, ge=1)
        _size_query = Query(default=pg.default_limit, ge=1, le=pg.max_limit)

        @ctx.router.get("/", response_model=response_model, **route_kwargs)
        async def list_resources(
            session: Annotated[AsyncSession, Depends(ctx.session_dep)],
            user: Annotated[User, user_dep],
            filter_params: Annotated[BaseModel, filter_dep],
            page: int = _page_query,
            size: int = _size_query,
            sort: str | None = _sort_query,
        ) -> ListResponse:
            offset = (page - 1) * size
            return await _list_core(
                ctx=ctx,
                session=session,
                user=user,
                offset=offset,
                limit=size,
                sort=sort,
                filter_params=filter_params if filters else None,
            )

    else:
        _offset_query = Query(default=0, ge=0)
        _limit_query = Query(default=pg.default_limit, ge=1, le=pg.max_limit)

        @ctx.router.get("/", response_model=response_model, **route_kwargs)
        async def list_resources(
            session: Annotated[AsyncSession, Depends(ctx.session_dep)],
            user: Annotated[User, user_dep],
            filter_params: Annotated[BaseModel, filter_dep],
            offset: int = _offset_query,
            limit: int = _limit_query,
            sort: str | None = _sort_query,
        ) -> ListResponse:
            return await _list_core(
                ctx=ctx,
                session=session,
                user=user,
                offset=offset,
                limit=limit,
                sort=sort,
                filter_params=filter_params if filters else None,
            )


def add_async_read(ctx: RouterContext) -> None:
    """Register the async READ endpoint."""
    read_schema = ctx.schemas.read
    user_dep = _make_user_dep(ctx.auth, Operation.READ)
    model = ctx.model
    model_name = ctx.model_name
    repo_factory = ctx.repository_factory
    pol = ctx.policy
    pk_name = ctx.pk_name
    pk_type = ctx.pk_type

    @ctx.router.get(
        "/{" + pk_name + "}",
        response_model=read_schema,
        **_op_kwargs(ctx, Operation.READ),
    )
    async def read_resource(
        session: Annotated[AsyncSession, Depends(ctx.session_dep)],
        user: Annotated[User, user_dep],
        **path_params: PKValue,
    ) -> SQLModel:
        pk_value = path_params[pk_name]
        repo = repo_factory(model)
        db_obj = await repo.get(session, pk_value)
        if db_obj is None:
            raise NotFoundError(model_name, pk_name, pk_value)
        if not pol.can_read(user, db_obj):
            raise ForbiddenError()
        return db_obj

    _rename_path_param(read_resource, "path_params", pk_name, pk_type)


def add_async_update(ctx: RouterContext) -> None:
    """Register the async UPDATE endpoint."""
    update_schema = ctx.schemas.update
    read_schema = ctx.schemas.read
    user_dep = _make_user_dep(ctx.auth, Operation.UPDATE)
    model = ctx.model
    model_name = ctx.model_name
    repo_factory = ctx.repository_factory
    pol = ctx.policy
    pk_name = ctx.pk_name
    pk_type = ctx.pk_type

    @ctx.router.patch(
        "/{" + pk_name + "}",
        response_model=read_schema,
        **_op_kwargs(ctx, Operation.UPDATE),
    )
    async def update_resource(
        obj_in: BaseModel,
        session: Annotated[AsyncSession, Depends(ctx.session_dep)],
        user: Annotated[User, user_dep],
        **path_params: PKValue,
    ) -> SQLModel:
        pk_value = path_params[pk_name]
        repo = repo_factory(model)
        db_obj = await repo.get(session, pk_value)
        if db_obj is None:
            raise NotFoundError(model_name, pk_name, pk_value)
        if not pol.can_update(user, db_obj, obj_in):
            raise ForbiddenError()
        hooks = ctx.hooks
        if hooks.before_update:
            await hooks.before_update(session, db_obj, user)
        result = await repo.update(session, db_obj=db_obj, obj_in=obj_in)
        if hooks.after_update:
            await hooks.after_update(session, result, user)
        return result

    _rename_path_param(update_resource, "path_params", pk_name, pk_type)
    _set_param_annotations(update_resource, {"obj_in": update_schema})


def add_async_nested_update(ctx: RouterContext) -> None:
    """Register the async NESTED UPDATE endpoint."""
    update_schema = _build_nested_update_schema(ctx)
    read_schema = ctx.schemas.read
    user_dep = _make_user_dep(ctx.auth, Operation.NESTED_UPDATE)
    model = ctx.model
    model_name = ctx.model_name
    repo_factory = ctx.repository_factory
    pol = ctx.policy
    pk_name = ctx.pk_name
    pk_type = ctx.pk_type

    @ctx.router.patch(
        "/{" + pk_name + "}/nested",
        response_model=read_schema,
        **_op_kwargs(ctx, Operation.NESTED_UPDATE),
    )
    async def nested_update_resource(
        obj_in: BaseModel,
        session: Annotated[AsyncSession, Depends(ctx.session_dep)],
        user: Annotated[User, user_dep],
        **path_params: PKValue,
    ) -> SQLModel:
        pk_value = path_params[pk_name]
        repo = repo_factory(model)
        db_obj = await repo.get(session, pk_value)
        if db_obj is None:
            raise NotFoundError(model_name, pk_name, pk_value)
        if not pol.can_update(user, db_obj, obj_in):
            raise ForbiddenError()

        hooks = ctx.hooks
        if hooks.before_update:
            await hooks.before_update(session, db_obj, user)

        update_data = obj_in.model_dump(exclude_unset=True)
        await apply_nested_mutations(
            session=session,
            user=user,
            model=model,
            db_obj=db_obj,
            update_data=cast(dict[str, object], update_data),
            nested_writes=ctx.nested_writes,
            nested_policy_registry=ctx.nested_policy_registry,
        )

        update_obj = ctx.schemas.update.model_validate(update_data)
        result = await repo.update(session, db_obj=db_obj, obj_in=update_obj)
        if hooks.after_update:
            await hooks.after_update(session, result, user)
        return result

    _rename_path_param(nested_update_resource, "path_params", pk_name, pk_type)
    _set_param_annotations(nested_update_resource, {"obj_in": update_schema})


def add_async_delete(ctx: RouterContext) -> None:
    """Register the async DELETE endpoint."""
    read_schema = ctx.schemas.read
    user_dep = _make_user_dep(ctx.auth, Operation.DELETE)
    model = ctx.model
    model_name = ctx.model_name
    repo_factory = ctx.repository_factory
    pol = ctx.policy
    pk_name = ctx.pk_name
    pk_type = ctx.pk_type

    @ctx.router.delete(
        "/{" + pk_name + "}",
        response_model=read_schema,
        **_op_kwargs(ctx, Operation.DELETE),
    )
    async def delete_resource(
        session: Annotated[AsyncSession, Depends(ctx.session_dep)],
        user: Annotated[User, user_dep],
        **path_params: PKValue,
    ) -> SQLModel:
        pk_value = path_params[pk_name]
        repo = repo_factory(model)
        db_obj = await repo.get(session, pk_value)
        if db_obj is None:
            raise NotFoundError(model_name, pk_name, pk_value)
        if not pol.can_delete(user, db_obj):
            raise ForbiddenError()
        hooks = ctx.hooks
        if hooks.before_delete:
            await hooks.before_delete(session, db_obj, user)
        await repo.delete(session, db_obj=db_obj)
        if hooks.after_delete:
            await hooks.after_delete(session, db_obj, user)
        return db_obj

    _rename_path_param(delete_resource, "path_params", pk_name, pk_type)


def add_async_bulk_create(ctx: RouterContext) -> None:
    """Register the async BULK CREATE endpoint."""
    create_schema = ctx.schemas.create
    read_schema = ctx.schemas.read
    user_dep = _make_user_dep(ctx.auth, Operation.BULK_CREATE)
    model = ctx.model
    repo_factory = ctx.repository_factory

    if TYPE_CHECKING:
        bulk_response_model = list[BaseModel]
    else:
        bulk_response_model = list[read_schema]

    @ctx.router.post(
        "/bulk",
        response_model=bulk_response_model,
        status_code=status.HTTP_201_CREATED,
        **_op_kwargs(ctx, Operation.BULK_CREATE),
    )
    async def bulk_create(
        objects_in: list[BaseModel],
        session: Annotated[AsyncSession, Depends(ctx.session_dep)],
        user: Annotated[User, user_dep],
    ) -> list[SQLModel]:
        pol = ctx.policy
        for obj in objects_in:
            if not pol.can_create(user, obj):
                raise ForbiddenError()
        repo = repo_factory(model)
        return list(await repo.bulk_create(session, objects_in=objects_in))

    _set_param_annotations(bulk_create, {"objects_in": GenericAlias(list, create_schema)})


def add_async_bulk_update(ctx: RouterContext) -> None:
    """Register the async BULK UPDATE endpoint."""
    read_schema = ctx.schemas.read
    user_dep = _make_user_dep(ctx.auth, Operation.BULK_UPDATE)
    model = ctx.model
    model_name = ctx.model_name
    repo_factory = ctx.repository_factory
    pol = ctx.policy
    pk_name = ctx.pk_name

    if TYPE_CHECKING:
        bulk_update_response = list[BaseModel]
    else:
        bulk_update_response = list[read_schema]

    @ctx.router.patch(
        "/bulk",
        response_model=bulk_update_response,
        **_op_kwargs(ctx, Operation.BULK_UPDATE),
    )
    async def bulk_update(
        items_in: list[BaseModel],
        session: Annotated[AsyncSession, Depends(ctx.session_dep)],
        user: Annotated[User, user_dep],
    ) -> list[SQLModel]:
        repo = repo_factory(model)
        pairs: list[tuple[SQLModel, BaseModel]] = []
        for item in items_in:
            pk = getattr(item, pk_name)
            db_obj = await repo.get(session, pk)
            if db_obj is None:
                raise NotFoundError(model_name, pk_name, pk)
            if not pol.can_update(user, db_obj, item):
                raise ForbiddenError()
            update_data = item.model_dump(exclude={pk_name}, exclude_unset=True)
            update_obj = ctx.schemas.update.model_validate(update_data)
            pairs.append((db_obj, update_obj))
        return list(await repo.bulk_update(session, items=pairs))

    _set_param_annotations(
        bulk_update,
        {"items_in": GenericAlias(list, read_schema)},
    )


def add_async_bulk_delete(ctx: RouterContext) -> None:
    """Register the async BULK DELETE endpoint."""
    read_schema = ctx.schemas.read
    user_dep = _make_user_dep(ctx.auth, Operation.BULK_DELETE)
    model = ctx.model
    model_name = ctx.model_name
    repo_factory = ctx.repository_factory
    pol = ctx.policy
    pk_name = ctx.pk_name
    pk_type = ctx.pk_type

    identifier_schema = _build_identifier_schema(model_name, pk_name, pk_type)

    if TYPE_CHECKING:
        bulk_delete_response = list[BaseModel]
    else:
        bulk_delete_response = list[read_schema]

    @ctx.router.delete(
        "/bulk",
        response_model=bulk_delete_response,
        **_op_kwargs(ctx, Operation.BULK_DELETE),
    )
    async def bulk_delete(
        items_in: list[BaseModel],
        session: Annotated[AsyncSession, Depends(ctx.session_dep)],
        user: Annotated[User, user_dep],
    ) -> list[SQLModel]:
        repo = repo_factory(model)
        db_objects: list[SQLModel] = []
        for item in items_in:
            pk = getattr(item, pk_name)
            db_obj = await repo.get(session, pk)
            if db_obj is None:
                raise NotFoundError(model_name, pk_name, pk)
            if not pol.can_delete(user, db_obj):
                raise ForbiddenError()
            db_objects.append(db_obj)
        await repo.bulk_delete(session, db_objects=db_objects)
        return db_objects

    _set_param_annotations(
        bulk_delete,
        {"items_in": GenericAlias(list, identifier_schema)},
    )
