"""
Backend test suite for Dossier Camion STEF — Iteration 2.

Covers:
- Auth refactor: login schema {first_name, password}
- Users CRUD: create/update/delete with new password rules (>= 4 chars)
- PATCH /api/users/{id}: role change protection, duplicate prénom guard, password update
- Folders: DELETE /api/folders/{id} (bureau only)
- PATCH /api/folders/{id}: no history push when nothing changes
- WebSocket: bureau receives 'new_folder', cariste receives 'status_change'
"""
import os
import json
import uuid
import asyncio
import pytest
import requests
import websockets

BASE_URL = os.environ.get('REACT_APP_BACKEND_URL', '').rstrip('/')
if not BASE_URL:
    try:
        with open('/app/frontend/.env') as f:
            for line in f:
                if line.startswith('REACT_APP_BACKEND_URL='):
                    BASE_URL = line.split('=', 1)[1].strip().rstrip('/')
                    break
    except Exception:
        pass

API = f"{BASE_URL}/api"

TINY_JPEG = (
    "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD//gA7Q1JFQVRPUjogZ2QtanBlZyB2MS4w"
    "ICh1c2luZyBJSkcgSlBFRyB2NjIpLCBxdWFsaXR5ID0gOTAK"
)

# Admin: migrated from iteration 1, so password = old PIN "1234"
ADMIN_PASSWORD_CANDIDATES = ["1234", "admin1234"]


# ------------------ Fixtures ------------------
@pytest.fixture(scope="session")
def admin_token():
    for pwd in ADMIN_PASSWORD_CANDIDATES:
        r = requests.post(f"{API}/auth/login", json={"first_name": "Admin", "password": pwd})
        if r.status_code == 200:
            return r.json()["token"]
    pytest.fail(f"Admin login failed with all candidate passwords: {ADMIN_PASSWORD_CANDIDATES}")


@pytest.fixture(scope="session")
def admin_user(admin_token):
    r = requests.get(f"{API}/auth/me", headers={"Authorization": f"Bearer {admin_token}"})
    assert r.status_code == 200
    return r.json()


@pytest.fixture(scope="session")
def cariste_creds():
    suf = uuid.uuid4().hex[:6]
    return {"first_name": f"Jean{suf}", "last_name": f"Dupont{suf}",
            "password": "pass1234", "role": "cariste"}


@pytest.fixture(scope="session")
def cariste_user(admin_token, cariste_creds):
    r = requests.post(f"{API}/users", json=cariste_creds, headers=H(admin_token))
    assert r.status_code == 200, f"Cariste create failed: {r.status_code} {r.text}"
    return r.json()


@pytest.fixture(scope="session")
def cariste_token(cariste_user, cariste_creds):
    r = requests.post(f"{API}/auth/login", json={
        "first_name": cariste_creds["first_name"],
        "password": cariste_creds["password"],
    })
    assert r.status_code == 200, f"Cariste login failed: {r.status_code} {r.text}"
    return r.json()["token"]


def H(token):
    return {"Authorization": f"Bearer {token}"}


# ------------------ Auth tests ------------------
class TestAuth:
    def test_login_admin_success(self, admin_token):
        assert isinstance(admin_token, str) and len(admin_token) > 10

    def test_login_wrong_password(self):
        r = requests.post(f"{API}/auth/login",
                          json={"first_name": "Admin", "password": "wrong-pw"})
        assert r.status_code == 401

    def test_login_nonexistent_first_name(self):
        r = requests.post(f"{API}/auth/login",
                          json={"first_name": f"Nobody_{uuid.uuid4().hex[:6]}",
                                "password": "anything"})
        assert r.status_code == 401

    def test_login_schema_rejects_missing_fields(self):
        r = requests.post(f"{API}/auth/login", json={"first_name": "Admin"})
        assert r.status_code in (400, 422)

    def test_me_endpoint(self, admin_token):
        r = requests.get(f"{API}/auth/me", headers=H(admin_token))
        assert r.status_code == 200
        data = r.json()
        assert data["role"] == "bureau"
        assert data["first_name"] == "Admin"
        assert "password_hash" not in data
        assert "pin_hash" not in data


