#!/usr/bin/env python3
"""
Test suite for Speaker API v1 endpoints.

Covers:
  - PUT  /recordings/<id>/speakers/assign   (17 tests)
  - POST /recordings/<id>/speakers/identify (10 tests)
  - PUT  /settings/auto-summarization        (5 tests)
  - Regression for GET /speakers and GET /recordings/<id>/speakers (2 tests)

Pattern follows tests/test_api_v1_upload.py — standalone, no pytest fixtures.
"""

import json
import secrets
import sys
import os
from unittest.mock import patch, MagicMock

# Add parent directory so we can import the app
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from src.app import app, db
from src.models import User, APIToken, Recording, Speaker
from src.utils.token_auth import hash_token

# ---------------------------------------------------------------------------
# Test data constants
# ---------------------------------------------------------------------------

SAMPLE_TRANSCRIPTION_JSON = json.dumps([
    {"speaker": "SPEAKER_00", "sentence": "Hi, I'm Alice."},
    {"speaker": "SPEAKER_01", "sentence": "Hello Alice, I'm Bob."},
    {"speaker": "SPEAKER_00", "sentence": "Nice to meet you, Bob."},
])

SAMPLE_TRANSCRIPTION_TEXT = (
    "[SPEAKER_00]: Hi, I'm Alice.\n"
    "[SPEAKER_01]: Hello Alice, I'm Bob.\n"
    "[SPEAKER_00]: Nice to meet you, Bob."
)

SAMPLE_EMBEDDINGS = json.dumps({
    "SPEAKER_00": [0.1] * 256,
    "SPEAKER_01": [0.2] * 256,
})

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _get_or_create_test_user(suffix=""):
    """Get or create a test user. Returns (user, created_bool)."""
    username = f"speaker_test_user{suffix}"
    user = User.query.filter_by(username=username).first()
    created = False
    if not user:
        user = User(
            username=username,
            email=f"{username}@local.test",
            name="Test User" if not suffix else None,
        )
        db.session.add(user)
        db.session.commit()
        created = True
    return user, created


def _create_api_token(user):
    """Create a fresh API token. Returns (token_record, plaintext)."""
    plaintext = f"test-token-{secrets.token_urlsafe(16)}"
    token = APIToken(
        user_id=user.id,
        token_hash=hash_token(plaintext),
        name="test-api-token",
    )
    db.session.add(token)
    db.session.commit()
    return token, plaintext


def _create_test_recording(user, transcription=None, speaker_embeddings=None, status="COMPLETED"):
    """Create a Recording owned by *user*."""
    rec = Recording(
        user_id=user.id,
        title="Test Recording",
        status=status,
        transcription=transcription,
        speaker_embeddings=speaker_embeddings,
    )
    db.session.add(rec)
    db.session.commit()
    return rec


def _create_test_speaker(user, name="Alice"):
    """Create a Speaker owned by *user*."""
    speaker = Speaker(name=name, user_id=user.id)
    db.session.add(speaker)
    db.session.commit()
    return speaker


def _cleanup(*objects):
    """Delete DB objects in reverse order, committing once."""
    for obj in reversed(objects):
        try:
            db.session.delete(obj)
        except Exception:
            db.session.rollback()
            try:
                merged = db.session.merge(obj)
                db.session.delete(merged)
            except Exception:
                pass
    db.session.commit()


# =========================================================================
# Group 1: PUT /recordings/<id>/speakers/assign  (17 tests)
# =========================================================================


def test_assign_no_auth():
    """No token -> 302 redirect (Flask-Login)."""
    with app.app_context():
        user, cu = _get_or_create_test_user()
        rec = _create_test_recording(user, transcription=SAMPLE_TRANSCRIPTION_JSON)
        client = app.test_client()
        try:
            resp = client.put(f"/api/v1/recordings/{rec.id}/speakers/assign",
                              json={"speaker_map": {}})
            assert resp.status_code in (302, 401), f"Expected 302/401, got {resp.status_code}"
            return True
        finally:
            _cleanup(rec)
            if cu:
                _cleanup(user)


