"""HEL-1026: Leave Meeting or End Meeting, the auto-end, and attendance records."""

import json

import pytest
from sqlalchemy import select

from app.audit.models import AuditRecord
from app.integrations.registry import AdapterRegistry
from app.main import create_app
from app.meetings.leaving import HOST_CHANGED_TOPIC
from app.meetings.models import Meeting, MeetingDevice, MeetingMember, MeetingStatus
from app.revocation.recheck import recheck_once
from tests.conftest import make_app, make_settings, running, session_headers
from tests.fakes import FAKE_WEBHOOK_SIGNATURE, FakeClock, make_helixtahr_adapter

HOST = "674"
HOST_DEVICE = "host-laptop-01"
DEVICE = "tab-device-01"


def _identity(user: str, device: str = DEVICE) -> 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):
    """A started meeting; the Host joined and is connected on one device."""
    meeting_id = (await client.post("/v1/meetings", headers=host)).json()["id"]
    assert (await _join(client, host, meeting_id, HOST_DEVICE)).status_code == 200
    await _webhook(client, "participant_joined", meeting_id, _identity(HOST, HOST_DEVICE))
    return meeting_id


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


async def _webhook(client, event, meeting_id, identity=None, 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 _leave(client, headers, meeting_id, device=DEVICE, **choice):
    return await client.post(
        f"/v1/meetings/{meeting_id}/leave", headers=headers, json={"device_id": device, **choice}
    )


async def _end(client, headers, meeting_id):
    return await client.post(f"/v1/meetings/{meeting_id}/end", headers=headers)


async def _meeting(db, meeting_id) -> Meeting:
    db.expire_all()
    return await db.get(Meeting, meeting_id)


async def _records(db, action):
    db.expire_all()
    return list(await db.scalars(select(AuditRecord).where(AuditRecord.action == action)))


async def _role(db, meeting_id, user):
    db.expire_all()
    return (await db.get(MeetingMember, (meeting_id, user))).role


# --- Leave meeting ---------------------------------------------------------


async def test_participant_leaving_disconnects_only_them(client, db, host, meeting_id):
    person = await _in_meeting(client, host, meeting_id)

    response = await _leave(client, person, meeting_id)

    assert response.json() == {"status": "left", "new_host_name": None}
    assert (await _meeting(db, meeting_id)).status == MeetingStatus.ACTIVE
    device = await db.get(MeetingDevice, (meeting_id, _identity("700")))
    assert device.left_at is not None
    [record] = await _records(db, "participant_left")
    assert record.actor_user_id == "700"
    assert record.details == {"device_id": DEVICE, "on_purpose": True}


async def test_participant_who_left_can_come_back_within_2_minutes(client, db, host, meeting_id):
    # Decided 3 Nov: leaving on purpose = a drop (2 minutes, muted).
    person = await _in_meeting(client, host, meeting_id)
    await _leave(client, person, meeting_id)

    response = await _join(client, person, meeting_id)

    assert response.status_code == 200
    assert response.json()["microphone_on"] is False


async def test_host_leaving_while_a_co_host_stays_keeps_being_host(client, db, host, meeting_id):
    # AC-24.1 / AC-24.8: carries on under the Co-host; no handover, no Acting Host.
    await _in_meeting(client, host, meeting_id)
    await client.post(f"/v1/meetings/{meeting_id}/co-hosts/700", headers=host)

    response = await _leave(client, host, meeting_id, HOST_DEVICE)

    assert response.json()["status"] == "left"
    meeting = await _meeting(db, meeting_id)
    assert (meeting.status, meeting.host_user_id) == (MeetingStatus.ACTIVE, HOST)
    rejoin = await _join(client, host, meeting_id, HOST_DEVICE)
    assert (rejoin.status_code, rejoin.json()["role"]) == (200, "host")


async def test_last_host_must_choose_or_end(client, db, host, meeting_id):
    await _in_meeting(client, host, meeting_id)

    response = await _leave(client, host, meeting_id, HOST_DEVICE)

    assert response.status_code == 409
    assert response.json()["error"]["code"] == "handover_required"
    meeting = await _meeting(db, meeting_id)
    assert (meeting.status, meeting.host_user_id) == (MeetingStatus.ACTIVE, HOST)
    device = await db.get(MeetingDevice, (meeting_id, _identity(HOST, HOST_DEVICE)))
    assert device.left_at is None  # Cancel leaves everything as it was (AC-24.6)


async def test_host_on_another_device_is_not_the_last(client, db, host, meeting_id):
    await _join(client, host, meeting_id, "host-phone-02")
    await _webhook(client, "participant_joined", meeting_id, _identity(HOST, "host-phone-02"))

    response = await _leave(client, host, meeting_id, HOST_DEVICE)

    assert response.json()["status"] == "left"


async def test_handover_candidates_are_admitted_people_in_the_meeting(client, db, host, meeting_id):
    await _in_meeting(client, host, meeting_id, "700")
    await _in_meeting(client, host, meeting_id, "701")
    await _join(client, await session_headers(client, sub="702"), meeting_id)  # waiting

    response = await client.get(f"/v1/meetings/{meeting_id}/handover-candidates", headers=host)

    assert response.json() == {
        "candidates": [
            {"user_id": "700", "name": "Person 700"},
            {"user_id": "701", "name": "Person 701"},
        ]
    }


async def test_ordinary_participant_gets_no_candidates(client, db, host, meeting_id):
    person = await _in_meeting(client, host, meeting_id)

    response = await client.get(f"/v1/meetings/{meeting_id}/handover-candidates", headers=person)

    assert response.status_code == 403


async def test_handover_makes_the_chosen_person_host(client, db, host, meeting_id, fake_livekit):
    # AC-24.5, AC-24.15, BR-24.6
    await _in_meeting(client, host, meeting_id)

    response = await _leave(client, host, meeting_id, HOST_DEVICE, new_host_user_id="700")

    assert response.json() == {"status": "handed_over", "new_host_name": "Person 700"}
    meeting = await _meeting(db, meeting_id)
    assert (meeting.status, meeting.host_user_id, meeting.host_name) == (
        MeetingStatus.ACTIVE,
        "700",
        "Person 700",
    )
    assert await _role(db, meeting_id, "700") == "host"
    assert await _role(db, meeting_id, HOST) == "co_host"
    assert fake_livekit.attributes[(meeting_id, _identity("700"))] == {"role": "host"}
    [notice] = [sent for sent in fake_livekit.sent if sent.topic == HOST_CHANGED_TOPIC]
    assert notice.identities is None  # everyone
    assert notice.message == {"host_user_id": "700", "host_name": "Person 700"}
    [record] = await _records(db, "host_handed_over")
    assert record.details == {"from_user_id": HOST, "to_user_id": "700", "to_name": "Person 700"}


async def test_host_who_handed_over_rejoins_as_co_host(client, db, host, meeting_id):
    # AC-24.11
    await _in_meeting(client, host, meeting_id)
    await _leave(client, host, meeting_id, HOST_DEVICE, new_host_user_id="700")

    response = await _join(client, host, meeting_id, HOST_DEVICE)

    assert (response.status_code, response.json()["role"]) == (200, "co_host")


async def test_chosen_person_gone_means_choose_again(client, db, host, meeting_id):
    # AC-24.13
    await _in_meeting(client, host, meeting_id)
    await _webhook(client, "participant_left", meeting_id, _identity("700"))

    response = await _leave(client, host, meeting_id, HOST_DEVICE, new_host_user_id="700")

    assert response.status_code == 409
    assert response.json()["error"]["code"] == "new_host_gone"
    assert (await _meeting(db, meeting_id)).host_user_id == HOST


async def test_last_host_leaving_without_choosing_ends_the_meeting(
    client, db, host, meeting_id, fake_livekit
):
    # AC-24.7
    await _in_meeting(client, host, meeting_id)

    response = await _leave(client, host, meeting_id, HOST_DEVICE, end_for_everyone=True)

    assert response.json()["status"] == "ended"
    assert (await _meeting(db, meeting_id)).status == MeetingStatus.ENDED
    assert fake_livekit.deleted_rooms == [meeting_id]
    [record] = await _records(db, "meeting_ended")
    assert record.details == {"reason": "last_host_left"}
    link = await client.get(f"/v1/meetings/{meeting_id}", headers=host)
    assert link.json()["error"]["code"] == "link_invalid"


async def test_nobody_to_choose_still_asks_before_ending(client, db, host, meeting_id):
    # AC-24.17: no candidates → the screen warns, then ends.
    first = await _leave(client, host, meeting_id, HOST_DEVICE)
    candidates = await client.get(f"/v1/meetings/{meeting_id}/handover-candidates", headers=host)
    second = await _leave(client, host, meeting_id, HOST_DEVICE, end_for_everyone=True)

    assert first.status_code == 409
    assert candidates.json() == {"candidates": []}
    assert second.json()["status"] == "ended"


# --- End meeting -----------------------------------------------------------


async def test_host_ends_the_meeting_for_everyone(client, db, host, meeting_id, fake_livekit):
    # AC-24.2, AC-24.16
    await _in_meeting(client, host, meeting_id)

    response = await _end(client, host, meeting_id)

    assert response.status_code == 204
    meeting = await _meeting(db, meeting_id)
    assert meeting.status == MeetingStatus.ENDED and meeting.ended_at is not None
    assert fake_livekit.deleted_rooms == [meeting_id]
    [record] = await _records(db, "meeting_ended")
    assert (record.actor_user_id, record.details) == (HOST, {"reason": "end_meeting"})
    assert (await client.get("/v1/meetings", headers=host)).json()["meetings"] == []
    history = (await client.get("/v1/meetings/history", headers=host)).json()["meetings"]
    assert [row["id"] for row in history] == [meeting_id]


async def test_ending_releases_the_waiting_room(client, db, host, meeting_id):
    # AC-24.9 / BR-24.4
    waiting = await session_headers(client, sub="702", name="Person 702")
    await _join(client, waiting, meeting_id)

    await _end(client, host, meeting_id)

    status = await client.get(f"/v1/meetings/{meeting_id}/admission", headers=waiting)
    assert status.json() == {"status": "ended", "message": "The meeting has ended"}
    assert (await _join(client, waiting, meeting_id)).json()["error"]["code"] == "link_invalid"


async def test_co_host_can_end_but_participants_cannot(client, db, host, meeting_id):
    # BR-24.1 / AC-24.4
    co_host = await _in_meeting(client, host, meeting_id, "700")
    await client.post(f"/v1/meetings/{meeting_id}/co-hosts/700", headers=host)
    participant = await _in_meeting(client, host, meeting_id, "701")

    refused = await _end(client, participant, meeting_id)
    ended = await _end(client, co_host, meeting_id)

    assert refused.status_code == 403
    assert refused.json()["error"]["code"] == "not_a_host"
    assert ended.status_code == 204


async def test_ending_twice_and_leaving_afterwards_are_harmless(client, db, host, meeting_id):
    person = await _in_meeting(client, host, meeting_id)
    await _end(client, host, meeting_id)

    assert (await _end(client, host, meeting_id)).status_code == 204
    assert (await _leave(client, person, meeting_id)).json()["status"] == "ended"
    assert len(await _records(db, "meeting_ended")) == 1


@pytest.mark.parametrize(("problem", "refused"), [("switched_off", 403), ("helixtahr_down", 503)])
async def test_leave_and_end_work_whatever_helixtahr_says(
    db, fake_livekit, fake_helixtahr, problem, refused
):
    # Decision 8a note: they only take access away.
    clock = FakeClock()
    products = AdapterRegistry([make_helixtahr_adapter(fake_helixtahr, clock)])
    app = create_app(make_settings(), livekit_adapter=fake_livekit, products=products)
    async with running(app) as client:
        host = await session_headers(client)
        meeting_id = (await client.post("/v1/meetings", headers=host)).json()["id"]
        await _join(client, host, meeting_id, HOST_DEVICE)
        await _webhook(client, "participant_joined", meeting_id, _identity(HOST, HOST_DEVICE))
        person = await _in_meeting(client, host, meeting_id)
        if problem == "switched_off":
            fake_helixtahr.meetings_allowed = False
        else:
            fake_helixtahr.unreachable = True
        clock.now += 61  # HelixtaHR's last answer is no longer remembered

        # Everything else is refused now...
        assert (await client.get("/v1/meetings", headers=host)).status_code == refused
        # ...but Leave and End still work.
        assert (await _leave(client, person, meeting_id)).json()["status"] == "left"
        assert (await _end(client, host, meeting_id)).status_code == 204


# --- Auto-end: nobody connected for 2 minutes ------------------------------


async def test_meeting_ends_when_livekit_closes_the_empty_room(client, db, host, meeting_id):
    # BR-24.2: LiveKit closes a room after 2 minutes with nobody in it.
    response = await _webhook(client, "room_finished", meeting_id)

    assert response.status_code == 200
    assert (await _meeting(db, meeting_id)).status == MeetingStatus.ENDED
    [record] = await _records(db, "meeting_ended")
    assert (record.actor_user_id, record.details) == ("system", {"reason": "nobody_connected"})


async def test_room_closed_after_end_meeting_changes_nothing(client, db, host, meeting_id):
    await _end(client, host, meeting_id)

    await _webhook(client, "room_finished", meeting_id)

    assert len(await _records(db, "meeting_ended")) == 1


# --- Attendance records ----------------------------------------------------


async def test_attendance_records_connects_reconnects_and_leaves(client, db, host, meeting_id):
    person = await _in_meeting(client, host, meeting_id)  # connected
    await _webhook(client, "participant_left", meeting_id, _identity("700"))  # dropped
    await _join(client, person, meeting_id)
    await _webhook(client, "participant_joined", meeting_id, _identity("700"), "conn-2")
    await _join(client, person, meeting_id, "phone-device-2")
    await _webhook(client, "participant_joined", meeting_id, _identity("700", "phone-device-2"))

    db.expire_all()
    records = list(
        await db.scalars(
            select(AuditRecord)
            .where(AuditRecord.actor_user_id == "700", AuditRecord.action.like("participant_%"))
            .order_by(AuditRecord.id)
        )
    )
    # (The admission record is the Host's, so it isn't among 700's own records.)
    assert [(r.action, r.details["device_id"]) for r in records] == [
        ("participant_connected", DEVICE),
        ("participant_left", DEVICE),
        ("participant_reconnected", DEVICE),
        ("participant_connected", "phone-device-2"),  # a second device, not a reconnect
    ]


async def test_leave_is_recorded_once(client, db, host, meeting_id):
    person = await _in_meeting(client, host, meeting_id)
    await _leave(client, person, meeting_id)

    await _webhook(client, "participant_left", meeting_id, _identity("700"))

    assert len(await _records(db, "participant_left")) == 1


async def test_late_noticed_leave_is_recorded(app, client, db, host, meeting_id, fake_livekit):
    await _in_meeting(client, host, meeting_id)
    fake_livekit.in_room[meeting_id] = {_identity(HOST, HOST_DEVICE)}  # 700's "left" was lost

    state = app.state
    await recheck_once(
        state.db_sessions, state.livekit, state.products, state.sessions, state.alerts
    )

    [record] = await _records(db, "participant_left")
    assert record.details == {"device_id": DEVICE, "noticed_late": True}
