"""
Lector de QR / organizer (services/qr_detect.py, organizer_service.py, scan_service.py).

Reproduce las reglas de resiliencia de hec-photo-app (cada una nació de una caída real) y la cascada de
lectura, con códigos QR REALES generados al vuelo.
"""
import asyncio
import base64
import io
import sys
import threading
import types
import uuid

import cv2
import numpy as np
import pytest

from core import config
from services import organizer_service as O
from services import photographer_service, photo_service, qr_detect, school_service, scan_service
from conftest import plain_image, qr_image, save_jpeg


# ─── Extracción del código ────────────────────────────────────────────────────

@pytest.mark.parametrize("raw,expected", [
    ("LMR-4827", "LMR-4827"),
    (" lmr-4827 ", "LMR-4827"),
    ("MAR-4JC-IBD", "MAR-4JC-IBD"),
    ("https://photos.meeconic.com/r/MAR-4JC-IBD", "MAR-4JC-IBD"),
    ("HTTPS://PHOTOS.MEECONIC.COM/R/MAR-4JC-IBD", "MAR-4JC-IBD"),
    ("https://photos.meeconic.com/?code=EMM-2931&x=1", "EMM-2931"),
    ("https://photos.meeconic.com/otra-cosa", None),
    ("../../etc/passwd", None),
    ("..\\..\\windows", None),
    ("https://evil.test/r/../../x", None),
    ("A" * 80, None),
    ("", None),
    (None, None),
])
def test_extract_code_rejects_anything_that_is_not_a_family_code(raw, expected):
    assert qr_detect.extract_code(raw) == expected


# ─── Cascada de lectura ───────────────────────────────────────────────────────

def _bgr(pil):
    return cv2.cvtColor(np.array(pil.convert("RGB")), cv2.COLOR_RGB2BGR)


def test_reads_a_real_qr_card():
    assert qr_detect.detect_qr(_bgr(qr_image("MAR-4JC-IBD")), use_ocr=False) == "MAR-4JC-IBD"


def test_reads_the_url_embedded_in_the_qr():
    raw = qr_detect.detect_qr(_bgr(qr_image("https://photos.meeconic.com/r/MAR-4JC-IBD")), use_ocr=False)
    assert qr_detect.extract_code(raw) == "MAR-4JC-IBD"


def test_a_portrait_without_qr_is_rejected_at_the_gate():
    assert qr_detect.detect_qr(_bgr(plain_image()), use_ocr=False) is None


def test_reads_a_blurred_low_contrast_qr():
    """Los preprocesados (CLAHE / Otsu / enfoque) rescatan tarjetas fotografiadas flojas."""
    img = qr_image("MAR-4JC-IBD", blur=2, contrast=0.55)
    assert qr_detect.extract_code(qr_detect.detect_qr(_bgr(img), use_ocr=False)) == "MAR-4JC-IBD"


def test_reads_a_small_qr_far_from_the_camera():
    img = qr_image("MAR-4JC-IBD", size=(3000, 2000), qr_px=240)
    assert qr_detect.extract_code(qr_detect.detect_qr(_bgr(img), use_ocr=False)) == "MAR-4JC-IBD"


def test_reads_one_card_when_several_are_in_the_photo():
    img = qr_image("MAR-4JC-IBD", size=(2400, 1200), qr_px=400)
    second = qr_image("MAR-ZZZ-999", size=(2400, 1200), qr_px=400).crop((900, 400, 1500, 800))
    img.paste(second, (200, 100))
    assert qr_detect.extract_code(qr_detect.detect_qr(_bgr(img), use_ocr=False)) in {"MAR-4JC-IBD", "MAR-ZZZ-999"}


# ─── OCR de rescate ───────────────────────────────────────────────────────────

class _FakeTesseract(types.SimpleNamespace):
    pass


def _fake_pytesseract(monkeypatch, text):
    fake = types.ModuleType("pytesseract")
    fake.image_to_string = lambda *a, **k: text
    monkeypatch.setitem(sys.modules, "pytesseract", fake)
    qr_detect.reset_ocr_state()
    return fake


def test_ocr_rescue_reads_the_printed_code_with_the_schools_prefix(monkeypatch):
    _fake_pytesseract(monkeypatch, "algo de ruido\nMAR 4JC IBD\notro")
    assert qr_detect.ocr_code(_bgr(plain_image()), {"MAR"}) == "MAR-4JC-IBD"