def test_assign_recording_not_found():
    """Nonexistent recording ID -> 404."""
    with app.app_context():
        user, cu = _get_or_create_test_user()
        token_rec, token = _create_api_token(user)
        client = app.test_client()
        try:
            resp = client.put("/api/v1/recordings/999999/speakers/assign",
                              headers={"X-API-Token": token},
                              json={"speaker_map": {}})
            assert resp.status_code == 404, f"Expected 404, got {resp.status_code}"
            return True
        finally:
            _cleanup(token_rec)
            if cu:
                _cleanup(user)


def test_assign_wrong_user_recording():
    """Other user's recording -> 403."""
    with app.app_context():
        owner, co = _get_or_create_test_user("_owner")
        other, cu = _get_or_create_test_user("_other")
        token_rec, token = _create_api_token(other)
        rec = _create_test_recording(owner, transcription=SAMPLE_TRANSCRIPTION_JSON)
        client = app.test_client()
        try:
            resp = client.put(f"/api/v1/recordings/{rec.id}/speakers/assign",
                              headers={"X-API-Token": token},
                              json={"speaker_map": {"SPEAKER_00": "Alice"}})
            assert resp.status_code == 403, f"Expected 403, got {resp.status_code}"
            return True
        finally:
            _cleanup(rec, token_rec)
            if cu:
                _cleanup(other)
            if co:
                _cleanup(owner)


def test_assign_missing_speaker_map():
    """Body {} -> 400 'speaker_map is required'."""
    with app.app_context():
        user, cu = _get_or_create_test_user()
        token_rec, token = _create_api_token(user)
        rec = _create_test_recording(user, transcription=SAMPLE_TRANSCRIPTION_JSON)
        client = app.test_client()
        try:
            resp = client.put(f"/api/v1/recordings/{rec.id}/speakers/assign",
                              headers={"X-API-Token": token},
                              json={})
            assert resp.status_code == 400, f"Expected 400, got {resp.status_code}"
            body = resp.get_json()
            assert "speaker_map" in body.get("error", "").lower(), f"Unexpected error: {body}"
            return True
        finally:
            _cleanup(rec, token_rec)
            if cu:
                _cleanup(user)


def test_assign_invalid_speaker_map_type():
    """speaker_map: 'string' -> 400."""
    with app.app_context():
        user, cu = _get_or_create_test_user()
        token_rec, token = _create_api_token(user)
        rec = _create_test_recording(user, transcription=SAMPLE_TRANSCRIPTION_JSON)
        client = app.test_client()
        try:
            resp = client.put(f"/api/v1/recordings/{rec.id}/speakers/assign",
                              headers={"X-API-Token": token},
                              json={"speaker_map": "not a dict"})
            assert resp.status_code == 400, f"Expected 400, got {resp.status_code}"
            return True
        finally:
            _cleanup(rec, token_rec)
            if cu:
                _cleanup(user)


def test_assign_string_value_json_transcript():
    """Happy path: string names update JSON segments + participants."""
    with app.app_context():
        user, cu = _get_or_create_test_user()
        token_rec, token = _create_api_token(user)
        rec = _create_test_recording(user, transcription=SAMPLE_TRANSCRIPTION_JSON)
        client = app.test_client()
        try:
            resp = client.put(f"/api/v1/recordings/{rec.id}/speakers/assign",
                              headers={"X-API-Token": token},
                              json={"speaker_map": {"SPEAKER_00": "Alice", "SPEAKER_01": "Bob"}})
            assert resp.status_code == 200, f"Expected 200, got {resp.status_code}"
            body = resp.get_json()
            assert body.get("success") is True
            # Verify participants
            participants = body["recording"]["participants"]
            assert "Alice" in participants and "Bob" in participants
            # Verify transcription was updated
            db.session.refresh(rec)
            segments = json.loads(rec.transcription)
            assert segments[0]["speaker"] == "Alice"
            assert segments[1]["speaker"] == "Bob"
            return True
        finally:
            _cleanup(rec, token_rec)
            if cu:
                _cleanup(user)


def test_assign_object_value_with_name():
    """Happy path: {name, isMe} object format."""
    with app.app_context():
        user, cu = _get_or_create_test_user()
        token_rec, token = _create_api_token(user)
        rec = _create_test_recording(user, transcription=SAMPLE_TRANSCRIPTION_JSON)
        client = app.test_client()
        try:
            resp = client.put(f"/api/v1/recordings/{rec.id}/speakers/assign",
                              headers={"X-API-Token": token},
                              json={"speaker_map": {
                                  "SPEAKER_00": {"name": "Alice", "isMe": False},
                              }})
            assert resp.status_code == 200, f"Expected 200, got {resp.status_code}"
            db.session.refresh(rec)
            segments = json.loads(rec.transcription)
            assert segments[0]["speaker"] == "Alice"
            return True
        finally:
            _cleanup(rec, token_rec)
            if cu:
                _cleanup(user)