# ------------------ User CRUD tests ------------------
class TestUserCreate:
    def test_create_user_short_password(self, admin_token):
        r = requests.post(f"{API}/users", json={
            "first_name": f"Bad{uuid.uuid4().hex[:4]}", "last_name": "Pw",
            "password": "abc", "role": "cariste"
        }, headers=H(admin_token))
        assert r.status_code == 400

    def test_create_user_duplicate_first_name(self, admin_token, cariste_creds, cariste_user):
        r = requests.post(f"{API}/users", json={
            "first_name": cariste_creds["first_name"], "last_name": "Other",
            "password": "pass5678", "role": "cariste"
        }, headers=H(admin_token))
        assert r.status_code == 400

    def test_create_user_last_name_optional(self, admin_token):
        suf = uuid.uuid4().hex[:6]
        r = requests.post(f"{API}/users", json={
            "first_name": f"NoLast{suf}", "password": "pass1234", "role": "cariste"
        }, headers=H(admin_token))
        assert r.status_code == 200, r.text
        uid = r.json()["id"]
        # cleanup
        requests.delete(f"{API}/users/{uid}", headers=H(admin_token))


class TestUserUpdate:
    def test_patch_user_password_and_login(self, admin_token):
        suf = uuid.uuid4().hex[:6]
        fn = f"PwUser{suf}"
        r = requests.post(f"{API}/users", json={
            "first_name": fn, "last_name": "X", "password": "pass1234", "role": "cariste"
        }, headers=H(admin_token))
        assert r.status_code == 200
        uid = r.json()["id"]

        # Update password
        r2 = requests.patch(f"{API}/users/{uid}",
                            json={"password": "newpass99"}, headers=H(admin_token))
        assert r2.status_code == 200, r2.text

        # Old password fails
        r3 = requests.post(f"{API}/auth/login",
                           json={"first_name": fn, "password": "pass1234"})
        assert r3.status_code == 401
        # New password works
        r4 = requests.post(f"{API}/auth/login",
                           json={"first_name": fn, "password": "newpass99"})
        assert r4.status_code == 200

        # cleanup
        requests.delete(f"{API}/users/{uid}", headers=H(admin_token))

    def test_patch_user_short_password(self, admin_token, cariste_user):
        r = requests.patch(f"{API}/users/{cariste_user['id']}",
                           json={"password": "ab"}, headers=H(admin_token))
        assert r.status_code == 400

    def test_patch_user_first_last_name(self, admin_token):
        suf = uuid.uuid4().hex[:6]
        r = requests.post(f"{API}/users", json={
            "first_name": f"Ren{suf}", "last_name": "Old",
            "password": "pass1234", "role": "cariste"
        }, headers=H(admin_token))
        assert r.status_code == 200
        uid = r.json()["id"]
        new_fn = f"NewName{suf}"
        r2 = requests.patch(f"{API}/users/{uid}",
                            json={"first_name": new_fn, "last_name": "New"},
                            headers=H(admin_token))
        assert r2.status_code == 200, r2.text
        body = r2.json()
        assert body["first_name"] == new_fn
        assert body["last_name"] == "New"
        # Login with new first_name works (password unchanged)
        r3 = requests.post(f"{API}/auth/login",
                           json={"first_name": new_fn, "password": "pass1234"})
        assert r3.status_code == 200
        requests.delete(f"{API}/users/{uid}", headers=H(admin_token))

    def test_patch_duplicate_first_name(self, admin_token, cariste_user, cariste_creds):
        # Create temp user, try to rename to existing cariste's first_name
        suf = uuid.uuid4().hex[:6]
        r = requests.post(f"{API}/users", json={
            "first_name": f"Tmp{suf}", "password": "pass1234", "role": "cariste"
        }, headers=H(admin_token))
        uid = r.json()["id"]
        r2 = requests.patch(f"{API}/users/{uid}",
                            json={"first_name": cariste_creds["first_name"]},
                            headers=H(admin_token))
        assert r2.status_code == 400
        requests.delete(f"{API}/users/{uid}", headers=H(admin_token))

    def test_patch_self_role_demotion_blocked(self, admin_token, admin_user):
        r = requests.patch(f"{API}/users/{admin_user['id']}",
                           json={"role": "cariste"}, headers=H(admin_token))
        assert r.status_code == 400

    def test_patch_user_role_promotion(self, admin_token):
        suf = uuid.uuid4().hex[:6]
        r = requests.post(f"{API}/users", json={
            "first_name": f"Promo{suf}", "password": "pass1234", "role": "cariste"
        }, headers=H(admin_token))
        uid = r.json()["id"]
        r2 = requests.patch(f"{API}/users/{uid}",
                            json={"role": "bureau"}, headers=H(admin_token))
        assert r2.status_code == 200
        assert r2.json()["role"] == "bureau"
        requests.delete(f"{API}/users/{uid}", headers=H(admin_token))

    def test_patch_user_as_cariste_forbidden(self, cariste_token, admin_user):
        r = requests.patch(f"{API}/users/{admin_user['id']}",
                           json={"first_name": "Hack"}, headers=H(cariste_token))
        assert r.status_code == 403

    def test_patch_user_not_found(self, admin_token):
        r = requests.patch(f"{API}/users/{uuid.uuid4()}",
                           json={"first_name": "X"}, headers=H(admin_token))
        assert r.status_code == 404