def test_ocr_rescue_ignores_prefixes_of_other_schools(monkeypatch):
    """Antes llevaba HEC escrito a mano: para cualquier otro colegio no podía acertar jamás."""
    _fake_pytesseract(monkeypatch, "HEC 4JC IBD")
    assert qr_detect.ocr_code(_bgr(plain_image()), {"MAR"}) is None
    assert qr_detect.ocr_code(_bgr(plain_image()), {"HEC"}) == "HEC-4JC-IBD"


def test_ocr_rejects_a_repeated_word_that_is_not_a_code(monkeypatch):
    _fake_pytesseract(monkeypatch, "MAR MAR IBD")
    assert qr_detect.ocr_code(_bgr(plain_image()), {"MAR"}) is None


def test_ocr_is_skipped_without_known_prefixes(monkeypatch):
    called = []
    fake = _fake_pytesseract(monkeypatch, "MAR 4JC IBD")
    fake.image_to_string = lambda *a, **k: called.append(1) or "MAR 4JC IBD"
    assert qr_detect.ocr_code(_bgr(plain_image()), set()) is None
    assert called == []


def test_ocr_is_disabled_for_good_when_tesseract_is_missing(monkeypatch):
    """Es el paso más caro: si no puede funcionar, no se paga foto a foto."""
    monkeypatch.setitem(sys.modules, "pytesseract", None)      # `import pytesseract` → ImportError
    qr_detect.reset_ocr_state()
    assert qr_detect.ocr_code(_bgr(plain_image()), {"MAR"}) is None
    assert qr_detect._ocr_unavailable is True
    qr_detect.reset_ocr_state()


def test_valid_prefixes_come_from_config_and_from_the_cards_already_on_disk(tmp_path):
    (tmp_path / "xavier" / "MAR-ABC-123").mkdir(parents=True)
    (tmp_path / "xavier" / "STADE-XYZ-111").mkdir()
    (tmp_path / "xavier" / "_no_qr").mkdir()
    (tmp_path / "xavier" / ".trash").mkdir()
    assert qr_detect.valid_prefixes("om", tmp_path) == {"OM", "MAR", "STADE"}


# ─── Escaneo de fotogramas (cámara) ───────────────────────────────────────────

def _data_url(pil) -> str:
    buf = io.BytesIO()
    pil.save(buf, "JPEG", quality=85)
    return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode()


def test_scan_frame_reads_qr():
    assert scan_service.scan_frame(_data_url(qr_image("MAR-4JC-IBD", size=(900, 700), qr_px=300))) == \
        {"code": "MAR-4JC-IBD", "method": "qr"}


def test_scan_frame_falls_back_to_text_ocr(monkeypatch):
    _fake_pytesseract(monkeypatch, "MAR 4JC IBD")
    res = scan_service.scan_frame(_data_url(plain_image((900, 700))), {"MAR"})
    assert res == {"code": "MAR-4JC-IBD", "method": "text"}


def test_scan_frame_with_nothing_readable_or_garbage_input():
    assert scan_service.scan_frame(_data_url(plain_image((900, 700)))) == {"code": None, "method": None}
    assert scan_service.scan_frame("esto no es base64 ni imagen") == {"code": None, "method": None}


async def test_portal_scan_endpoint_is_public_validates_size_and_is_rate_limited(client):
    r = await client.post("/api/portal/scan", json={"image": _data_url(qr_image("MAR-4JC-IBD", size=(900, 700), qr_px=300))})
    assert r.status_code == 200 and r.json()["code"] == "MAR-4JC-IBD"
    assert (await client.post("/api/portal/scan", json={"image": "x" * 8_000_000})).status_code == 422
    assert (await client.post("/api/portal/scan", json={})).status_code == 422
    from core.rate_limit import is_blocked
    assert not is_blocked("portal-scan:127.0.0.1", 120, 300)


async def test_portal_scan_rate_limit(client):
    from core.rate_limit import record_hit
    for _ in range(120):
        record_hit("portal-scan:127.0.0.1")
    r = await client.post("/api/portal/scan", json={"image": _data_url(plain_image((300, 300)))})
    assert r.status_code == 429


# ─── Organizer : colocación ───────────────────────────────────────────────────

@pytest.fixture
def school():
    sid = f"org-{uuid.uuid4().hex[:6]}"
    school_service.create_school("Organizer Test", slug=sid)
    return sid