def test_assign_is_me_flag_with_user_name():
    """isMe: true resolves to user.name."""
    with app.app_context():
        user, cu = _get_or_create_test_user()  # user.name == "Test User"
        token_rec, token = _create_api_token(user)
        rec = _create_test_recording(user, transcription=SAMPLE_TRANSCRIPTION_JSON)
        client = app.test_client()
        try:
            resp = client.put(f"/api/v1/recordings/{rec.id}/speakers/assign",
                              headers={"X-API-Token": token},
                              json={"speaker_map": {
                                  "SPEAKER_00": {"name": "", "isMe": True},
                              }})
            assert resp.status_code == 200, f"Expected 200, got {resp.status_code}"
            db.session.refresh(rec)
            segments = json.loads(rec.transcription)
            assert segments[0]["speaker"] == "Test User", f"Got {segments[0]['speaker']}"
            return True
        finally:
            _cleanup(rec, token_rec)
            if cu:
                _cleanup(user)


def test_assign_is_me_flag_without_user_name():
    """isMe: true falls back to 'Me' when user.name is None."""
    with app.app_context():
        user, cu = _get_or_create_test_user("_noname")
        # Ensure user.name is None
        user.name = None
        db.session.commit()
        token_rec, token = _create_api_token(user)
        rec = _create_test_recording(user, transcription=SAMPLE_TRANSCRIPTION_JSON)
        client = app.test_client()
        try:
            resp = client.put(f"/api/v1/recordings/{rec.id}/speakers/assign",
                              headers={"X-API-Token": token},
                              json={"speaker_map": {
                                  "SPEAKER_00": {"name": "", "isMe": True},
                              }})
            assert resp.status_code == 200, f"Expected 200, got {resp.status_code}"
            db.session.refresh(rec)
            segments = json.loads(rec.transcription)
            assert segments[0]["speaker"] == "Me", f"Got {segments[0]['speaker']}"
            return True
        finally:
            _cleanup(rec, token_rec)
            if cu:
                _cleanup(user)


def test_assign_plain_text_transcript():
    """Replaces [SPEAKER_XX] in plain text format."""
    with app.app_context():
        user, cu = _get_or_create_test_user()
        token_rec, token = _create_api_token(user)
        rec = _create_test_recording(user, transcription=SAMPLE_TRANSCRIPTION_TEXT)
        client = app.test_client()
        try:
            resp = client.put(f"/api/v1/recordings/{rec.id}/speakers/assign",
                              headers={"X-API-Token": token},
                              json={"speaker_map": {"SPEAKER_00": "Alice", "SPEAKER_01": "Bob"}})
            assert resp.status_code == 200, f"Expected 200, got {resp.status_code}"
            db.session.refresh(rec)
            assert "[Alice]" in rec.transcription
            assert "[Bob]" in rec.transcription
            assert "[SPEAKER_00]" not in rec.transcription
            return True
        finally:
            _cleanup(rec, token_rec)
            if cu:
                _cleanup(user)


def test_assign_speaker_xx_filtered_from_participants():
    """Unresolved SPEAKER_XX labels excluded from participants."""
    with app.app_context():
        user, cu = _get_or_create_test_user()
        token_rec, token = _create_api_token(user)
        rec = _create_test_recording(user, transcription=SAMPLE_TRANSCRIPTION_JSON)
        client = app.test_client()
        try:
            # Only assign one speaker - SPEAKER_01 stays unresolved
            resp = client.put(f"/api/v1/recordings/{rec.id}/speakers/assign",
                              headers={"X-API-Token": token},
                              json={"speaker_map": {"SPEAKER_00": "Alice"}})
            assert resp.status_code == 200, f"Expected 200, got {resp.status_code}"
            body = resp.get_json()
            participants = body["recording"]["participants"]
            assert "SPEAKER_01" not in participants, f"SPEAKER_01 should be filtered: {participants}"
            assert "Alice" in participants
            return True
        finally:
            _cleanup(rec, token_rec)
            if cu:
                _cleanup(user)


