"""HEL-1136: Permanent Meeting Rooms."""

import json
from datetime import UTC, datetime, timedelta

import pytest
from sqlalchemy import func, select, update

from app.audit.models import AuditRecord
from app.meetings import permanent
from app.meetings.leaving import end_unmanaged_sessions
from app.meetings.models import (
    Meeting,
    MeetingDevice,
    MeetingMember,
    MeetingStatus,
    Room,
    RoomStandingCoHost,
    RoomStatus,
)
from app.revocation.recheck import recheck_once
from tests.conftest import make_app, running, session_headers
from tests.fakes import FAKE_WEBHOOK_SIGNATURE

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 running meeting: the Host and Person 700 (admitted) connected."""
    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))
    await _in_meeting(client, host, meeting_id, "700")
    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, room, identity=None, connection="conn-1"):
    body = {"event": event, "room": room, "identity": identity, "connection_id": connection}
    return await client.post(
        "/livekit/webhook",
        content=json.dumps(body),
        headers={"Authorization": FAKE_WEBHOOK_SIGNATURE},
    )


async def _person(client, user):
    return await session_headers(client, sub=user, name=f"Person {user}")


async def _in_meeting(client, host, meeting_id, user):
    person = await _person(client, 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 _convert(client, headers, meeting_id, **fields):
    body = {"name": "Team standup", "url": "standup", "grace_minutes": 2} | fields
    return await client.post(f"/v1/meetings/{meeting_id}/room", headers=headers, json=body)


async def _room_state(client, headers, url="standup", org="26"):
    return await client.get(f"/v1/rooms/{org}/{url}", headers=headers)


async def _start(client, headers, url="standup"):
    return await client.post(f"/v1/rooms/26/{url}/start", headers=headers)


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


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


async def _get(db, model, key):
    db.expire_all()
    return await db.get(model, key)


async def _old_room(db, url, *, days_ago, org="26"):
    db.add(
        Room(
            product="helixtahr",
            org_id=org,
            url=url,
            name=url,
            owner_user_id="900",
            owner_name="Someone",
            created_from_meeting_id="m-x",
            status=RoomStatus.DISABLED,
            disabled_at=datetime.now(UTC) - timedelta(days=days_ago),
        )
    )
    await db.commit()


# --- Converting ------------------------------------------------------------


async def test_original_host_converts_without_interrupting(
    client, db, host, meeting_id, fake_livekit
):
    # AC-39.1, AC-39.3
    response = await _convert(
        client, host, meeting_id, grace_minutes=10, standing_co_host_ids=["700"]
    )

    assert response.status_code == 201
    body = response.json()
    assert body | {"room_id": "x"} == {
        "room_id": "x",
        "name": "Team standup",
        "org_id": "26",
        "url": "standup",
        "grace_minutes": 10,
        "standing_co_hosts": [{"user_id": "700", "name": "Person 700"}],
        "turning_off": False,
    }
    meeting = await _get(db, Meeting, meeting_id)
    assert (meeting.kind, meeting.room_id, meeting.status) == (
        "room_session",
        body["room_id"],
        MeetingStatus.ACTIVE,
    )
    assert meeting.title == "Team standup"
    member = await _get(db, MeetingMember, (meeting_id, "700"))
    assert member.role == "standing_co_host"
    assert fake_livekit.attributes[(meeting_id, _identity("700"))] == {"role": "standing_co_host"}
    [record] = await _records(db, "room_created")
    assert record.details["url"] == "standup"


@pytest.mark.parametrize("who", ["co_host", "participant"])
async def test_only_the_original_host_converts(client, db, host, meeting_id, who):
    # BR-39.1 / AC-39.2
    other = await _person(client, "700")
    if who == "co_host":
        await client.post(f"/v1/meetings/{meeting_id}/co-hosts/700", headers=host)

    response = await _convert(client, other, meeting_id)

    assert response.status_code == 403
    assert response.json()["error"]["code"] == "not_the_original_host"
    assert (await _get(db, Meeting, meeting_id)).kind == "normal"


async def test_converting_twice_is_refused(client, db, host, meeting_id):
    # AC-39.28
    await _convert(client, host, meeting_id)

    response = await _convert(client, host, meeting_id, url="another")

    assert response.status_code == 409
    assert response.json()["error"]["code"] == "already_converted"


@pytest.mark.parametrize(
    ("url", "code"),
    [
        ("ab", "url_invalid"),
        ("Team", "url_invalid"),
        ("team room", "url_invalid"),
        ("x" * 31, "url_invalid"),
        ("admin", "url_reserved"),
        ("team-settings", "url_reserved"),
        ("fuckwits", "url_offensive"),
        ("my-crap", "url_offensive"),
    ],
)
async def test_bad_urls_are_refused(client, db, host, meeting_id, url, code):
    # AC-39.6
    response = await _convert(client, host, meeting_id, url=url)

    assert response.status_code == 409
    assert response.json()["error"]["code"] == code
    assert (await _get(db, Meeting, meeting_id)).kind == "normal"


async def test_ordinary_words_containing_short_offensive_ones_are_fine(
    client, db, host, meeting_id
):
    assert (await _convert(client, host, meeting_id, url="dickson-scrapbook")).status_code == 201


async def test_url_taken_in_the_same_company_is_refused(client, db, host, meeting_id):
    # AC-39.4
    db.add(
        Room(
            product="helixtahr",
            org_id="26",
            url="standup",
            name="x",
            owner_user_id="900",
            owner_name="x",
            created_from_meeting_id="m-x",
        )
    )
    await db.commit()

    response = await _convert(client, host, meeting_id)

    assert response.json()["error"] == {
        "code": "url_taken",
        "message": "This URL is already used in your company. Choose another.",
    }


async def test_same_url_in_another_company_is_fine(client, db, host, meeting_id):
    # AC-39.5
    db.add(
        Room(
            product="helixtahr",
            org_id="30",
            url="standup",
            name="x",
            owner_user_id="900",
            owner_name="x",
            created_from_meeting_id="m-x",
        )
    )
    await db.commit()

    assert (await _convert(client, host, meeting_id)).status_code == 201


async def test_url_of_a_room_turned_off_30_days_ago_is_refused(client, db, host, meeting_id):
    # AC-39.7 / BR-39.4
    await _old_room(db, "standup", days_ago=30)
    await _old_room(db, "weekly", days_ago=100)

    refused = await _convert(client, host, meeting_id)
    accepted = await _convert(client, host, meeting_id, url="weekly")

    assert refused.json()["error"]["code"] == "url_recently_disabled"
    assert accepted.status_code == 201


async def test_availability_check(client, db, host, meeting_id):
    await _old_room(db, "standup", days_ago=30)

    free = await client.get("/v1/room-urls/weekly/available", headers=host)
    used = await client.get("/v1/room-urls/standup/available", headers=host)

    assert free.json() == {"available": True, "reason": None, "message": None}
    assert used.json()["available"] is False
    assert used.json()["reason"] == "recently_disabled"


async def test_url_taken_between_the_check_and_save(client, db, host, meeting_id, monkeypatch):
    # AC-39.25 / AC-39.26: someone else's room takes it in between.
    async def says_free(*args, **kwargs):
        return None

    monkeypatch.setattr(permanent, "url_problem", says_free)
    db.add(
        Room(
            product="helixtahr",
            org_id="26",
            url="standup",
            name="x",
            owner_user_id="900",
            owner_name="x",
            created_from_meeting_id="m-x",
        )
    )
    await db.commit()

    response = await _convert(client, host, meeting_id)

    assert response.status_code == 409
    assert response.json()["error"] == {
        "code": "url_just_taken",
        "message": "This URL was just taken",
    }
    meeting = await _get(db, Meeting, meeting_id)
    assert (meeting.kind, meeting.status) == ("normal", MeetingStatus.ACTIVE)


async def test_standing_co_hosts_must_be_admitted_here(client, db, host, meeting_id):
    # BR-39.2 (decided 3 Nov: people in this meeting)
    response = await _convert(client, host, meeting_id, standing_co_host_ids=["999"])

    assert response.status_code == 422
    assert (await _get(db, Meeting, meeting_id)).kind == "normal"


async def test_grace_period_must_be_2_to_30_minutes(client, db, host, meeting_id):
    assert (await _convert(client, host, meeting_id, grace_minutes=1)).status_code == 422
    assert (await _convert(client, host, meeting_id, grace_minutes=31)).status_code == 422


# --- The temporary link and the URL ----------------------------------------


async def test_both_addresses_work_until_the_converting_session_ends(client, db, host, meeting_id):
    # AC-39.8
    await _convert(client, host, meeting_id)

    by_link = await client.get(f"/v1/meetings/{meeting_id}", headers=host)
    by_url = await _room_state(client, host)
    assert by_link.json()["kind"] == "permanent_room"
    assert by_url.json()["meeting_id"] == meeting_id

    await _end(client, host, meeting_id)

    assert (await client.get(f"/v1/meetings/{meeting_id}", headers=host)).status_code == 404
    assert (await _room_state(client, host)).json()["status"] == "not_started"


# --- The room's address ----------------------------------------------------


async def _room_between_sessions(client, host, meeting_id):
    await _convert(client, host, meeting_id, standing_co_host_ids=["700"])
    await _end(client, host, meeting_id)


async def test_not_started_room_is_locked(client, db, host, meeting_id):
    # AC-39.12
    await _room_between_sessions(client, host, meeting_id)
    employee = await _person(client, "702")

    response = await _room_state(client, employee)

    assert response.status_code == 200
    assert response.json() | {"room_id": "x"} == {
        "status": "not_started",
        "room_id": "x",
        "name": "Team standup",
        "meeting_id": None,
        "can_start": False,
    }


async def test_another_companys_room_address_reveals_nothing(client, db, host, meeting_id):
    await _room_between_sessions(client, host, meeting_id)
    outsider = await session_headers(client, company_id=27)

    response = await _room_state(client, outsider)

    assert response.status_code == 403
    assert response.json()["error"]["code"] == "cannot_join"
    assert "standup" not in response.text.lower()


async def test_unknown_room_address(client, db, host):
    response = await _room_state(client, host, url="nothing-here")

    assert response.json()["error"]["code"] == "link_invalid"


async def test_ordinary_employee_cannot_start(client, db, host, meeting_id):
    # AC-39.10
    await _room_between_sessions(client, host, meeting_id)

    response = await _start(client, await _person(client, "702"))

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


async def test_standing_co_host_starts_but_cannot_change_settings(client, db, host, meeting_id):
    # AC-39.11 / AC-39.20
    await _room_between_sessions(client, host, meeting_id)
    standing = await _person(client, "700")

    started = await _start(client, standing)
    room_id = (await _room_state(client, standing)).json()["room_id"]
    change = await client.patch(f"/v1/rooms/{room_id}", headers=standing, json={"name": "Mine"})

    assert started.status_code == 200
    assert (await _room_state(client, standing)).json()["can_start"] is True
    assert change.status_code == 403
    assert change.json()["error"]["code"] == "not_the_owner"


async def test_session_admission(client, db, host, meeting_id):
    # AC-39.13 / BR-39.5: standing Co-host directly, an employee waits.
    # AC-05.10 / BR-05.4: a Co-host's first connection has the mic on.
    await _room_between_sessions(client, host, meeting_id)
    session_id = (await _start(client, host)).json()["meeting_id"]

    standing = await _join(client, await _person(client, "700"), session_id)
    employee = await _join(client, await _person(client, "702"), session_id)

    assert (standing.status_code, standing.json()["role"]) == (200, "standing_co_host")
    assert standing.json()["microphone_on"] is True
    assert employee.status_code == 202


async def test_starting_twice_gives_the_same_session(client, db, host, meeting_id):
    await _room_between_sessions(client, host, meeting_id)

    first = (await _start(client, host)).json()["meeting_id"]
    second = (await _start(client, await _person(client, "700"))).json()["meeting_id"]

    assert first == second


async def test_room_used_twice_meetings_and_history(client, db, host, meeting_id):
    # AC-39.15, AC-39.19, AC-39.22
    await _room_between_sessions(client, host, meeting_id)
    second = (await _start(client, host)).json()["meeting_id"]
    await _join(client, host, second, HOST_DEVICE)
    await _end(client, host, second)

    listed = (await client.get("/v1/meetings", headers=host)).json()["meetings"]
    history = (await client.get("/v1/meetings/history", headers=host)).json()["meetings"]

    assert [(r["kind"], r["room_name"]) for r in listed] == [("permanent_room", "standup")]
    assert sorted(r["id"] for r in history) == sorted([meeting_id, second])
    assert (await _room_state(client, host)).json()["status"] == "not_started"


# --- In a session ----------------------------------------------------------


async def test_no_handover_in_a_room(client, db, host, meeting_id, fake_livekit):
    # BR-39.6: the last Host leaving just leaves; the grace period applies.
    await _convert(client, host, meeting_id)

    response = await client.post(
        f"/v1/meetings/{meeting_id}/leave", headers=host, json={"device_id": HOST_DEVICE}
    )

    assert response.json()["status"] == "left"
    assert (await _get(db, Meeting, meeting_id)).status == MeetingStatus.ACTIVE


async def test_no_acting_host_in_a_room(client, db, host, meeting_id, fake_livekit):
    # AC-22.6 / BR-39.6
    await _convert(client, host, meeting_id)

    await _webhook(client, "participant_left", meeting_id, _identity(HOST, HOST_DEVICE))

    assert (await _get(db, MeetingMember, (meeting_id, "700"))).role == "participant"


async def _owner_gone(db, meeting_id, minutes_ago):
    await db.execute(
        update(MeetingDevice)
        .where(MeetingDevice.meeting_id == meeting_id, MeetingDevice.user_id == HOST)
        .values(left_at=func.now() - timedelta(minutes=minutes_ago))
    )
    await db.commit()


async def test_session_ends_after_the_grace_period_without_owner_or_standing(
    client, db, host, meeting_id, fake_livekit
):
    # AC-39.14: Person 700 (a participant) is still there; it ends anyway.
    await _convert(client, host, meeting_id, grace_minutes=2)
    await _owner_gone(db, meeting_id, minutes_ago=1)
    assert await end_unmanaged_sessions(db, fake_livekit) == []

    await _owner_gone(db, meeting_id, minutes_ago=3)
    assert await end_unmanaged_sessions(db, fake_livekit) == [meeting_id]

    assert (await _get(db, Meeting, meeting_id)).status == MeetingStatus.ENDED
    assert fake_livekit.deleted_rooms == [meeting_id]
    [record] = await _records(db, "meeting_ended")
    assert record.details == {"reason": "grace_period"}


async def test_a_standing_co_host_present_keeps_it_going(
    client, db, host, meeting_id, fake_livekit
):
    await _convert(client, host, meeting_id, standing_co_host_ids=["700"])
    await _owner_gone(db, meeting_id, minutes_ago=10)

    assert await end_unmanaged_sessions(db, fake_livekit) == []


async def test_owner_comes_back_directly_when_full(client, db, host, meeting_id):
    # AC-39.23: held for the whole session.
    await _convert(client, host, meeting_id)
    await _owner_gone(db, meeting_id, minutes_ago=1)
    now = datetime.now(UTC)
    for i in range(24):
        user = str(9000 + i)
        db.add(
            MeetingMember(
                meeting_id=meeting_id,
                product="helixtahr",
                org_id="26",
                user_id=user,
                user_name=user,
                first_admitted_at=now,
                admitted_at=now - timedelta(hours=1),
            )
        )
        db.add(
            MeetingDevice(
                meeting_id=meeting_id,
                product="helixtahr",
                org_id="26",
                identity=_identity(user, "filler"),
                user_id=user,
                device_id="filler",
                pass_issued_at=now - timedelta(hours=1),
                connected_at=now - timedelta(hours=1),
            )
        )
    await db.execute(
        update(MeetingDevice)
        .where(MeetingDevice.meeting_id == meeting_id, MeetingDevice.user_id == "700")
        .values(left_at=func.now() - timedelta(hours=1))
    )
    await db.execute(
        update(MeetingMember)
        .where(MeetingMember.meeting_id == meeting_id, MeetingMember.user_id == "700")
        .values(admitted_at=func.now() - timedelta(hours=1))
    )
    await db.commit()

    assert (await _join(client, host, meeting_id, HOST_DEVICE)).status_code == 200


# --- Settings --------------------------------------------------------------


async def _room_id(client, host):
    return (await _room_state(client, host)).json()["room_id"]


async def test_owner_changes_settings_during_a_session(client, db, host, meeting_id):
    # AC-39.3, AC-39.18
    await _convert(client, host, meeting_id)
    room_id = await _room_id(client, host)

    response = await client.patch(
        f"/v1/rooms/{room_id}",
        headers=host,
        json={"name": "Daily standup", "url": "daily", "grace_minutes": 15},
    )

    assert response.status_code == 200
    assert (response.json()["name"], response.json()["url"], response.json()["grace_minutes"]) == (
        "Daily standup",
        "daily",
        15,
    )
    assert (await _get(db, Meeting, meeting_id)).grace_seconds == 900
    [record] = await _records(db, "room_settings_changed")
    assert record.details == {
        "room_id": room_id,
        "name": "Daily standup",
        "url": "daily",
        "grace_minutes": 15,
    }


async def test_settings_only_during_a_session(client, db, host, meeting_id):
    # AC-39.21
    await _room_between_sessions(client, host, meeting_id)
    room_id = await _room_id(client, host)

    response = await client.patch(f"/v1/rooms/{room_id}", headers=host, json={"name": "x"})

    assert response.status_code == 409
    assert response.json()["error"]["code"] == "no_session"


async def test_removing_a_standing_co_host_takes_effect_at_once(client, db, host, meeting_id):
    # AC-39.27 / BR-39.14
    await _convert(client, host, meeting_id, standing_co_host_ids=["700"])
    room_id = await _room_id(client, host)

    await client.patch(f"/v1/rooms/{room_id}", headers=host, json={"standing_co_host_ids": []})

    assert (await _get(db, MeetingMember, (meeting_id, "700"))).role == "participant"
    assert await _get(db, RoomStandingCoHost, (room_id, "700")) is None
    assert len(await _records(db, "standing_co_host_removed")) == 1
    await _end(client, host, meeting_id)
    assert (await _start(client, await _person(client, "700"))).status_code == 403


async def test_turning_the_room_off_during_a_session(client, db, host, meeting_id):
    # AC-39.16, AC-39.24
    await _convert(client, host, meeting_id)
    room_id = await _room_id(client, host)

    response = await client.patch(f"/v1/rooms/{room_id}", headers=host, json={"keep_room": False})

    assert response.json()["turning_off"] is True
    assert (await _get(db, Meeting, meeting_id)).status == MeetingStatus.ACTIVE
    await _end(client, host, meeting_id)
    assert (await _get(db, Room, room_id)).status == RoomStatus.DISABLED
    assert (await _room_state(client, host)).json()["error"]["code"] == "link_invalid"
    [record] = await _records(db, "room_disabled")
    assert record.details == {"room_id": room_id, "reason": "turned_off"}


# --- The owner loses access ------------------------------------------------


async def test_owner_losing_access_turns_the_rooms_off(
    app, client, db, host, meeting_id, fake_helixtahr
):
    # AC-39.17: detected by the re-check; standing Co-hosts don't inherit.
    await _convert(client, host, meeting_id, standing_co_host_ids=["700"])
    room_id = await _room_id(client, host)
    fake_helixtahr.revoked_users.add(HOST)

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

    room = await _get(db, Room, room_id)
    assert room.turn_off_after_session is True  # the live session finishes first
    await _end(client, await _person(client, "700"), meeting_id)
    assert (await _get(db, Room, room_id)).status == RoomStatus.DISABLED
    assert [r.details["reason"] for r in await _records(db, "room_disabled")] == [
        "owner_lost_access",
        "turned_off",
    ]


async def test_join_now_tells_the_screen_who_may_change_the_room(client, db, host, meeting_id):
    # Meeting Settings: the original Host converts; in a room, the owner changes settings.
    before = await _join(client, host, meeting_id, HOST_DEVICE)
    other = await _join(client, await _person(client, "700"), meeting_id)
    await _convert(client, host, meeting_id)
    after = await _join(client, host, meeting_id, HOST_DEVICE)

    assert (before.json()["original_host"], before.json()["room_id"]) == (True, None)
    assert other.json()["original_host"] is False
    assert after.json()["room_id"] == (await _room_state(client, host)).json()["room_id"]


async def test_removed_standing_co_host_waits_this_session_but_stays_standing(
    client, db, host, meeting_id
):
    # HEL-984 (Arshdeep, 3 Nov): removed from this session only.
    await _room_between_sessions(client, host, meeting_id)
    session_id = (await _start(client, host)).json()["meeting_id"]
    assert (await _join(client, host, session_id)).status_code == 200
    standing = await _person(client, "700")
    assert (await _join(client, standing, session_id)).status_code == 200
    await _webhook(client, "participant_joined", session_id, _identity("700"))

    removed = await client.post(f"/v1/meetings/{session_id}/participants/700/remove", headers=host)

    assert removed.status_code == 204
    assert (await _join(client, standing, session_id)).status_code == 202
    settings = await client.get(f"/v1/rooms/{await _room_id(client, host)}", headers=host)
    assert [person["user_id"] for person in settings.json()["standing_co_hosts"]] == ["700"]
