"""Storage adapter that puts the BW Auth accounts kit on Scout's own tables.

The kit (`app/bw_accounts.py`) owns the permission model, the guards and the
central reporting. This class owns only *where the rows live*: levels in
`app_levels`, members in `app_accounts`, instances in `projects`, per-project
grants in `project_members`.

Contract notes that are easy to get wrong:

* No method here performs authorization — the kit already did that before
  calling. Adding a check here would be a second, divergent policy.
* Usernames arrive normalized (lowercased, stripped); store them verbatim.
* A DEACTIVATED account is reported as **not a member** (D22). That is what makes
  deactivation take effect everywhere at once: `can()` goes false and
  `effective_level()` goes None on the very next request, with no separate check
  scattered through the routers.
"""

from collections.abc import Callable

from sqlalchemy import delete, func, select, update
from sqlalchemy.orm import Session

from app.models.account import Account
from app.models.level import Level
from app.models.project import Project, ProjectMember


class ScoutAccountsStore:
    """Implements the kit's store contract over Scout's schema.

    Takes a session factory rather than a session: the kit calls these methods
    at arbitrary points, so each one owns a short-lived session instead of
    holding a request-scoped one open.
    """

    def __init__(self, session_factory: Callable[[], Session]):
        self._session_factory = session_factory

    # The migration owns the schema; there is nothing for the kit to create.
    # (`ensure_schema` is optional in the contract, so it is deliberately absent.)

    # ---------------------------------------------------------------- levels

    def get_level(self, name: str) -> dict | None:
        with self._session_factory() as db:
            level = db.get(Level, (name or "").strip())
            return level.as_dict() if level else None

    def list_levels(self) -> list[dict]:
        with self._session_factory() as db:
            return [x.as_dict() for x in db.scalars(select(Level).order_by(Level.name))]

    def add_level(self, name: str, permissions: list, assignable: list) -> None:
        with self._session_factory() as db:
            db.add(Level(name=name, permissions=list(permissions), assignable=list(assignable)))
            db.commit()

    def update_level(self, name: str, permissions: list, assignable: list) -> None:
        with self._session_factory() as db:
            level = db.get(Level, name)
            if level is not None:
                level.permissions = list(permissions)
                level.assignable = list(assignable)
                db.commit()

    def delete_level(self, name: str) -> None:
        with self._session_factory() as db:
            level = db.get(Level, name)
            if level is not None:
                db.delete(level)
                db.commit()

    def count_level_usage(self, name: str) -> int:
        """Both places a level name can be referenced — the kit refuses to delete
        a level still in use, and it can be in use app-wide or on one project."""
        with self._session_factory() as db:
            app_wide = db.scalar(
                select(func.count()).select_from(Account).where(Account.level == name)
            )
            per_project = db.scalar(
                select(func.count()).select_from(ProjectMember).where(ProjectMember.level == name)
            )
            return int(app_wide or 0) + int(per_project or 0)

    # --------------------------------------------------------------- members

    def get_member(self, username: str) -> dict | None:
        with self._session_factory() as db:
            account = db.get(Account, username)
            if account is None or not account.active:
                return None  # deactivated == not a member (D22)
            grants = dict(
                db.execute(
                    select(ProjectMember.project_id, ProjectMember.level).where(
                        ProjectMember.username == username
                    )
                ).all()
            )
            return self._member_dict(account, grants)

    def list_members(self) -> list[dict]:
        """Active accounts only, so `sync_reports` never re-publishes access for
        someone who has been switched off."""
        with self._session_factory() as db:
            accounts = list(
                db.scalars(select(Account).where(Account.active.is_(True)).order_by(Account.username))
            )
            grants: dict[str, dict[str, str]] = {}
            for username, project_id, level in db.execute(
                select(ProjectMember.username, ProjectMember.project_id, ProjectMember.level)
            ).all():
                grants.setdefault(username, {})[str(project_id)] = level
            return [self._member_dict(a, grants.get(a.username, {})) for a in accounts]

    def add_member(self, username: str, level: str, all_instances: bool, added_by: str) -> None:
        with self._session_factory() as db:
            account = db.get(Account, username)
            if account is None:
                # Scout provisions accounts through a verified BW lookup, so a
                # bare row here would be one nobody can ever claim.
                raise RuntimeError(
                    f"no Scout account for {username!r}; provision it via accounts.provision first"
                )
            account.level = level
            account.all_instances = bool(all_instances)
            account.active = True
            db.commit()

    def remove_member(self, username: str) -> None:
        """Strip access without deleting history: the account is deactivated and
        every per-project grant dropped. Reviews stay attributed."""
        with self._session_factory() as db:
            db.execute(delete(ProjectMember).where(ProjectMember.username == username))
            db.execute(
                update(Account).where(Account.username == username).values(active=False)
            )
            db.commit()

    def set_member_level(self, username: str, level: str) -> None:
        with self._session_factory() as db:
            db.execute(update(Account).where(Account.username == username).values(level=level))
            db.commit()

    def set_all_instances(self, username: str, value: bool) -> None:
        with self._session_factory() as db:
            db.execute(
                update(Account)
                .where(Account.username == username)
                .values(all_instances=bool(value))
            )
            db.commit()

    # ------------------------------------------------------------- instances

    def list_instances(self) -> list[dict]:
        with self._session_factory() as db:
            return [
                {"id": str(p.id), "label": p.name}
                for p in db.scalars(select(Project).order_by(Project.name))
            ]

    def has_instance(self, instance_id: str) -> bool:
        with self._session_factory() as db:
            return db.get(Project, self._as_id(instance_id)) is not None

    def add_instance(self, instance_id: str, label: str, created_by: str) -> None:
        raise RuntimeError(
            "Projects are created through Scout's own create-project flow, not the "
            "accounts kit. Reaching this means bwa.create_instance() was called."
        )

    def set_grant(self, username: str, instance_id: str, level: str) -> None:
        project_id = self._as_id(instance_id)
        with self._session_factory() as db:
            member = db.get(ProjectMember, (project_id, username))
            if member is None:
                db.add(
                    ProjectMember(
                        project_id=project_id,
                        username=username,
                        level=level,
                        added_by=username,
                    )
                )
            else:
                member.level = level
            db.commit()

    def remove_grant(self, username: str, instance_id: str) -> None:
        with self._session_factory() as db:
            member = db.get(ProjectMember, (self._as_id(instance_id), username))
            if member is not None:
                db.delete(member)
                db.commit()

    # ----------------------------------------------------------------- utils

    @staticmethod
    def _as_id(instance_id: str) -> int:
        """Instance ids are strings in the kit and integers in Scout. A
        non-numeric id is simply an id no project has."""
        try:
            return int(instance_id)
        except (TypeError, ValueError):
            return -1

    @staticmethod
    def _member_dict(account: Account, grants: dict) -> dict:
        return {
            "username": account.username,
            "level": account.level,
            "all_instances": bool(account.all_instances),
            "grants": {str(k): v for k, v in grants.items()},
        }