def test_assign_invalid_value_type():
    """Array value -> 400 'Invalid value type'."""
    with app.app_context():
        user, cu = _get_or_create_test_user()
        token_rec, token = _create_api_token(user)
        rec = _create_test_recording(user, transcription=SAMPLE_TRANSCRIPTION_JSON)
        client = app.test_client()
        try:
            resp = client.put(f"/api/v1/recordings/{rec.id}/speakers/assign",
                              headers={"X-API-Token": token},
                              json={"speaker_map": {"SPEAKER_00": [1, 2, 3]}})
            assert resp.status_code == 400, f"Expected 400, got {resp.status_code}"
            body = resp.get_json()
            assert "invalid value type" in body.get("error", "").lower(), f"Unexpected: {body}"
            return True
        finally:
            _cleanup(rec, token_rec)
            if cu:
                _cleanup(user)


def test_assign_empty_speaker_map():
    """Empty speaker_map {} -> 200 with no changes."""
    with app.app_context():
        user, cu = _get_or_create_test_user()
        token_rec, token = _create_api_token(user)
        rec = _create_test_recording(user, transcription=SAMPLE_TRANSCRIPTION_JSON)
        client = app.test_client()
        try:
            resp = client.put(f"/api/v1/recordings/{rec.id}/speakers/assign",
                              headers={"X-API-Token": token},
                              json={"speaker_map": {}})
            assert resp.status_code == 200, f"Expected 200, got {resp.status_code}"
            body = resp.get_json()
            assert body.get("success") is True
            # Transcription should be unchanged
            db.session.refresh(rec)
            segments = json.loads(rec.transcription)
            assert segments[0]["speaker"] == "SPEAKER_00"
            return True
        finally:
            _cleanup(rec, token_rec)
            if cu:
                _cleanup(user)


def test_assign_regenerate_summary():
    """regenerate_summary: true -> job_queue.enqueue called, summary_queued: true."""
    with app.app_context():
        user, cu = _get_or_create_test_user()
        token_rec, token = _create_api_token(user)
        rec = _create_test_recording(user, transcription=SAMPLE_TRANSCRIPTION_JSON)
        client = app.test_client()
        try:
            mock_jq = MagicMock()
            mock_jq.enqueue = MagicMock(return_value="job-123")
            with patch("src.services.job_queue.job_queue", mock_jq):
                resp = client.put(f"/api/v1/recordings/{rec.id}/speakers/assign",
                                  headers={"X-API-Token": token},
                                  json={
                                      "speaker_map": {"SPEAKER_00": "Alice"},
                                      "regenerate_summary": True,
                                  })
            assert resp.status_code == 200, f"Expected 200, got {resp.status_code}"
            body = resp.get_json()
            assert body.get("summary_queued") is True
            mock_jq.enqueue.assert_called_once()
            return True
        finally:
            _cleanup(rec, token_rec)
            if cu:
                _cleanup(user)


def test_assign_embeddings_updated():
    """With speaker_embeddings -> update_speaker_embedding called, counts returned."""
    with app.app_context():
        user, cu = _get_or_create_test_user()
        token_rec, token = _create_api_token(user)
        rec = _create_test_recording(
            user,
            transcription=SAMPLE_TRANSCRIPTION_JSON,
            speaker_embeddings=SAMPLE_EMBEDDINGS,
        )
        speaker = _create_test_speaker(user, "Alice")
        client = app.test_client()
        try:
            mock_update = MagicMock()
            mock_snippets = MagicMock(return_value=2)
            with patch("src.services.speaker_embedding_matcher.update_speaker_embedding", mock_update), \
                 patch("src.services.speaker_snippets.create_speaker_snippets", mock_snippets):
                resp = client.put(f"/api/v1/recordings/{rec.id}/speakers/assign",
                                  headers={"X-API-Token": token},
                                  json={"speaker_map": {"SPEAKER_00": "Alice"}})
            assert resp.status_code == 200, f"Expected 200, got {resp.status_code}"
            body = resp.get_json()
            assert body.get("embeddings_updated") >= 1, f"embeddings_updated: {body}"
            mock_update.assert_called()
            return True
        finally:
            _cleanup(rec, speaker, token_rec)
            if cu:
                _cleanup(user)


