"""Migration tests for maziggy/bambuddy-security #2 — read permission OWN/ALL backfill.

Pre-fix, ARCHIVES_READ / LIBRARY_READ / QUEUE_READ were flat "read all" flags.
Post-fix they split into OWN/ALL. The migration in seed_default_groups must:

  1. Rename legacy `archives:read` etc to `archives:read_all` on Administrators
     and to `archives:read_own` on every other role (fail-closed default).
  2. Backfill `_own` AND `_all` variants for the Administrators group on upgrade
     so an upgraded install matches a fresh install's permission set.
  3. Backfill `_own` variants for Operators and Viewers so they keep read access
     even if their stored row didn't carry the legacy flag.

These regressions are the failure shape Maziggy hit on a live upgrade — the
admin role ended up missing queue:read_own AND queue:read after migration.
"""

import pytest
from httpx import AsyncClient
from sqlalchemy import select

from backend.app.core import database as _database_module
from backend.app.core.database import seed_default_groups
from backend.app.models.group import Group

_READ_FLAGS = frozenset(
    {
        "archives:read",
        "archives:read_own",
        "archives:read_all",
        "library:read",
        "library:read_own",
        "library:read_all",
        "queue:read",
        "queue:read_own",
        "queue:read_all",
    }
)


async def _strip_and_set(group_name: str, extra: list[str] | None = None) -> None:
    """Strip every read flag from ``group_name`` then add ``extra`` flags.

    Simulates a pre-migration state where the group either had only the
    legacy flat permission (set ``extra=['archives:read']``) or no read
    permission at all (set ``extra=None``).
    """
    async with _database_module.async_session() as session:
        grp = (await session.execute(select(Group).where(Group.name == group_name))).scalar_one_or_none()
        assert grp is not None, f"group {group_name} not pre-seeded"
        stripped = [p for p in (grp.permissions or []) if p not in _READ_FLAGS]
        stripped.extend(extra or [])
        grp.permissions = stripped
        await session.commit()


async def _get_perms(group_name: str) -> set[str]:
    async with _database_module.async_session() as session:
        grp = (await session.execute(select(Group).where(Group.name == group_name))).scalar_one_or_none()
        assert grp is not None
        return set(grp.permissions or [])


# Note: ``async_client`` is depended upon (even though unused) so pytest-asyncio
# uses the same event loop the conftest fixture uses for async_session(). Without
# it, calling ``async_session()`` twice in one test trips an asyncpg
# "got Future attached to a different loop" RuntimeError.