# ------------------ Folder tests ------------------
class TestFolders:
    folder_id = None

    def test_create_folder(self, cariste_token, cariste_user):
        r = requests.post(f"{API}/folders", json={
            "plate": "it2-001-xx", "problem": "Pneu crevé",
            "photos": [TINY_JPEG],
        }, headers=H(cariste_token))
        assert r.status_code == 200, r.text
        TestFolders.folder_id = r.json()["id"]

    def test_list_folders_unchanged(self, cariste_token, admin_token):
        r = requests.get(f"{API}/folders", headers=H(cariste_token))
        assert r.status_code == 200
        ids_cariste = {f["id"] for f in r.json()}
        assert TestFolders.folder_id in ids_cariste
        r2 = requests.get(f"{API}/folders", headers=H(admin_token))
        assert r2.status_code == 200
        assert TestFolders.folder_id in {f["id"] for f in r2.json()}

    def test_patch_folder_no_change_no_history(self, cariste_token):
        # GET current history length
        r0 = requests.get(f"{API}/folders/{TestFolders.folder_id}", headers=H(cariste_token))
        assert r0.status_code == 200
        before = len(r0.json()["history"])

        # PATCH with same problem (no actual change), no photos
        r = requests.patch(f"{API}/folders/{TestFolders.folder_id}",
                           json={"problem": r0.json()["problem"]},
                           headers=H(cariste_token))
        assert r.status_code == 200
        after = len(r.json()["history"])
        assert after == before, f"History grew despite no change: {before}→{after}"

        # PATCH with empty body
        r2 = requests.patch(f"{API}/folders/{TestFolders.folder_id}",
                            json={}, headers=H(cariste_token))
        assert r2.status_code == 200
        assert len(r2.json()["history"]) == before

    def test_patch_folder_with_real_change_adds_history(self, cariste_token):
        r0 = requests.get(f"{API}/folders/{TestFolders.folder_id}", headers=H(cariste_token))
        before = len(r0.json()["history"])
        r = requests.patch(f"{API}/folders/{TestFolders.folder_id}",
                           json={"problem": "Vraiment modifié"},
                           headers=H(cariste_token))
        assert r.status_code == 200
        after = len(r.json()["history"])
        assert after == before + 1

    def test_delete_folder_as_cariste_forbidden(self, cariste_token):
        r = requests.delete(f"{API}/folders/{TestFolders.folder_id}",
                            headers=H(cariste_token))
        assert r.status_code == 403

    def test_delete_folder_not_found(self, admin_token):
        r = requests.delete(f"{API}/folders/{uuid.uuid4()}", headers=H(admin_token))
        assert r.status_code == 404

    def test_delete_folder_as_bureau(self, admin_token, cariste_token):
        # Create a fresh folder, delete it
        r = requests.post(f"{API}/folders", json={
            "plate": "del-001-aa", "problem": "to delete",
            "photos": [TINY_JPEG],
        }, headers=H(cariste_token))
        fid = r.json()["id"]
        r2 = requests.delete(f"{API}/folders/{fid}", headers=H(admin_token))
        assert r2.status_code == 200
        # GET should now 404
        r3 = requests.get(f"{API}/folders/{fid}", headers=H(admin_token))
        assert r3.status_code == 404