def test_assign_no_transcription():
    """Recording without transcription -> speakers applied to empty content gracefully."""
    with app.app_context():
        user, cu = _get_or_create_test_user()
        token_rec, token = _create_api_token(user)
        rec = _create_test_recording(user, transcription=None)
        client = app.test_client()
        try:
            resp = client.put(f"/api/v1/recordings/{rec.id}/speakers/assign",
                              headers={"X-API-Token": token},
                              json={"speaker_map": {"SPEAKER_00": "Alice"}})
            # Should succeed (or at least not 500)
            assert resp.status_code in (200, 400), f"Expected 200/400, got {resp.status_code}"
            return True
        finally:
            _cleanup(rec, token_rec)
            if cu:
                _cleanup(user)


def test_assign_whitespace_name_trimmed():
    """Names with leading/trailing whitespace get trimmed."""
    with app.app_context():
        user, cu = _get_or_create_test_user()
        token_rec, token = _create_api_token(user)
        rec = _create_test_recording(user, transcription=SAMPLE_TRANSCRIPTION_JSON)
        client = app.test_client()
        try:
            resp = client.put(f"/api/v1/recordings/{rec.id}/speakers/assign",
                              headers={"X-API-Token": token},
                              json={"speaker_map": {"SPEAKER_00": "  Alice  "}})
            assert resp.status_code == 200, f"Expected 200, got {resp.status_code}"
            db.session.refresh(rec)
            segments = json.loads(rec.transcription)
            assert segments[0]["speaker"] == "Alice", f"Name not trimmed: '{segments[0]['speaker']}'"
            return True
        finally:
            _cleanup(rec, token_rec)
            if cu:
                _cleanup(user)


# =========================================================================
# Group 2: POST /recordings/<id>/speakers/identify  (10 tests)
# =========================================================================


def test_identify_no_auth():
    """No token -> 302."""
    with app.app_context():
        user, cu = _get_or_create_test_user()
        rec = _create_test_recording(user, transcription=SAMPLE_TRANSCRIPTION_JSON)
        client = app.test_client()
        try:
            resp = client.post(f"/api/v1/recordings/{rec.id}/speakers/identify")
            assert resp.status_code in (302, 401), f"Expected 302/401, got {resp.status_code}"
            return True
        finally:
            _cleanup(rec)
            if cu:
                _cleanup(user)


def test_identify_recording_not_found():
    """Nonexistent ID -> 404."""
    with app.app_context():
        user, cu = _get_or_create_test_user()
        token_rec, token = _create_api_token(user)
        client = app.test_client()
        try:
            resp = client.post("/api/v1/recordings/999999/speakers/identify",
                               headers={"X-API-Token": token})
            assert resp.status_code == 404, f"Expected 404, got {resp.status_code}"
            return True
        finally:
            _cleanup(token_rec)
            if cu:
                _cleanup(user)


def test_identify_wrong_user_recording():
    """Other user's recording -> 403."""
    with app.app_context():
        owner, co = _get_or_create_test_user("_id_owner")
        other, cu = _get_or_create_test_user("_id_other")
        token_rec, token = _create_api_token(other)
        rec = _create_test_recording(owner, transcription=SAMPLE_TRANSCRIPTION_JSON)
        client = app.test_client()
        try:
            resp = client.post(f"/api/v1/recordings/{rec.id}/speakers/identify",
                               headers={"X-API-Token": token})
            assert resp.status_code == 403, f"Expected 403, got {resp.status_code}"
            return True
        finally:
            _cleanup(rec, token_rec)
            if cu:
                _cleanup(other)
            if co:
                _cleanup(owner)


def test_identify_no_transcription():
    """No transcription -> 400."""
    with app.app_context():
        user, cu = _get_or_create_test_user()
        token_rec, token = _create_api_token(user)
        rec = _create_test_recording(user, transcription=None)
        client = app.test_client()
        try:
            resp = client.post(f"/api/v1/recordings/{rec.id}/speakers/identify",
                               headers={"X-API-Token": token})
            assert resp.status_code == 400, f"Expected 400, got {resp.status_code}"
            return True
        finally:
            _cleanup(rec, token_rec)
            if cu:
                _cleanup(user)


