"""HEL-1044: the access re-check (removal within 5 minutes; decisions 8 and 8a)."""

import asyncio
import json
import logging

import pytest
from pydantic import ValidationError
from sqlalchemy import select

from app.access.sessions import PERSON_TOKEN_KEEP_SECONDS, SessionService
from app.audit.models import AuditRecord
from app.core.principal import Principal
from app.meetings.models import AdmissionRequest, MeetingDevice, MeetingMember
from app.meetings.places import count_places
from app.revocation.recheck import recheck_forever, recheck_once
from tests.conftest import make_app, make_settings, running, session_headers
from tests.fakes import FAKE_WEBHOOK_SIGNATURE, FakeClock

HOST = "674"
PERSON = ("helixtahr", "26", "700")


def _identity(user: str, device: str = "tab-device-01") -> str:
    return f"helixtahr:26:{user}:{device}"


@pytest.fixture
async def app_client(db, fake_livekit, fake_helixtahr):
    app = make_app(livekit=fake_livekit, helixtahr=fake_helixtahr)
    async with running(app) as client:
        yield app, client


@pytest.fixture
def app(app_client):
    return app_client[0]


@pytest.fixture
def client(app_client):
    return app_client[1]


@pytest.fixture
async def host(client):
    return await session_headers(client)


@pytest.fixture
async def meeting_id(client, host):
    meeting_id = (await client.post("/v1/meetings", headers=host)).json()["id"]
    assert (await _join(client, host, meeting_id, "host-laptop-01")).status_code == 200
    await _webhook(client, "participant_joined", meeting_id, _identity(HOST, "host-laptop-01"))
    return meeting_id


async def _join(client, headers, meeting_id, device="tab-device-01"):
    return await client.post(
        f"/v1/meetings/{meeting_id}/join", headers=headers, json={"device_id": device}
    )


async def _webhook(client, event, meeting_id, identity, connection="conn-1"):
    body = {"event": event, "room": meeting_id, "identity": identity, "connection_id": connection}
    return await client.post(
        "/livekit/webhook",
        content=json.dumps(body),
        headers={"Authorization": FAKE_WEBHOOK_SIGNATURE},
    )


async def _in_meeting(client, host, meeting_id, user="700"):
    """A colleague waits, is admitted, joins and connects."""
    person = await session_headers(client, sub=user, name=f"Person {user}")
    assert (await _join(client, person, meeting_id)).status_code == 202
    waiting = (await client.get(f"/v1/meetings/{meeting_id}/waiting", headers=host)).json()
    request_id = next(r["request_id"] for r in waiting["waiting"] if r["user_id"] == user)
    admit = f"/v1/meetings/{meeting_id}/waiting/{request_id}/admit"
    assert (await client.post(admit, headers=host)).status_code == 204
    assert (await _join(client, person, meeting_id)).status_code == 200
    await _webhook(client, "participant_joined", meeting_id, _identity(user))
    return person


async def _recheck(app):
    state = app.state
    return await recheck_once(
        state.db_sessions, state.livekit, state.products, state.sessions, state.alerts
    )


async def _all(db, model, *where):
    db.expire_all()
    return list(await db.scalars(select(model).where(*where)))


# --- Someone loses access (HelixtaHR refuses their token) ------------------


async def test_person_who_lost_access_is_removed(
    app, client, db, host, meeting_id, fake_livekit, fake_helixtahr
):
    # AC-35.6 / BR-35.8
    await _in_meeting(client, host, meeting_id)
    fake_helixtahr.revoked_users.add("700")

    result = await _recheck(app)

    assert result.removed == [PERSON]
    assert fake_livekit.removed == [(meeting_id, _identity("700"))]
    [member] = await _all(db, MeetingMember, MeetingMember.user_id == "700")
    assert member.removed_at is not None
    [record] = await _all(db, AuditRecord, AuditRecord.action == "participant_removed")
    assert (record.meeting_id, record.actor_user_id, record.actor_name) == (
        meeting_id,
        "system",
        "Access re-check",
    )
    assert record.details == {"user_id": "700", "reason": "access_revoked"}