@pytest.fixture
def org(school, tmp_path, monkeypatch):
    monkeypatch.setattr(config, "FTP_STABLE_SECS", 0.0)          # sin la espera de estabilidad
    monkeypatch.setattr(config, "PURGE_MIN_AGE_SECS", 0.0)
    O._status_by_school.pop(school, None)
    incoming, output = tmp_path / "incoming", tmp_path / "photos" / school
    incoming.mkdir(parents=True)
    organizer = O.PhotoOrganizer(school, incoming, output)
    organizer.incoming_dir = incoming
    return organizer


def _drop(org, name, img):
    return save_jpeg(img, org.incoming / name)


def _tree(org):
    return sorted(str(p.relative_to(org.output)).replace("\\", "/") for p in org.output.rglob("*") if p.is_file())


async def test_card_photo_goes_to_dot_qr_and_following_photos_hang_from_it(org):
    _drop(org, "DSC_0001.JPG", qr_image("MAR-4JC-IBD"))
    _drop(org, "DSC_0002.JPG", plain_image(seed=1))
    _drop(org, "DSC_0003.JPG", plain_image(seed=2))
    await org.process_pending()
    assert _tree(org) == [
        "photographer/MAR-4JC-IBD/.qr/DSC_0001.JPG",
        "photographer/MAR-4JC-IBD/DSC_0002.JPG",
        "photographer/MAR-4JC-IBD/DSC_0003.JPG",
    ]
    assert not list(org.incoming.glob("*.JPG"))


async def test_photos_before_the_first_card_wait_in_no_qr(org):
    _drop(org, "DSC_0001.JPG", plain_image())
    await org.process_pending()
    assert _tree(org) == ["photographer/_no_qr/DSC_0001.JPG"]


async def test_card_url_is_reduced_to_its_code(org):
    _drop(org, "DSC_0001.JPG", qr_image("https://photos.meeconic.com/r/MAR-4JC-IBD"))
    await org.process_pending()
    assert _tree(org) == ["photographer/MAR-4JC-IBD/.qr/DSC_0001.JPG"]


async def test_a_qr_that_is_not_a_family_code_is_ignored_and_not_used_as_a_folder(org):
    _drop(org, "DSC_0001.JPG", qr_image("../../../etc/passwd"))
    await org.process_pending()
    assert all(".." not in t for t in _tree(org))
    assert _tree(org) == ["photographer/_no_qr/DSC_0001.JPG"]


async def test_each_photographer_keeps_their_own_sticky_qr(org, school):
    photographer_service.update_photographer_map(school, {"DSC": "xavier", "CAR": "carlos"})
    _drop(org, "DSC_0001.JPG", qr_image("MAR-AAA-111"))       # xavier fotografía la tarjeta A
    _drop(org, "CAR_0001.JPG", qr_image("MAR-BBB-222"))       # carlos, la B
    _drop(org, "DSC_0002.JPG", plain_image(seed=1))           # → A
    _drop(org, "CAR_0002.JPG", plain_image(seed=2))           # → B
    await org.process_pending()
    tree = _tree(org)
    assert "xavier/MAR-AAA-111/DSC_0002.JPG" in tree
    assert "carlos/MAR-BBB-222/CAR_0002.JPG" in tree
    assert not any("xavier/MAR-BBB" in t or "carlos/MAR-AAA" in t for t in tree)
    assert photographer_service.get_sticky_qr(school, "xavier") == "MAR-AAA-111"


async def test_sticky_qr_survives_a_restart(org, school, tmp_path):
    _drop(org, "DSC_0001.JPG", qr_image("MAR-4JC-IBD"))
    await org.process_pending()
    fresh = O.PhotoOrganizer(school, org.incoming, org.output)      # «reinicio»: objeto nuevo, estado en base
    _drop(fresh, "DSC_0002.JPG", plain_image(seed=5))
    await fresh.process_pending()
    assert "photographer/MAR-4JC-IBD/DSC_0002.JPG" in _tree(fresh)


async def test_unknown_prefix_goes_to_errors_without_being_lost(org, school):
    photographer_service.update_photographer_map(school, {"DSC": "xavier"})
    _drop(org, "IMG_0001.JPG", plain_image())
    await org.process_pending()
    assert (org.incoming / "_errors" / "IMG_0001.JPG").exists()
    assert _tree(org) == []