# ------------------ WebSocket tests ------------------
def _ws_url(token):
    base = BASE_URL.replace("https://", "wss://").replace("http://", "ws://")
    return f"{base}/api/ws/notifications?token={token}"


class TestWebSocket:
    def test_bureau_receives_new_folder(self, admin_token, cariste_token):
        async def runner():
            async with websockets.connect(_ws_url(admin_token), open_timeout=10) as ws:
                async def create():
                    await asyncio.sleep(0.6)
                    return requests.post(f"{API}/folders", json={
                        "plate": "ws-new-01", "problem": "ws bureau",
                        "photos": [TINY_JPEG],
                    }, headers=H(cariste_token))
                creator = asyncio.create_task(create())
                msg = await asyncio.wait_for(ws.recv(), timeout=8)
                created = await creator
                return json.loads(msg), created.json()["id"]
        msg, fid = asyncio.run(runner())
        assert msg["type"] == "new_folder"
        assert msg["folder"]["plate"] == "WS-NEW-01"
        # cleanup
        requests.delete(f"{API}/folders/{fid}", headers=H(admin_token))

    def test_cariste_receives_status_change(self, admin_token, cariste_token, cariste_user):
        # Create folder as cariste
        r = requests.post(f"{API}/folders", json={
            "plate": "ws-sc-01", "problem": "ws status",
            "photos": [TINY_JPEG],
        }, headers=H(cariste_token))
        assert r.status_code == 200
        fid = r.json()["id"]

        async def runner():
            async with websockets.connect(_ws_url(cariste_token), open_timeout=10) as ws:
                async def trigger():
                    await asyncio.sleep(0.6)
                    return requests.patch(f"{API}/folders/{fid}/status",
                                          json={"action": "take"},
                                          headers=H(admin_token))
                t = asyncio.create_task(trigger())
                msg = await asyncio.wait_for(ws.recv(), timeout=8)
                await t
                return json.loads(msg)

        msg = asyncio.run(runner())
        assert msg["type"] == "status_change"
        assert msg["folder"]["id"] == fid
        assert msg["folder"]["plate"] == "WS-SC-01"
        assert msg["folder"]["status"] == "en_cours"
        assert "label" in msg["folder"] and "by_name" in msg["folder"]
        # cleanup
        requests.delete(f"{API}/folders/{fid}", headers=H(admin_token))

    def test_bureau_does_NOT_receive_status_change(self, admin_token, cariste_token):
        """Status change must NOT broadcast to bureau channel."""
        r = requests.post(f"{API}/folders", json={
            "plate": "ws-nb-01", "problem": "no bureau notif",
            "photos": [TINY_JPEG],
        }, headers=H(cariste_token))
        fid = r.json()["id"]

        async def runner():
            async with websockets.connect(_ws_url(admin_token), open_timeout=10) as ws:
                async def trigger():
                    await asyncio.sleep(0.6)
                    return requests.patch(f"{API}/folders/{fid}/status",
                                          json={"action": "take"},
                                          headers=H(admin_token))
                t = asyncio.create_task(trigger())
                got = None
                try:
                    msg = await asyncio.wait_for(ws.recv(), timeout=3)
                    got = json.loads(msg)
                except asyncio.TimeoutError:
                    pass
                await t
                return got

        msg = asyncio.run(runner())
        # Either nothing, or anything BUT a status_change for this folder
        if msg is not None:
            assert msg.get("type") != "status_change", \
                f"Bureau unexpectedly received status_change: {msg}"
        requests.delete(f"{API}/folders/{fid}", headers=H(admin_token))



# ------------------ Folder Append tests (Iteration 3) ------------------
def _create_folder(token, plate="app-001", problem="initial problem"):
    r = requests.post(f"{API}/folders", json={
        "plate": plate, "problem": problem, "photos": [TINY_JPEG],
    }, headers=H(token))
    assert r.status_code == 200, r.text
    return r.json()