async def test_others_are_not_touched(
    app, client, db, host, meeting_id, fake_livekit, fake_helixtahr
):
    await _in_meeting(client, host, meeting_id, "700")
    await _in_meeting(client, host, meeting_id, "701")
    fake_helixtahr.revoked_users.add("700")

    result = await _recheck(app)

    assert result.checked == 3  # Host, 700, 701
    assert [identity for _, identity in fake_livekit.removed] == [_identity("700")]


async def test_removed_person_cannot_rejoin(app, client, db, host, meeting_id, fake_helixtahr):
    person = await _in_meeting(client, host, meeting_id)
    fake_helixtahr.revoked_users.add("700")
    await _recheck(app)

    response = await _join(client, person, meeting_id)

    assert response.status_code == 401
    assert response.json()["error"]["code"] == "not_signed_in"


async def test_removed_device_reconnecting_with_its_old_pass_is_removed_again(
    app, client, db, host, meeting_id, fake_livekit, fake_helixtahr
):
    await _in_meeting(client, host, meeting_id)
    fake_helixtahr.revoked_users.add("700")
    await _recheck(app)

    await _webhook(client, "participant_joined", meeting_id, _identity("700"), connection="again")

    assert fake_livekit.removed[-1] == (meeting_id, _identity("700"))
    assert len(fake_livekit.removed) == 2


async def test_removal_frees_the_place(app, client, db, host, meeting_id, fake_helixtahr):
    await _in_meeting(client, host, meeting_id)
    fake_helixtahr.revoked_users.add("700")

    await _recheck(app)

    places = await count_places(db, meeting_id)
    assert places.in_use() == 1  # only the Host
    assert not places.is_in("700")


async def test_waiting_person_who_lost_access_leaves_the_waiting_list(
    app, client, db, host, meeting_id, fake_helixtahr
):
    waiting = await session_headers(client, sub="702", name="Person 702")
    await _join(client, waiting, meeting_id)
    fake_helixtahr.revoked_users.add("702")

    result = await _recheck(app)

    assert ("helixtahr", "26", "702") in result.removed
    [request] = await _all(db, AdmissionRequest)
    assert request.status == "left"
    listing = await client.get(f"/v1/meetings/{meeting_id}/waiting", headers=host)
    assert listing.json()["waiting"] == []


async def test_access_restored_means_a_fresh_admission(
    app, client, db, host, meeting_id, fake_livekit, fake_helixtahr
):
    # Removed → must be admitted again; once admitted, the new pass is valid.
    person = await _in_meeting(client, host, meeting_id)
    fake_helixtahr.revoked_users.add("700")
    await _recheck(app)
    fake_helixtahr.revoked_users.clear()
    person = await session_headers(client, sub="700", name="Person 700")

    assert (await _join(client, person, meeting_id)).status_code == 202
    await _in_meeting_again(client, host, meeting_id, person)
    removed_before = len(fake_livekit.removed)
    await _webhook(client, "participant_joined", meeting_id, _identity("700"), connection="new")

    assert len(fake_livekit.removed) == removed_before  # not kicked


async def _in_meeting_again(client, host, meeting_id, person):
    waiting = (await client.get(f"/v1/meetings/{meeting_id}/waiting", headers=host)).json()
    request_id = waiting["waiting"][0]["request_id"]
    await client.post(f"/v1/meetings/{meeting_id}/waiting/{request_id}/admit", headers=host)
    assert (await _join(client, person, meeting_id)).status_code == 200


async def test_host_who_lost_access_is_removed_too(
    app, client, db, host, meeting_id, fake_livekit, fake_helixtahr
):
    fake_helixtahr.revoked_users.add(HOST)

    result = await _recheck(app)

    assert result.removed == [("helixtahr", "26", HOST)]
    assert fake_livekit.removed == [(meeting_id, _identity(HOST, "host-laptop-01"))]