async def test_unreadable_image_goes_to_errors_and_the_rest_is_organised(org):
    (org.incoming / "DSC_0001.JPG").write_bytes(b"esto no es un jpeg")
    _drop(org, "DSC_0002.JPG", qr_image("MAR-4JC-IBD"))
    await org.process_pending()
    assert (org.incoming / "_errors" / "DSC_0001.JPG").exists()
    assert _tree(org) == ["photographer/MAR-4JC-IBD/.qr/DSC_0002.JPG"]


async def test_duplicate_filenames_are_never_overwritten(org):
    _drop(org, "DSC_0001.JPG", qr_image("MAR-4JC-IBD"))
    _drop(org, "DSC_0002.JPG", plain_image(seed=1))
    await org.process_pending()
    _drop(org, "DSC_0002.JPG", plain_image(seed=2))                  # la cámara reinicia su contador
    await org.process_pending()
    assert "photographer/MAR-4JC-IBD/DSC_0002.JPG" in _tree(org)
    assert "photographer/MAR-4JC-IBD/DSC_0002_1.JPG" in _tree(org)


async def test_photos_are_organised_in_shooting_order_not_alphabetical(org):
    """La tarjeta se fotografía ANTES que las fotos de esa familia: el orden lo da EXIF/mtime, no el nombre."""
    import os
    import time
    card = _drop(org, "ZZZ_card.JPG", qr_image("MAR-4JC-IBD"))
    photo = _drop(org, "AAA_photo.JPG", plain_image(seed=1))
    os.utime(card, (time.time() - 100, time.time() - 100))
    os.utime(photo, (time.time() - 10, time.time() - 10))
    await org.process_pending()
    assert _tree(org) == ["photographer/MAR-4JC-IBD/.qr/ZZZ_card.JPG", "photographer/MAR-4JC-IBD/AAA_photo.JPG"]


# ─── Organizer : resiliencia (las 5 caídas de HEC) ────────────────────────────

async def test_a_crash_in_a_pass_does_not_leave_processing_stuck(org, monkeypatch):
    _drop(org, "DSC_0001.JPG", qr_image("MAR-4JC-IBD"))
    monkeypatch.setattr(org, "_purge_unprocessable", lambda: (_ for _ in ()).throw(RuntimeError("base caída")))
    with pytest.raises(RuntimeError):
        await org.process_pending()
    assert O._status_for(org.school_id)["processing"] is False
    monkeypatch.undo()
    monkeypatch.setattr(config, "FTP_STABLE_SECS", 0.0)
    await org.process_pending()                                       # la siguiente pasada SÍ trabaja
    assert _tree(org) == ["photographer/MAR-4JC-IBD/.qr/DSC_0001.JPG"]


async def test_one_bad_file_does_not_take_down_the_batch(org, monkeypatch):
    _drop(org, "DSC_0001.JPG", qr_image("MAR-4JC-IBD"))
    _drop(org, "DSC_0002.JPG", plain_image(seed=1))
    _drop(org, "DSC_0003.JPG", plain_image(seed=2))
    real = O._analyze_file

    def flaky(path, prefixes=None):
        if path.name == "DSC_0002.JPG":
            raise RuntimeError("análisis reventado")
        return real(path, prefixes)

    monkeypatch.setattr(O, "_analyze_file", flaky)
    await org.process_pending()
    tree = _tree(org)
    assert "photographer/MAR-4JC-IBD/.qr/DSC_0001.JPG" in tree and "photographer/MAR-4JC-IBD/DSC_0003.JPG" in tree
    assert (org.incoming / "DSC_0002.JPG").exists()                  # se queda en incoming, no se pierde
    st = O.get_organizer_status(org.school_id)
    assert "DSC_0002.JPG" in st["failed_files"] and st["last_error"]


async def test_a_failed_move_is_reported_not_counted_as_organised(org, monkeypatch):
    _drop(org, "DSC_0001.JPG", qr_image("MAR-4JC-IBD"))
    monkeypatch.setattr(org, "_move", lambda src, dst: False)         # permisos / disco lleno
    await org.process_pending()
    st = O.get_organizer_status(org.school_id)
    assert st["last_processed_count"] == 0
    assert "DSC_0001.JPG" in st["failed_files"]
    assert (org.incoming / "DSC_0001.JPG").exists()