class TestReadPermissionMigration:
    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_legacy_archives_read_renamed_to_all_for_administrators(self, async_client: AsyncClient):
        """Existing Administrators group with legacy `archives:read` → gets
        `archives:read_all` after seed_default_groups runs, and gets the
        `_own` companion backfilled too."""
        await seed_default_groups()
        await _strip_and_set("Administrators", extra=["archives:read"])

        await seed_default_groups()

        perms = await _get_perms("Administrators")
        # Rename happened: legacy renamed to _all
        assert "archives:read_all" in perms
        # Backfill also added _own so fresh install and upgraded install match
        assert "archives:read_own" in perms

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_administrators_backfill_adds_all_six_read_flags(self, async_client: AsyncClient):
        """Even with NO legacy flags present, Administrators ends up with both
        OWN and ALL variants for archives / library / queue after the backfill
        pass. This is the case Maziggy hit — admin missing `queue:read_own`
        after upgrade."""
        await seed_default_groups()
        await _strip_and_set("Administrators")

        await seed_default_groups()

        perms = await _get_perms("Administrators")
        for needed in (
            "archives:read_own",
            "archives:read_all",
            "library:read_own",
            "library:read_all",
            "queue:read_own",
            "queue:read_all",
        ):
            assert needed in perms, f"{needed} must be backfilled for Administrators"

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_operators_backfill_adds_own_read_flags(self, async_client: AsyncClient):
        """Operators with no read flags get the _OWN variants backfilled
        (fail-closed — no _ALL)."""
        await seed_default_groups()
        await _strip_and_set("Operators")

        await seed_default_groups()

        perms = await _get_perms("Operators")
        assert "archives:read_own" in perms
        assert "library:read_own" in perms
        assert "queue:read_own" in perms
        assert "archives:read_all" not in perms
        assert "library:read_all" not in perms
        assert "queue:read_all" not in perms

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_operators_legacy_archives_read_renamed_to_own(self, async_client: AsyncClient):
        """Pre-PR Operators with legacy `archives:read` get the _OWN rename
        (fail-closed — close the IDOR, the operator can re-request _ALL via
        admin if cross-user visibility is genuinely needed)."""
        await seed_default_groups()
        await _strip_and_set("Operators", extra=["archives:read"])

        await seed_default_groups()

        perms = await _get_perms("Operators")
        assert "archives:read_own" in perms
        assert "archives:read_all" not in perms

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_administrators_legacy_archives_read_retained(self, async_client: AsyncClient):
        """Admin keeps the LEGACY `archives:read` flag — the frontend gates
        download / preview UI on it (ArchivesPage / FileManagerPage), and
        removing it on rename was leaving admin with no visible download
        buttons after upgrade. The new API gates use the _ALL variant which
        the backfill also ensures is present."""
        await seed_default_groups()
        await _strip_and_set("Administrators", extra=["archives:read"])

        await seed_default_groups()

        perms = await _get_perms("Administrators")
        # Both the legacy flag (for the UI) and the _all variant (for the API)
        # must coexist on admin.
        assert "archives:read" in perms
        assert "archives:read_all" in perms
        assert "archives:read_own" in perms

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_administrators_backfill_adds_legacy_read_flags(self, async_client: AsyncClient):
        """Admin with NO read flags at all (hand-edited or stripped role) ends
        up with the legacy `archives:read` / `queue:read` / `library:read`
        backfilled — so the UI gates work — alongside the OWN/ALL split."""
        await seed_default_groups()
        await _strip_and_set("Administrators")

        await seed_default_groups()

        perms = await _get_perms("Administrators")
        for needed in (
            "archives:read",
            "library:read",
            "queue:read",
            "archives:read_own",
            "archives:read_all",
            "library:read_own",
            "library:read_all",
            "queue:read_own",
            "queue:read_all",
        ):
            assert needed in perms, f"{needed} must be backfilled for Administrators"

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_administrators_orca_cloud_auth_backfilled(self, async_client: AsyncClient):
        """Admin without `orca_cloud:auth` (older custom edit) gets it
        backfilled — matches the fresh-install default."""
        await seed_default_groups()
        async with _database_module.async_session() as session:
            grp = (await session.execute(select(Group).where(Group.name == "Administrators"))).scalar_one()
            grp.permissions = [p for p in (grp.permissions or []) if p != "orca_cloud:auth"]
            await session.commit()

        await seed_default_groups()

        perms = await _get_perms("Administrators")
        assert "orca_cloud:auth" in perms

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_operators_orca_cloud_auth_backfilled(self, async_client: AsyncClient):
        """Operators on upgraded installs get `orca_cloud:auth` backfilled
        (the new default — needed for the Slice modal's Orca Cloud preset
        picker)."""
        await seed_default_groups()
        async with _database_module.async_session() as session:
            grp = (await session.execute(select(Group).where(Group.name == "Operators"))).scalar_one()
            grp.permissions = [p for p in (grp.permissions or []) if p != "orca_cloud:auth"]
            await session.commit()

        await seed_default_groups()

        perms = await _get_perms("Operators")
        assert "orca_cloud:auth" in perms

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_administrators_printer_sensor_history_read_backfilled(self, async_client: AsyncClient):
        """Admin without `printer_sensor_history:read` (older custom edit or
        a DB seeded before that permission existed) gets it backfilled —
        regression for the gap maziggy hit on a live install where the
        per-permission admin backfills missed it."""
        await seed_default_groups()
        async with _database_module.async_session() as session:
            grp = (await session.execute(select(Group).where(Group.name == "Administrators"))).scalar_one()
            grp.permissions = [p for p in (grp.permissions or []) if p != "printer_sensor_history:read"]
            await session.commit()

        await seed_default_groups()

        perms = await _get_perms("Administrators")
        assert "printer_sensor_history:read" in perms

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_administrators_sync_covers_every_current_permission(self, async_client: AsyncClient):
        """Generic invariant: ALL_PERMISSIONS sync ensures every Permission
        enum value is present on the Administrators group, no matter what
        was stripped pre-backfill. Catches every future "new permission
        missing on upgrade" regression without needing a one-off test."""
        from backend.app.core.permissions import ALL_PERMISSIONS

        await seed_default_groups()
        # Wipe the admin group's permission list entirely and force the sync
        # to put everything back.
        async with _database_module.async_session() as session:
            grp = (await session.execute(select(Group).where(Group.name == "Administrators"))).scalar_one()
            grp.permissions = []
            await session.commit()

        await seed_default_groups()

        perms = await _get_perms("Administrators")
        missing = [p for p in ALL_PERMISSIONS if p not in perms]
        assert not missing, f"Administrators missing permissions after backfill: {missing}"

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_administrators_sync_is_additive_only(self, async_client: AsyncClient):
        """The sync block must never remove a permission an operator added by
        hand — only add missing entries from ALL_PERMISSIONS."""
        await seed_default_groups()
        async with _database_module.async_session() as session:
            grp = (await session.execute(select(Group).where(Group.name == "Administrators"))).scalar_one()
            grp.permissions = [*(grp.permissions or []), "custom:plugin_permission"]
            await session.commit()

        await seed_default_groups()

        perms = await _get_perms("Administrators")
        assert "custom:plugin_permission" in perms

    @pytest.mark.asyncio
    @pytest.mark.integration
    async def test_viewers_do_not_get_orca_cloud_auth(self, async_client: AsyncClient):
        """Viewers stay read-only — orca_cloud:auth is not added by the
        backfill (matches the fresh-install Viewers bootstrap, which
        intentionally excludes cloud-auth permissions)."""
        await seed_default_groups()
        async with _database_module.async_session() as session:
            grp = (await session.execute(select(Group).where(Group.name == "Viewers"))).scalar_one()
            grp.permissions = [p for p in (grp.permissions or []) if p != "orca_cloud:auth"]
            await session.commit()

        await seed_default_groups()

        perms = await _get_perms("Viewers")
        assert "orca_cloud:auth" not in perms