def test_identify_non_json_transcription():
    """Plain text -> 400."""
    with app.app_context():
        user, cu = _get_or_create_test_user()
        token_rec, token = _create_api_token(user)
        rec = _create_test_recording(user, transcription=SAMPLE_TRANSCRIPTION_TEXT)
        client = app.test_client()
        try:
            resp = client.post(f"/api/v1/recordings/{rec.id}/speakers/identify",
                               headers={"X-API-Token": token})
            assert resp.status_code == 400, f"Expected 400, got {resp.status_code}"
            return True
        finally:
            _cleanup(rec, token_rec)
            if cu:
                _cleanup(user)


def test_identify_json_but_not_list():
    """Dict JSON -> 400."""
    with app.app_context():
        user, cu = _get_or_create_test_user()
        token_rec, token = _create_api_token(user)
        rec = _create_test_recording(user, transcription=json.dumps({"key": "value"}))
        client = app.test_client()
        try:
            resp = client.post(f"/api/v1/recordings/{rec.id}/speakers/identify",
                               headers={"X-API-Token": token})
            assert resp.status_code == 400, f"Expected 400, got {resp.status_code}"
            return True
        finally:
            _cleanup(rec, token_rec)
            if cu:
                _cleanup(user)


def test_identify_happy_path():
    """Mock LLM returns names -> 200 with speaker_map."""
    with app.app_context():
        user, cu = _get_or_create_test_user()
        token_rec, token = _create_api_token(user)
        rec = _create_test_recording(user, transcription=SAMPLE_TRANSCRIPTION_JSON)
        client = app.test_client()
        try:
            # Build a mock LLM completion response
            mock_completion = MagicMock()
            mock_completion.choices = [MagicMock()]
            mock_completion.choices[0].message.content = json.dumps({
                "SPEAKER_00": "Alice",
                "SPEAKER_01": "Bob",
            })

            with patch("src.services.llm.call_llm_completion", return_value=mock_completion), \
                 patch("src.models.system.SystemSetting") as mock_ss:
                mock_ss.get_setting.return_value = 30000
                resp = client.post(f"/api/v1/recordings/{rec.id}/speakers/identify",
                                   headers={"X-API-Token": token})

            assert resp.status_code == 200, f"Expected 200, got {resp.status_code}"
            body = resp.get_json()
            assert body.get("success") is True
            sm = body.get("speaker_map", {})
            assert sm.get("SPEAKER_00") == "Alice"
            assert sm.get("SPEAKER_01") == "Bob"
            return True
        finally:
            _cleanup(rec, token_rec)
            if cu:
                _cleanup(user)


def test_identify_post_processing_unknown_values():
    """'Unknown'/'N/A' cleared to ''."""
    with app.app_context():
        user, cu = _get_or_create_test_user()
        token_rec, token = _create_api_token(user)
        rec = _create_test_recording(user, transcription=SAMPLE_TRANSCRIPTION_JSON)
        client = app.test_client()
        try:
            mock_completion = MagicMock()
            mock_completion.choices = [MagicMock()]
            mock_completion.choices[0].message.content = json.dumps({
                "SPEAKER_00": "Unknown",
                "SPEAKER_01": "N/A",
            })

            with patch("src.services.llm.call_llm_completion", return_value=mock_completion), \
                 patch("src.models.system.SystemSetting") as mock_ss:
                mock_ss.get_setting.return_value = 30000
                resp = client.post(f"/api/v1/recordings/{rec.id}/speakers/identify",
                                   headers={"X-API-Token": token})

            assert resp.status_code == 200, f"Expected 200, got {resp.status_code}"
            body = resp.get_json()
            sm = body.get("speaker_map", {})
            assert sm.get("SPEAKER_00") == "", f"Expected empty, got {sm.get('SPEAKER_00')}"
            assert sm.get("SPEAKER_01") == "", f"Expected empty, got {sm.get('SPEAKER_01')}"
            return True
        finally:
            _cleanup(rec, token_rec)
            if cu:
                _cleanup(user)