async def test_status_recovers_after_a_clean_pass(org, monkeypatch):
    _drop(org, "DSC_0001.JPG", qr_image("MAR-4JC-IBD"))
    monkeypatch.setattr(org, "_move", lambda src, dst: False)
    await org.process_pending()
    assert O.get_organizer_status(org.school_id)["last_error"]
    monkeypatch.undo()
    monkeypatch.setattr(config, "FTP_STABLE_SECS", 0.0)
    await org.process_pending()
    st = O.get_organizer_status(org.school_id)
    assert st["last_error"] is None and st["consecutive_errors"] == 0 and st["failed_files"] == []


async def test_files_still_uploading_defer_the_whole_batch(org, monkeypatch):
    f = _drop(org, "DSC_0001.JPG", qr_image("MAR-4JC-IBD"))
    monkeypatch.setattr(config, "FTP_STABLE_SECS", 0.05)

    grew = {"n": 0}
    real_sleep = O.time.sleep

    def sleep_and_grow(secs):
        real_sleep(secs)
        with open(f, "ab") as fh:                                     # el FTP sigue escribiendo
            fh.write(b"\0" * 10)
        grew["n"] += 1

    monkeypatch.setattr(O.time, "sleep", sleep_and_grow)
    await org.process_pending()
    assert grew["n"] == 1 and _tree(org) == [] and f.exists()


def test_purge_removes_raw_leftovers_but_keeps_images_and_camera_index(org):
    import os
    import time
    old = time.time() - 3600
    for name in ("DSC_0001.NEF", "resto.tmp", "NC_FLLST.DAT", "DSC_9.JPG"):
        (org.incoming / name).write_bytes(b"x")
        os.utime(org.incoming / name, (old, old))
    org._purge_unprocessable()
    left = sorted(p.name for p in org.incoming.iterdir() if p.is_file())
    assert left == ["DSC_9.JPG", "NC_FLLST.DAT"]


def test_purge_does_not_touch_a_file_still_being_uploaded(org, monkeypatch):
    monkeypatch.setattr(config, "PURGE_MIN_AGE_SECS", 30.0)
    (org.incoming / "DSC_0001.NEF").write_bytes(b"x")                 # recién creado
    org._purge_unprocessable()
    assert (org.incoming / "DSC_0001.NEF").exists()


async def test_the_loop_survives_failing_passes_with_backoff_and_recovers(school, tmp_path, monkeypatch):
    """El `while True` vive fuera del try: una caída de la base retrasa el trabajo, no lo cancela."""
    monkeypatch.setattr(config, "INCOMING_DIR", tmp_path / "inc")
    monkeypatch.setattr(config, "PHOTOS_DIR", tmp_path / "ph")
    monkeypatch.setattr(config, "ORGANIZER_RETRY_SECS", 0.01)
    monkeypatch.setattr(config, "ORGANIZER_POLL_SECS", 0.01)
    O._status_by_school.pop(school, None)
    calls = {"n": 0}

    async def flaky(self):
        calls["n"] += 1
        if calls["n"] <= 3:
            raise RuntimeError("MySQL caído")

    monkeypatch.setattr(O.PhotoOrganizer, "process_pending", flaky)
    task = asyncio.create_task(O.run_organizer(school))
    for _ in range(200):                      # hasta 10 s: las escrituras de estado en SQLite no son instantáneas
        await asyncio.sleep(0.05)
        if calls["n"] > 3:
            break
    assert not task.done(), "una excepción de la pasada NO debe matar el bucle"
    assert calls["n"] > 3 and O._status_for(school)["alive"] is True
    task.cancel()
    with pytest.raises(asyncio.CancelledError):
        await task
    assert O._status_for(school)["alive"] is False


async def test_state_is_per_school_one_failing_school_does_not_block_another(tmp_path, monkeypatch):
    monkeypatch.setattr(config, "FTP_STABLE_SECS", 0.0)
    a, b = (f"iso-{uuid.uuid4().hex[:5]}" for _ in range(2))
    for sid in (a, b):
        school_service.create_school("Aislado " + sid, slug=sid)
    oa = O.PhotoOrganizer(a, tmp_path / "ia", tmp_path / "pa")
    ob = O.PhotoOrganizer(b, tmp_path / "ib", tmp_path / "pb")
    O._status_for(a)["processing"] = True                              # «A» quedó colgado
    save_jpeg(qr_image("MAR-4JC-IBD"), ob.incoming / "DSC_0001.JPG")
    await ob.process_pending()
    assert (tmp_path / "pb" / "photographer" / "MAR-4JC-IBD" / ".qr" / "DSC_0001.JPG").exists()
    O._status_for(a)["processing"] = False


