"""
Regression test: a doctor who logs off must stop receiving prescriptions.

Reproduces the production report ("doctor logged off but prescriptions kept
coming"). The mechanism:
  - A prescription is offered to the doctor -> they are lrem'd out of the pool
    and tracked in ASSIGNED_DOCTORS (so they are NOT in the pool list).
  - The doctor logs off -> remove_doctor is called, but the doctor isn't in the
    pool at that instant, so (pre-fix) it is a no-op.
  - The prescription expires -> the expiry-requeue safe_rpush's the doctor back
    into the pool (no activity check) -> they get new prescriptions again.

The active-doctors-set fix makes remove_doctor mark the doctor inactive and
makes safe_rpush_doctor refuse to re-add an inactive doctor. This test asserts
the doctor is NOT back in the pool after the expiry, so it FAILS on the current
code and PASSES once the fix is in.
"""
import asyncio
import json
import time
from types import SimpleNamespace
from unittest.mock import AsyncMock

import pytest

import app.main as main
from app.main import (
    assign_doctor,
    handle_expired_prescription,
    remove_doctor,
    vendor_keys,
)

VENDOR_ID = "vendor_logoff"
REDIS_KEY = "doctor_pool_vendor_logoff"          # must follow the pool naming convention
ACTIVE_KEY = "active_doctors_vendor_logoff"      # derived active-set key


class FakeRedis:
    """In-memory Redis fake with list, hash, and set semantics."""

    def __init__(self):
        self.lists = {}
        self.hashes = {}
        self.sets = {}

    # ---- lists ----
    async def lrange(self, key, start, end):
        items = self.lists.get(key, [])
        return items[start:] if end == -1 else items[start : end + 1]

    async def lrem(self, key, count, value):
        items = self.lists.get(key, [])
        if count == 0:
            new = [i for i in items if i != value]
            removed = len(items) - len(new)
            self.lists[key] = new
            return removed
        removed, new = 0, []
        for i in items:
            if i == value and removed < count:
                removed += 1
                continue
            new.append(i)
        self.lists[key] = new
        return removed

    async def rpush(self, key, value):
        self.lists.setdefault(key, []).append(value)

    async def eval(self, script, num_keys, *args):
        key, value = args[0], args[1]
        if value in self.lists.get(key, []):
            return 0
        self.lists.setdefault(key, []).append(value)
        return 1

    # ---- hashes ----
    async def hget(self, key, field):
        return self.hashes.get(key, {}).get(field)

    async def hgetall(self, key):
        return dict(self.hashes.get(key, {}))

    async def hset(self, key, field, value):
        self.hashes.setdefault(key, {})[field] = value

    async def hdel(self, key, field):
        if key in self.hashes:
            self.hashes[key].pop(field, None)

    # ---- sets ----
    async def sadd(self, key, *values):
        self.sets.setdefault(key, set()).update(values)

    async def srem(self, key, *values):
        s = self.sets.setdefault(key, set())
        s.difference_update(values)
        # Real Redis deletes a set once its last member is removed. Modelling this
        # is essential: the original bug was that an emptied active-doctors set
        # vanishes, so an exists()-based "are we tracking this vendor" check reads
        # as "not tracked" and re-adds a just-removed doctor. A fake that kept the
        # empty set around hid that bug.
        if not s:
            del self.sets[key]

    async def sismember(self, key, value):
        return value in self.sets.get(key, set())

    async def exists(self, key):
        return 1 if (key in self.sets or key in self.lists or key in self.hashes) else 0


@pytest.fixture(autouse=True)
def setup_vendor_keys():
    vendor_keys[VENDOR_ID] = REDIS_KEY
    yield
    vendor_keys.pop(VENDOR_ID, None)


def _stored_value(assigned_time, is_blocked=False):
    return json.dumps(
        {
            "vendor_id": VENDOR_ID,
            "assignment_time": str(assigned_time),
            "is_blocked": is_blocked,
        }
    )


def test_logged_off_doctor_not_readded_to_pool_on_expiry(monkeypatch):
    fake_redis = FakeRedis()
    fake_redis.lists[REDIS_KEY] = ["D1"]
    fake_redis.sets[ACTIVE_KEY] = {"D1"}  # D1 is logged in
    # Vendor is tracked for activity (seeded by pool init / add_doctor in prod).
    fake_redis.sets[main.ACTIVE_DOCTORS_TRACKED_KEY] = {VENDOR_ID}

    monkeypatch.setattr(main, "redis", fake_redis)
    monkeypatch.setattr(main, "redis_client", fake_redis, raising=False)
    monkeypatch.setattr(
        main,
        "doctors_data",
        SimpleNamespace(
            data=[
                SimpleNamespace(
                    vendor=VENDOR_ID, concurrent_doctors=1, immediate_unblock=False
                )
            ]
        ),
    )
    monkeypatch.setattr(main, "acquire_redis_lock", AsyncMock(return_value="lock"))
    monkeypatch.setattr(main, "release_redis_lock", AsyncMock())
    monkeypatch.setattr(main, "add_prescription_to_queue", AsyncMock())

    # P1 is offered to D1 -> D1 leaves the pool, sits in ASSIGNED_DOCTORS.
    asyncio.run(
        assign_doctor(SimpleNamespace(prescription_id="P1", vendor_id=VENDOR_ID))
    )
    assert asyncio.run(fake_redis.lrange(REDIS_KEY, 0, -1)) == []

    # D1 logs off while the prescription is on their screen.
    asyncio.run(
        remove_doctor(SimpleNamespace(id="D1", vendor_id=VENDOR_ID))
    )

    # The prescription expires and is requeued.
    asyncio.run(
        handle_expired_prescription("P1", _stored_value(0), int(time.time()))
    )

    # A logged-off doctor must NOT be back in the pool.
    pool_after = asyncio.run(fake_redis.lrange(REDIS_KEY, 0, -1))
    assert "D1" not in pool_after, (
        f"logged-off doctor D1 was resurrected into the pool: {pool_after}"
    )