def test_identify_no_speakers_in_transcript():
    """Segments without speaker field -> 400."""
    with app.app_context():
        user, cu = _get_or_create_test_user()
        token_rec, token = _create_api_token(user)
        no_speakers = json.dumps([{"sentence": "Hello"}, {"sentence": "World"}])
        rec = _create_test_recording(user, transcription=no_speakers)
        client = app.test_client()
        try:
            resp = client.post(f"/api/v1/recordings/{rec.id}/speakers/identify",
                               headers={"X-API-Token": token})
            assert resp.status_code == 400, f"Expected 400, got {resp.status_code}"
            return True
        finally:
            _cleanup(rec, token_rec)
            if cu:
                _cleanup(user)


def test_identify_llm_error():
    """LLM raises exception -> 500."""
    with app.app_context():
        user, cu = _get_or_create_test_user()
        token_rec, token = _create_api_token(user)
        rec = _create_test_recording(user, transcription=SAMPLE_TRANSCRIPTION_JSON)
        client = app.test_client()
        try:
            with patch("src.services.llm.call_llm_completion",
                       side_effect=RuntimeError("LLM down")), \
                 patch("src.models.system.SystemSetting") as mock_ss:
                mock_ss.get_setting.return_value = 30000
                resp = client.post(f"/api/v1/recordings/{rec.id}/speakers/identify",
                                   headers={"X-API-Token": token})
            assert resp.status_code == 500, f"Expected 500, got {resp.status_code}"
            return True
        finally:
            _cleanup(rec, token_rec)
            if cu:
                _cleanup(user)


# =========================================================================
# Group 3: PUT /settings/auto-summarization  (5 tests)
# =========================================================================


def test_auto_summarization_no_auth():
    """No token -> 302."""
    with app.app_context():
        client = app.test_client()
        resp = client.put("/api/v1/settings/auto-summarization",
                          json={"enabled": True})
        assert resp.status_code in (302, 401), f"Expected 302/401, got {resp.status_code}"
        return True


def test_auto_summarization_missing_enabled():
    """Body {} -> 400."""
    with app.app_context():
        user, cu = _get_or_create_test_user()
        token_rec, token = _create_api_token(user)
        client = app.test_client()
        try:
            resp = client.put("/api/v1/settings/auto-summarization",
                              headers={"X-API-Token": token},
                              json={})
            assert resp.status_code == 400, f"Expected 400, got {resp.status_code}"
            body = resp.get_json()
            assert "enabled" in body.get("error", "").lower(), f"Unexpected: {body}"
            return True
        finally:
            _cleanup(token_rec)
            if cu:
                _cleanup(user)


def test_auto_summarization_invalid_json():
    """Non-JSON body -> 400."""
    with app.app_context():
        user, cu = _get_or_create_test_user()
        token_rec, token = _create_api_token(user)
        client = app.test_client()
        try:
            resp = client.put("/api/v1/settings/auto-summarization",
                              headers={"X-API-Token": token,
                                       "Content-Type": "application/json"},
                              data="not valid json")
            assert resp.status_code == 400, f"Expected 400, got {resp.status_code}"
            return True
        finally:
            _cleanup(token_rec)
            if cu:
                _cleanup(user)


def test_auto_summarization_enable():
    """enabled: true -> updates user, returns true."""
    with app.app_context():
        user, cu = _get_or_create_test_user()
        user.auto_summarization = False
        db.session.commit()
        token_rec, token = _create_api_token(user)
        client = app.test_client()
        try:
            resp = client.put("/api/v1/settings/auto-summarization",
                              headers={"X-API-Token": token},
                              json={"enabled": True})
            assert resp.status_code == 200, f"Expected 200, got {resp.status_code}"
            body = resp.get_json()
            assert body.get("auto_summarization") is True
            db.session.refresh(user)
            assert user.auto_summarization is True
            return True
        finally:
            _cleanup(token_rec)
            if cu:
                _cleanup(user)


def test_auto_summarization_disable():
    """enabled: false -> updates user, returns false."""
    with app.app_context():
        user, cu = _get_or_create_test_user()
        user.auto_summarization = True
        db.session.commit()
        token_rec, token = _create_api_token(user)
        client = app.test_client()
        try:
            resp = client.put("/api/v1/settings/auto-summarization",
                              headers={"X-API-Token": token},
                              json={"enabled": False})
            assert resp.status_code == 200, f"Expected 200, got {resp.status_code}"
            body = resp.get_json()
            assert body.get("auto_summarization") is False
            db.session.refresh(user)
            assert user.auto_summarization is False
            return True
        finally:
            _cleanup(token_rec)
            if cu:
                _cleanup(user)