async def test_watchdog_events_from_another_thread_trigger_a_pass(org, monkeypatch):
    """watchdog llama desde SU hilo: todo debe entrar al event loop con call_soon_threadsafe."""
    monkeypatch.setattr(config, "WATCHDOG_DEBOUNCE_SECS", 0.05)
    ran = asyncio.Event()

    async def fake_pass():
        ran.set()

    monkeypatch.setattr(org, "process_pending", fake_pass)
    handler = O._Handler(org, asyncio.get_running_loop())
    event = types.SimpleNamespace(is_directory=False)
    for _ in range(5):                                                 # ráfaga: el debounce las junta
        await asyncio.to_thread(handler.on_created, event)
    await asyncio.wait_for(ran.wait(), timeout=2)


async def test_pass_error_from_the_watchdog_is_recorded_not_swallowed(org, monkeypatch):
    monkeypatch.setattr(config, "WATCHDOG_DEBOUNCE_SECS", 0.01)

    async def boom():
        raise RuntimeError("pasada rota")

    monkeypatch.setattr(org, "process_pending", boom)
    handler = O._Handler(org, asyncio.get_running_loop())
    await asyncio.to_thread(handler.on_modified, types.SimpleNamespace(is_directory=False))
    await asyncio.sleep(0.2)
    assert "pasada rota" in (O.get_organizer_status(org.school_id)["last_error"] or "")


# ─── Estado / salud ───────────────────────────────────────────────────────────

def test_status_is_stalled_when_the_loop_is_dead(school):
    O._status_by_school.pop(school, None)
    st = O.get_organizer_status(school)
    assert st["stalled"] is True and st["alive"] is False


def test_status_is_not_stalled_when_alive_and_idle(school):
    O._status_by_school.pop(school, None)
    O._status_for(school).update(alive=True, started_at=O.utcnow_iso())
    assert O.get_organizer_status(school)["stalled"] is False


def test_status_is_stalled_when_files_wait_and_nothing_moves(school, tmp_path, monkeypatch):
    from datetime import timedelta
    O._status_by_school.pop(school, None)
    inc = tmp_path / "inc"
    save_jpeg(plain_image((200, 200)), inc / "DSC_0001.JPG")
    monkeypatch.setattr(O, "incoming_dir_for", lambda sid: inc)
    long_ago = (O.utcnow() - timedelta(seconds=config.ORGANIZER_STALL_SECS + 60)).isoformat()
    O._status_for(school).update(alive=True, started_at=long_ago, last_run_at=long_ago)
    st = O.get_organizer_status(school)
    assert st["pending_count"] == 1 and st["stalled"] is True and st["seconds_since_last_run"] > config.ORGANIZER_STALL_SECS


def test_status_is_published_for_other_workers(school):
    O._status_by_school.pop(school, None)
    st = O._status_for(school)
    st.update(alive=False)
    st["sticky_by_photographer"]["xavier"] = "MAR-PUB-001"
    O._publish_status(school)
    stored = O._read_published(school)
    assert stored is not None and stored["sticky_by_photographer"]["xavier"] == "MAR-PUB-001"


# ─── Gestor : un organizer por colegio activo ─────────────────────────────────

async def test_manager_starts_one_organizer_per_active_school_and_stops_deactivated(monkeypatch):
    monkeypatch.setattr(config, "ORGANIZER_RESCAN_SECS", 0.05)
    started, stopped = [], []

    async def fake_run(sid):
        started.append(sid)
        try:
            await asyncio.sleep(3600)
        except asyncio.CancelledError:
            stopped.append(sid)
            raise

    monkeypatch.setattr(O, "run_organizer", fake_run)
    sid = f"mgr-{uuid.uuid4().hex[:5]}"
    school_service.create_school("Gestor", slug=sid)
    task = asyncio.create_task(O.run_all_organizers())
    await asyncio.sleep(0.3)
    assert sid in started and started.count(sid) == 1
    school_service.update_school(sid, active=False)
    await asyncio.sleep(0.3)
    assert sid in stopped
    task.cancel()
    with pytest.raises(asyncio.CancelledError):
        await task