# --- The organisation is switched off (decision 8a) ------------------------


async def test_running_meeting_goes_on_when_the_organisation_is_switched_off(
    app, client, db, host, meeting_id, fake_livekit, fake_helixtahr
):
    await _in_meeting(client, host, meeting_id)
    fake_helixtahr.meetings_allowed = False

    result = await _recheck(app)

    assert result.removed == []
    assert result.organisation_off == 2
    assert fake_livekit.removed == []


async def test_but_nobody_new_gets_in(app, client, db, host, meeting_id, fake_helixtahr):
    newcomer = await session_headers(client, sub="703", name="Person 703")
    fake_helixtahr.meetings_allowed = False

    response = await _join(client, newcomer, meeting_id)

    assert response.status_code == 403
    assert response.json()["error"]["code"] == "no_access"


# --- HelixtaHR can't be reached (decision 8) -------------------------------


async def test_nobody_is_removed_while_helixtahr_is_down(
    app, client, db, host, meeting_id, fake_livekit, fake_helixtahr, caplog
):
    await _in_meeting(client, host, meeting_id)
    fake_helixtahr.unreachable = True

    with caplog.at_level(logging.ERROR, logger="app.alerts"):
        result = await _recheck(app)

    assert result.removed == []
    assert result.unavailable >= 1
    assert fake_livekit.removed == []
    assert any("ALERT helixtahr unavailable" in r.getMessage() for r in caplog.records)


async def test_no_token_held_after_a_restart_keeps_them(app, client, db, host, meeting_id):
    await _in_meeting(client, host, meeting_id)
    app.state.sessions = SessionService("x" * 40, 900)  # what a restart leaves

    result = await _recheck(app)

    assert result.no_token == 2
    assert result.removed == []


# --- Correcting devices against LiveKit ------------------------------------


async def test_device_livekit_no_longer_has_is_marked_left(
    app, client, db, host, meeting_id, fake_livekit
):
    # The "left" webhook never arrived.
    await _in_meeting(client, host, meeting_id)
    fake_livekit.in_room[meeting_id] = {_identity(HOST, "host-laptop-01")}

    result = await _recheck(app)

    assert result.devices_corrected == 1
    [device] = await _all(db, MeetingDevice, MeetingDevice.user_id == "700")
    assert device.left_at is not None
    [host_device] = await _all(db, MeetingDevice, MeetingDevice.user_id == HOST)
    assert host_device.left_at is None


async def test_devices_are_left_alone_when_livekit_is_down(
    app, client, db, host, meeting_id, fake_livekit
):
    await _in_meeting(client, host, meeting_id)
    fake_livekit.healthy = False

    result = await _recheck(app)

    assert result.devices_corrected == 0
    assert all(d.left_at is None for d in await _all(db, MeetingDevice))


# --- Timing ----------------------------------------------------------------


def test_recheck_runs_at_least_every_4_minutes():
    assert make_settings().recheck_interval_seconds == 240
    with pytest.raises(ValidationError):
        make_settings(recheck_interval_seconds=241)


async def test_recheck_loop_runs_until_stopped():
    rounds = 0

    async def run_once():
        nonlocal rounds
        rounds += 1

    job = asyncio.create_task(recheck_forever(run_once, interval=0.01))
    await asyncio.sleep(0.1)
    job.cancel()

    assert rounds >= 3


def test_latest_product_token_per_person():
    clock = FakeClock()
    sessions = SessionService("s" * 40, 900, clock=clock)
    person = Principal("helixtahr", "26", "700", "Person 700", False)
    sessions.issue(person, "first-token")
    sessions.issue(person, "second-token")

    assert sessions.latest_for("helixtahr", "26", "700") == (person, "second-token")
    assert sessions.latest_for("helixtahr", "27", "700") is None
    clock.now += PERSON_TOKEN_KEEP_SECONDS
    assert sessions.latest_for("helixtahr", "26", "700") is None