# =========================================================================
# Group 4: Regression tests  (2 tests)
# =========================================================================


def test_regression_get_speakers_list():
    """GET /speakers still returns user's speakers."""
    with app.app_context():
        user, cu = _get_or_create_test_user()
        token_rec, token = _create_api_token(user)
        speaker = _create_test_speaker(user, "Regression Speaker")
        client = app.test_client()
        try:
            resp = client.get("/api/v1/speakers",
                              headers={"X-API-Token": token})
            assert resp.status_code == 200, f"Expected 200, got {resp.status_code}"
            body = resp.get_json()
            names = [s["name"] for s in body.get("speakers", [])]
            assert "Regression Speaker" in names, f"Speaker not found: {names}"
            return True
        finally:
            _cleanup(speaker, token_rec)
            if cu:
                _cleanup(user)


def test_regression_get_recording_speakers():
    """GET /recordings/<id>/speakers still returns transcript speakers."""
    with app.app_context():
        user, cu = _get_or_create_test_user()
        token_rec, token = _create_api_token(user)
        rec = _create_test_recording(user, transcription=SAMPLE_TRANSCRIPTION_JSON)
        client = app.test_client()
        try:
            with patch("src.services.speaker_embedding_matcher.find_matching_speakers", return_value={}):
                resp = client.get(f"/api/v1/recordings/{rec.id}/speakers",
                                  headers={"X-API-Token": token})
            assert resp.status_code == 200, f"Expected 200, got {resp.status_code}"
            body = resp.get_json()
            labels = [s["label"] for s in body.get("speakers", [])]
            assert "SPEAKER_00" in labels and "SPEAKER_01" in labels, f"Labels: {labels}"
            return True
        finally:
            _cleanup(rec, token_rec)
            if cu:
                _cleanup(user)


# =========================================================================
# Runner
# =========================================================================

ALL_TESTS = [
    # Group 1: assign
    test_assign_no_auth,
    test_assign_recording_not_found,
    test_assign_wrong_user_recording,
    test_assign_missing_speaker_map,
    test_assign_invalid_speaker_map_type,
    test_assign_string_value_json_transcript,
    test_assign_object_value_with_name,
    test_assign_is_me_flag_with_user_name,
    test_assign_is_me_flag_without_user_name,
    test_assign_plain_text_transcript,
    test_assign_speaker_xx_filtered_from_participants,
    test_assign_invalid_value_type,
    test_assign_empty_speaker_map,
    test_assign_regenerate_summary,
    test_assign_embeddings_updated,
    test_assign_no_transcription,
    test_assign_whitespace_name_trimmed,
    # Group 2: identify
    test_identify_no_auth,
    test_identify_recording_not_found,
    test_identify_wrong_user_recording,
    test_identify_no_transcription,
    test_identify_non_json_transcription,
    test_identify_json_but_not_list,
    test_identify_happy_path,
    test_identify_post_processing_unknown_values,
    test_identify_no_speakers_in_transcript,
    test_identify_llm_error,
    # Group 3: auto-summarization
    test_auto_summarization_no_auth,
    test_auto_summarization_missing_enabled,
    test_auto_summarization_invalid_json,
    test_auto_summarization_enable,
    test_auto_summarization_disable,
    # Group 4: regression
    test_regression_get_speakers_list,
    test_regression_get_recording_speakers,
]


def main():
    print(f"Running {len(ALL_TESTS)} Speaker API tests...\n")
    passed = 0
    failed = 0
    errors = []

    for test_fn in ALL_TESTS:
        name = test_fn.__name__
        try:
            result = test_fn()
            if result:
                print(f"  PASS  {name}")
                passed += 1
            else:
                print(f"  FAIL  {name} (returned False)")
                failed += 1
                errors.append(name)
        except Exception as e:
            print(f"  ERROR {name}: {e}")
            failed += 1
            errors.append(name)

    print(f"\n{'=' * 60}")
    print(f"Results: {passed} passed, {failed} failed out of {len(ALL_TESTS)}")
    if errors:
        print("Failed tests:")
        for e in errors:
            print(f"  - {e}")
    print('=' * 60)
    sys.exit(0 if failed == 0 else 1)


if __name__ == "__main__":
    main()