def test_legacy_school_keeps_the_root_of_incoming_others_get_a_subfolder(monkeypatch):
    from core.db import get_db
    with get_db() as conn:
        conn.execute("INSERT OR REPLACE INTO app_kv (key, value) VALUES ('legacy_school_id', 'colegio-viejo')")
        conn.commit()
    assert O.incoming_dir_for("colegio-viejo") == config.INCOMING_DIR
    assert O.incoming_dir_for("otro") == config.INCOMING_DIR / "otro"
    with get_db() as conn:
        conn.execute("DELETE FROM app_kv WHERE key='legacy_school_id'")
        conn.commit()


# ─── Fotógrafos ───────────────────────────────────────────────────────────────

def test_longest_prefix_wins_and_unknown_prefix_resolves_to_none(school):
    photographer_service.update_photographer_map(school, {"DS": "corto", "DSC": "largo"})
    assert photographer_service.resolve_photographer(school, "dsc_0001.jpg") == "largo"
    assert photographer_service.resolve_photographer(school, "DSX_1.jpg") == "corto"
    assert photographer_service.resolve_photographer(school, "IMG_1.jpg") is None


def test_with_no_photographers_configured_an_implicit_one_accepts_everything(school):
    assert photographer_service.resolve_photographer(school, "LO_QUE_SEA.JPG") == "photographer"


@pytest.mark.parametrize("bad", [{"": "x"}, {"DSC": "../fuera"}, {"DSC": "con/barra"}, {"D S C": "x"}, {"DSC": "A B"},
                                 {"DSC": "x", "dsc": "y"}])
def test_photographer_map_rejects_unsafe_values(school, bad):
    """El nombre es una CARPETA: nada de separadores ni rutas."""
    with pytest.raises(ValueError):
        photographer_service.update_photographer_map(school, bad)


def test_updating_the_map_keeps_the_sticky_of_photographers_that_remain(school):
    photographer_service.update_photographer_map(school, {"DSC": "xavier"})
    photographer_service.set_sticky_qr(school, "xavier", "MAR-KEP-001")
    photographer_service.update_photographer_map(school, {"DSC": "xavier", "CAR": "carlos"})
    assert photographer_service.get_sticky_qr(school, "xavier") == "MAR-KEP-001"
    assert photographer_service.get_sticky_qr(school, "carlos") == "_no_qr"


# ─── API de administración ────────────────────────────────────────────────────

async def test_photographers_api_roundtrip_and_validation(client, admin_headers):
    r = await client.put("/api/admin/photographers", json={"photographers": {"dsc": "Xavier", "car": "carlos"}},
                         headers=admin_headers)
    assert r.status_code == 200 and r.json()["map"] == {"DSC": "xavier", "CAR": "carlos"}
    got = (await client.get("/api/admin/photographers", headers=admin_headers)).json()
    assert {p["prefix"] for p in got["photographers"]} == {"DSC", "CAR"} and got["implicit"] is False
    bad = await client.put("/api/admin/photographers", json={"photographers": {"DSC": "../x"}}, headers=admin_headers)
    assert bad.status_code == 400
    await client.put("/api/admin/photographers", json={"photographers": {}}, headers=admin_headers)   # limpieza


async def test_photographers_and_organizer_status_require_operator(client, director_headers):
    assert (await client.get("/api/admin/photographers", headers=director_headers)).status_code == 403
    assert (await client.get("/api/admin/organizer/status", headers=director_headers)).status_code == 403
    assert (await client.get("/api/admin/organizer/status")).status_code == 401


async def test_organizer_status_endpoint_reports_health_and_ai(client, admin_headers):
    from services import ai_health
    O._status_by_school.pop("ecole-test", None)
    r = await client.get("/api/admin/organizer/status", headers=admin_headers)
    body = r.json()
    assert r.status_code == 200 and body["stalled"] is True and body["alive"] is False and body["ai"] is None
    ai_health.record_failure("ecole-test", ai_health.TASK_HEADS, RuntimeError("insufficient credits"))
    body = (await client.get("/api/admin/organizer/status", headers=admin_headers)).json()
    assert body["ai"]["code"] == "no_credits" and body["ai"]["task"] == "heads"
    ai_health.record_success("ecole-test")
    assert (await client.get("/api/admin/organizer/status", headers=admin_headers)).json()["ai"] is None