class TestFolderAppend:
    def test_append_text_by_cariste_owner(self, cariste_token, admin_token):
        folder = _create_folder(cariste_token, plate="app-txt-01")
        fid = folder["id"]
        before_hist = len(folder["history"])
        before_problem = folder["problem"]

        r = requests.post(f"{API}/folders/{fid}/append",
                          json={"text": "Ajout important"},
                          headers=H(cariste_token))
        assert r.status_code == 200, r.text
        body = r.json()
        # problem field appended with header
        assert body["problem"].startswith(before_problem)
        assert "Ajout important" in body["problem"]
        assert "— Ajout du" in body["problem"]
        assert "par" in body["problem"]
        # history entry added with action='appended'
        assert len(body["history"]) == before_hist + 1
        last = body["history"][-1]
        assert last["action"] == "appended"
        assert last["by_id"] == folder["cariste_id"]

        # cleanup
        requests.delete(f"{API}/folders/{fid}", headers=H(admin_token))

    def test_append_photos_concat_not_replace(self, cariste_token, admin_token):
        folder = _create_folder(cariste_token, plate="app-pho-01")
        fid = folder["id"]
        # initial folder has 1 photo
        r = requests.post(f"{API}/folders/{fid}/append",
                          json={"photos": [TINY_JPEG, TINY_JPEG]},
                          headers=H(cariste_token))
        assert r.status_code == 200, r.text
        body = r.json()
        assert len(body["photos"]) == 3, f"expected 3, got {len(body['photos'])}"
        # history entry added
        assert body["history"][-1]["action"] == "appended"
        requests.delete(f"{API}/folders/{fid}", headers=H(admin_token))

    def test_append_text_and_photos_together(self, cariste_token, admin_token):
        folder = _create_folder(cariste_token, plate="app-mix-01")
        fid = folder["id"]
        before_problem = folder["problem"]
        r = requests.post(f"{API}/folders/{fid}/append",
                          json={"text": "Mixte", "photos": [TINY_JPEG]},
                          headers=H(cariste_token))
        assert r.status_code == 200, r.text
        body = r.json()
        assert "Mixte" in body["problem"]
        assert body["problem"].startswith(before_problem)
        assert len(body["photos"]) == 2
        assert body["history"][-1]["action"] == "appended"
        requests.delete(f"{API}/folders/{fid}", headers=H(admin_token))

    def test_append_empty_returns_400(self, cariste_token, admin_token):
        folder = _create_folder(cariste_token, plate="app-emp-01")
        fid = folder["id"]
        # No text, no photos
        r = requests.post(f"{API}/folders/{fid}/append",
                          json={}, headers=H(cariste_token))
        assert r.status_code == 400
        # Empty whitespace text + empty list
        r2 = requests.post(f"{API}/folders/{fid}/append",
                           json={"text": "   ", "photos": []},
                           headers=H(cariste_token))
        assert r2.status_code == 400
        requests.delete(f"{API}/folders/{fid}", headers=H(admin_token))

    def test_append_exceeds_30_photos_returns_400(self, cariste_token, admin_token):
        folder = _create_folder(cariste_token, plate="app-cap-01")
        fid = folder["id"]
        # folder has 1 photo; adding 30 would exceed (1 + 30 = 31)
        r = requests.post(f"{API}/folders/{fid}/append",
                          json={"photos": [TINY_JPEG] * 30},
                          headers=H(cariste_token))
        assert r.status_code == 400
        # Exact boundary: 1 existing + 29 new = 30 should succeed
        r2 = requests.post(f"{API}/folders/{fid}/append",
                           json={"photos": [TINY_JPEG] * 29},
                           headers=H(cariste_token))
        assert r2.status_code == 200, r2.text
        assert len(r2.json()["photos"]) == 30
        requests.delete(f"{API}/folders/{fid}", headers=H(admin_token))

    def test_append_by_non_owner_cariste_forbidden(self, admin_token, cariste_token):
        # Create a folder owned by cariste1
        folder = _create_folder(cariste_token, plate="app-403-01")
        fid = folder["id"]
        # Create another cariste
        suf = uuid.uuid4().hex[:6]
        creds = {"first_name": f"Other{suf}", "password": "pass1234", "role": "cariste"}
        ru = requests.post(f"{API}/users", json=creds, headers=H(admin_token))
        assert ru.status_code == 200
        other_id = ru.json()["id"]
        rl = requests.post(f"{API}/auth/login",
                           json={"first_name": creds["first_name"], "password": creds["password"]})
        other_token = rl.json()["token"]

        r = requests.post(f"{API}/folders/{fid}/append",
                          json={"text": "intrusion"},
                          headers=H(other_token))
        assert r.status_code == 403
        # cleanup
        requests.delete(f"{API}/folders/{fid}", headers=H(admin_token))
        requests.delete(f"{API}/users/{other_id}", headers=H(admin_token))

    def test_append_by_bureau_on_any_folder(self, admin_token, cariste_token):
        folder = _create_folder(cariste_token, plate="app-bur-01")
        fid = folder["id"]
        r = requests.post(f"{API}/folders/{fid}/append",
                          json={"text": "Note bureau"},
                          headers=H(admin_token))
        assert r.status_code == 200, r.text
        body = r.json()
        assert "Note bureau" in body["problem"]
        assert body["history"][-1]["action"] == "appended"
        # by_id should be the admin/bureau user
        assert body["history"][-1]["by_id"] != folder["cariste_id"]
        requests.delete(f"{API}/folders/{fid}", headers=H(admin_token))

    def test_append_folder_not_found(self, cariste_token):
        r = requests.post(f"{API}/folders/{uuid.uuid4()}/append",
                          json={"text": "x"}, headers=H(cariste_token))
        assert r.status_code == 404

    def test_append_by_cariste_notifies_bureau_via_ws(self, admin_token, cariste_token):
        """When cariste appends, bureau WS should receive 'folder_appended'."""
        folder = _create_folder(cariste_token, plate="app-ws-01")
        fid = folder["id"]

        async def runner():
            async with websockets.connect(_ws_url(admin_token), open_timeout=10) as ws:
                async def trigger():
                    await asyncio.sleep(0.6)
                    return requests.post(f"{API}/folders/{fid}/append",
                                         json={"text": "via ws"},
                                         headers=H(cariste_token))
                t = asyncio.create_task(trigger())
                # May receive other messages; loop until we find folder_appended for our folder
                got = None
                try:
                    deadline = asyncio.get_event_loop().time() + 8
                    while True:
                        remaining = deadline - asyncio.get_event_loop().time()
                        if remaining <= 0:
                            break
                        msg = await asyncio.wait_for(ws.recv(), timeout=remaining)
                        parsed = json.loads(msg)
                        if parsed.get("type") == "folder_appended" and \
                                parsed.get("folder", {}).get("id") == fid:
                            got = parsed
                            break
                except asyncio.TimeoutError:
                    pass
                await t
                return got

        msg = asyncio.run(runner())
        assert msg is not None, "Bureau did not receive folder_appended notification"
        assert msg["type"] == "folder_appended"
        assert msg["folder"]["id"] == fid
        assert msg["folder"]["plate"] == "APP-WS-01"
        assert "cariste_name" in msg["folder"]
        assert "label" in msg["folder"]
        requests.delete(f"{API}/folders/{fid}", headers=H(admin_token))

    def test_append_by_bureau_does_NOT_notify_bureau_ws(self, admin_token, cariste_token):
        """When bureau appends, no folder_appended broadcast to bureau channel."""
        folder = _create_folder(cariste_token, plate="app-ws-02")
        fid = folder["id"]

        async def runner():
            async with websockets.connect(_ws_url(admin_token), open_timeout=10) as ws:
                async def trigger():
                    await asyncio.sleep(0.6)
                    return requests.post(f"{API}/folders/{fid}/append",
                                         json={"text": "bureau silent"},
                                         headers=H(admin_token))
                t = asyncio.create_task(trigger())
                got = None
                try:
                    deadline = asyncio.get_event_loop().time() + 3
                    while True:
                        remaining = deadline - asyncio.get_event_loop().time()
                        if remaining <= 0:
                            break
                        msg = await asyncio.wait_for(ws.recv(), timeout=remaining)
                        parsed = json.loads(msg)
                        if parsed.get("type") == "folder_appended" and \
                                parsed.get("folder", {}).get("id") == fid:
                            got = parsed
                            break
                except asyncio.TimeoutError:
                    pass
                await t
                return got

        msg = asyncio.run(runner())
        assert msg is None, f"Bureau should not receive folder_appended for its own append, got {msg}"
        requests.delete(f"{API}/folders/{fid}", headers=H(admin_token))
