"""Per-language QC service — two-stage (linguist → reviewer) assignment, approval, rejection, comments."""

import asyncio
from datetime import datetime
from typing import Any
from uuid import uuid4

from fastapi import HTTPException, status
from motor.motor_asyncio import AsyncIOMotorDatabase

from ..core.logging import get_logger
from ..models.audit_log import AuditAction, AuditLogSeverity
from ..models.job import (
    JobStatus,
    LanguageQCComment,
    LanguageQCEvent,
    LanguageQCState,
    LanguageQCStatus,
)
from ..models.user import User, UserRole
from ..services.audit_logger import audit_logger
from ..services.websocket import connection_manager

logger = get_logger(__name__)

_JOBS = "jobs"


async def _assert_user_in_job_org(
    db: AsyncIOMotorDatabase,
    user_id: str,
    job_doc: dict,
) -> None:
    """Raise 403 if user_id is not a member of the job's organization."""
    org_id = job_doc.get("organization_id")
    if not org_id:
        project_id = job_doc.get("project_id")
        if project_id:
            project = await db.projects.find_one({"_id": project_id}, {"client_id": 1})
            if project:
                org_id = project.get("client_id")
    if not org_id:
        raise HTTPException(
            status_code=422,
            detail="Job is not bound to an organization; cannot validate cross-org assignment",
        )
    mem = await db.memberships.find_one({"user_id": user_id, "organization_id": org_id})
    if not mem:
        raise HTTPException(
            status_code=403,
            detail="Assignee is not a member of this job's organization",
        )


# ── Helpers ───────────────────────────────────────────────────────────────────

def _job_languages(job_doc: dict) -> list[str]:
    """Canonical set of language codes that must be QC-approved before final review."""
    source_lang = job_doc.get("source", {}).get("language", "en")
    requested = job_doc.get("requested_outputs", {}).get("languages", [])
    langs = list({source_lang} | set(requested))
    return langs


def _all_approved(job_doc: dict) -> bool:
    lang_qc = job_doc.get("language_qc", {})
    for lang in _job_languages(job_doc):
        state = lang_qc.get(lang, {})
        if state.get("status") != LanguageQCStatus.APPROVED.value:
            return False
    return True


def _any_rejected(job_doc: dict) -> bool:
    lang_qc = job_doc.get("language_qc", {})
    for lang in _job_languages(job_doc):
        state = lang_qc.get(lang, {})
        if state.get("status") == LanguageQCStatus.REJECTED.value:
            return True
    return False


def _rebuild_qc_assignments(language_qc: dict) -> list[dict]:
    """Rebuild the denormalized qc_assignments array from language_qc dict."""
    assignments = []
    for lang, state in language_qc.items():
        linguist_id = state.get("assigned_linguist_id") if isinstance(state, dict) else state.assigned_linguist_id
        qc_status = state.get("status") if isinstance(state, dict) else state.status
        if linguist_id:
            assignments.append({
                "lang": lang,
                "linguist_id": linguist_id,
                "status": qc_status,
            })
    return assignments


def _qc_recipients(
    job_doc: dict,
    lang_state: dict,
    exclude_user_id: str | None,
) -> list[tuple[str, str]]:
    """Return [(email, full_name)] for linguist + reviewer assigned to a language, minus the actor."""
    seen: set[str] = set()
    result: list[tuple[str, str]] = []

    def _add(email: str | None, name: str | None) -> None:
        if email and email not in seen and email != exclude_user_id:
            seen.add(email)
            result.append((email, name or email.split("@")[0]))

    _add(lang_state.get("assigned_linguist_email"), lang_state.get("assigned_linguist_name"))
    _add(lang_state.get("assigned_reviewer_email"), lang_state.get("assigned_reviewer_name"))
    return result


def _deep_link(job_id: str, lang: str) -> str:
    from ..core.config import settings
    return f"{settings.app_url}/admin/qc/{job_id}#lang-{lang}"


# ── Auto-assignment ───────────────────────────────────────────────────────────

async def auto_assign_defaults(db: AsyncIOMotorDatabase, job_id: str) -> int:
    """Apply job.initial_linguist_id / initial_reviewer_id to all unassigned languages.

    Called lazily when the language-QC map is first fetched in PENDING_QC state,
    so PM assignments made at job-creation time take effect without touching Celery tasks.
    Returns the number of languages updated.
    """
    job_doc = await db[_JOBS].find_one({"_id": job_id})
    if not job_doc:
        return 0

    linguist_id: str | None = job_doc.get("initial_linguist_id")
    reviewer_id: str | None = job_doc.get("initial_reviewer_id")
    if not linguist_id and not reviewer_id:
        return 0

    languages: list[str] = (job_doc.get("requested_outputs") or {}).get("languages") or []
    if not languages:
        return 0

    linguist_doc = await db.users.find_one({"_id": linguist_id}) if linguist_id else None
    reviewer_doc = await db.users.find_one({"_id": reviewer_id}) if reviewer_id else None

    now = datetime.utcnow()
    updated = 0
    current_qc: dict = job_doc.get("language_qc") or {}

    for lang in languages:
        lang_state: dict = current_qc.get(lang) or {}
        already_assigned = bool(lang_state.get("assigned_linguist_id"))
        if already_assigned:
            continue

        patch: dict = {}
        if linguist_doc:
            patch.update({
                f"language_qc.{lang}.assigned_linguist_id": linguist_id,
                f"language_qc.{lang}.assigned_linguist_email": linguist_doc["email"],
                f"language_qc.{lang}.assigned_linguist_name": linguist_doc.get("full_name", ""),
                f"language_qc.{lang}.assigned_at": now,
                f"language_qc.{lang}.assigned_by_user_id": "system",
                f"language_qc.{lang}.status": lang_state.get("status", LanguageQCStatus.PENDING.value),
            })
        if reviewer_doc:
            patch.update({
                f"language_qc.{lang}.assigned_reviewer_id": reviewer_id,
                f"language_qc.{lang}.assigned_reviewer_email": reviewer_doc["email"],
                f"language_qc.{lang}.assigned_reviewer_name": reviewer_doc.get("full_name", ""),
            })

        if patch:
            await db[_JOBS].update_one({"_id": job_id}, {"$set": patch})
            updated += 1

    if updated:
        logger.info("auto_assign_defaults: assigned %d languages on job %s", updated, job_id)
    return updated


# ── Core mutations ────────────────────────────────────────────────────────────

async def get_state(db: AsyncIOMotorDatabase, job_id: str, lang: str) -> LanguageQCState | None:
    job_doc = await db[_JOBS].find_one({"_id": job_id}, {f"language_qc.{lang}": 1})
    if not job_doc:
        return None
    raw = job_doc.get("language_qc", {}).get(lang)
    if raw is None:
        return None
    return LanguageQCState(**raw)


async def get_all_states(db: AsyncIOMotorDatabase, job_id: str) -> dict[str, LanguageQCState]:
    job_doc = await db[_JOBS].find_one({"_id": job_id}, {"language_qc": 1})
    if not job_doc:
        return {}
    result = {}
    for lang, raw in (job_doc.get("language_qc") or {}).items():
        result[lang] = LanguageQCState(**raw)
    return result


# ── Linguist assignment ────────────────────────────────────────────────────────

async def assign_linguist(
    db: AsyncIOMotorDatabase,
    job_id: str,
    lang: str,
    linguist_user_id: str,
    actor: User,
    *,
    http_request=None,
    notes: str | None = None,
    deadline: datetime | None = None,
) -> LanguageQCState:
    """PM/PROD/ADMIN assigns a linguist to a language. Creates per-lang state if missing."""
    job_doc = await db[_JOBS].find_one({"_id": job_id})
    if not job_doc:
        raise HTTPException(status_code=404, detail="Job not found")

    linguist_doc = await db.users.find_one({"_id": linguist_user_id})
    if not linguist_doc:
        raise HTTPException(status_code=404, detail="Linguist not found")

    await _assert_user_in_job_org(db, linguist_user_id, job_doc)

    now = datetime.utcnow()
    current_state_raw = (job_doc.get("language_qc") or {}).get(lang, {})
    prev_assignee = current_state_raw.get("assigned_linguist_id") if isinstance(current_state_raw, dict) else None
    is_reassignment = prev_assignee is not None and prev_assignee != linguist_user_id
    action_label = "reassign" if is_reassignment else "assign"

    event = LanguageQCEvent(
        at=now,
        actor_user_id=str(actor.id),
        actor_email=actor.email,
        action=action_label,
        notes=notes,
        previous_assignee_id=prev_assignee if is_reassignment else None,
    )

    updated_state = {
        **(current_state_raw if isinstance(current_state_raw, dict) else {}),
        "status": current_state_raw.get("status", LanguageQCStatus.PENDING.value) if isinstance(current_state_raw, dict) else LanguageQCStatus.PENDING.value,
        "assigned_linguist_id": linguist_user_id,
        "assigned_linguist_email": linguist_doc["email"],
        "assigned_linguist_name": linguist_doc.get("full_name", ""),
        "assigned_at": now,
        "assigned_by_user_id": str(actor.id),
        "linguist_deadline": deadline,
        "history": (current_state_raw.get("history", []) if isinstance(current_state_raw, dict) else []) + [event.model_dump()],
    }

    full_language_qc = {**(job_doc.get("language_qc") or {}), lang: updated_state}
    qc_assignments = _rebuild_qc_assignments(full_language_qc)

    await db[_JOBS].update_one(
        {"_id": job_id},
        {"$set": {
            f"language_qc.{lang}": updated_state,
            "qc_assignments": qc_assignments,
            "updated_at": now,
        }}
    )

    audit_action = AuditAction.LANGUAGE_QC_REASSIGN if is_reassignment else AuditAction.LANGUAGE_QC_ASSIGN
    await audit_logger.log_action(
        audit_action,
        f"Language QC linguist {'reassigned' if is_reassignment else 'assigned'}: {lang} on job {job_id} → {linguist_doc['email']}",
        user=actor,
        request=http_request,
        resource_type="job_language",
        resource_id=f"{job_id}:{lang}",
        details={"lang": lang, "linguist_id": linguist_user_id, "linguist_email": linguist_doc["email"]},
    )

    # Email the new linguist
    try:
        from ..services.emailer import email_service
        await email_service.send_language_assignment_email(
            to_email=linguist_doc["email"],
            full_name=linguist_doc.get("full_name", ""),
            job_title=job_doc.get("title", job_id),
            lang=lang,
            role="linguist",
            deep_link=_deep_link(job_id, lang),
        )
    except Exception:
        logger.exception("Failed to send linguist assignment email")

    try:
        await connection_manager.broadcast_to_user(
            linguist_user_id,
            {"type": "language_qc_assigned", "job_id": job_id, "lang": lang, "job_title": job_doc.get("title")},
        )
    except Exception:
        pass

    return LanguageQCState(**updated_state)


async def reassign_linguist(
    db: AsyncIOMotorDatabase,
    job_id: str,
    lang: str,
    new_linguist_user_id: str,
    actor: User,
    *,
    http_request=None,
    notes: str | None = None,
    deadline: datetime | None = None,
) -> LanguageQCState:
    """Currently-assigned linguist OR PM/PROD/ADMIN hands off to a colleague."""
    job_doc = await db[_JOBS].find_one({"_id": job_id})
    if not job_doc:
        raise HTTPException(status_code=404, detail="Job not found")

    current_state_raw = (job_doc.get("language_qc") or {}).get(lang)
    if not current_state_raw:
        raise HTTPException(status_code=400, detail=f"No QC state found for language '{lang}'")

    current_assignee = current_state_raw.get("assigned_linguist_id") if isinstance(current_state_raw, dict) else None
    if current_assignee != str(actor.id):
        if actor.role not in (UserRole.PRODUCTION, UserRole.ADMIN, UserRole.PROJECT_MANAGER):
            raise HTTPException(status_code=403, detail="Not authorized to reassign this language")

    return await assign_linguist(db, job_id, lang, new_linguist_user_id, actor, http_request=http_request, notes=notes, deadline=deadline)


# ── Reviewer assignment ────────────────────────────────────────────────────────

async def assign_reviewer(
    db: AsyncIOMotorDatabase,
    job_id: str,
    lang: str,
    reviewer_user_id: str,
    actor: User,
    *,
    http_request=None,
    notes: str | None = None,
    deadline: datetime | None = None,
) -> LanguageQCState:
    """PM/PROD/ADMIN assigns a reviewer to a language."""
    job_doc = await db[_JOBS].find_one({"_id": job_id})
    if not job_doc:
        raise HTTPException(status_code=404, detail="Job not found")

    reviewer_doc = await db.users.find_one({"_id": reviewer_user_id})
    if not reviewer_doc:
        raise HTTPException(status_code=404, detail="Reviewer not found")

    await _assert_user_in_job_org(db, reviewer_user_id, job_doc)

    now = datetime.utcnow()
    current_state_raw = (job_doc.get("language_qc") or {}).get(lang, {})
    prev_reviewer = current_state_raw.get("assigned_reviewer_id") if isinstance(current_state_raw, dict) else None
    is_reassignment = prev_reviewer is not None and prev_reviewer != reviewer_user_id
    action_label = "reviewer_reassigned" if is_reassignment else "reviewer_assigned"

    event = LanguageQCEvent(
        at=now,
        actor_user_id=str(actor.id),
        actor_email=actor.email,
        action=action_label,
        notes=notes,
        previous_assignee_id=prev_reviewer if is_reassignment else None,
    )

    updated_state = {
        **(current_state_raw if isinstance(current_state_raw, dict) else {}),
        "assigned_reviewer_id": reviewer_user_id,
        "assigned_reviewer_email": reviewer_doc["email"],
        "assigned_reviewer_name": reviewer_doc.get("full_name", ""),
        "assigned_reviewer_at": now,
        "reviewer_deadline": deadline,
        "history": (current_state_raw.get("history", []) if isinstance(current_state_raw, dict) else []) + [event.model_dump()],
    }

    full_language_qc = {**(job_doc.get("language_qc") or {}), lang: updated_state}
    qc_assignments = _rebuild_qc_assignments(full_language_qc)

    await db[_JOBS].update_one(
        {"_id": job_id},
        {"$set": {
            f"language_qc.{lang}": updated_state,
            "qc_assignments": qc_assignments,
            "updated_at": now,
        }}
    )

    audit_action = AuditAction.LANGUAGE_QC_REVIEWER_REASSIGN if is_reassignment else AuditAction.LANGUAGE_QC_REVIEWER_ASSIGN
    await audit_logger.log_action(
        audit_action,
        f"Language QC reviewer {'reassigned' if is_reassignment else 'assigned'}: {lang} on job {job_id} → {reviewer_doc['email']}",
        user=actor,
        request=http_request,
        resource_type="job_language",
        resource_id=f"{job_id}:{lang}",
        details={"lang": lang, "reviewer_id": reviewer_user_id, "reviewer_email": reviewer_doc["email"]},
    )

    try:
        from ..services.emailer import email_service
        await email_service.send_language_assignment_email(
            to_email=reviewer_doc["email"],
            full_name=reviewer_doc.get("full_name", ""),
            job_title=job_doc.get("title", job_id),
            lang=lang,
            role="reviewer",
            deep_link=_deep_link(job_id, lang),
        )
    except Exception:
        logger.exception("Failed to send reviewer assignment email")

    return LanguageQCState(**updated_state)


async def reassign_reviewer(
    db: AsyncIOMotorDatabase,
    job_id: str,
    lang: str,
    new_reviewer_user_id: str,
    actor: User,
    *,
    http_request=None,
    notes: str | None = None,
    deadline: datetime | None = None,
) -> LanguageQCState:
    if actor.role not in (UserRole.PRODUCTION, UserRole.ADMIN, UserRole.PROJECT_MANAGER):
        raise HTTPException(status_code=403, detail="Only PM/PROD/ADMIN can reassign reviewer")
    return await assign_reviewer(db, job_id, lang, new_reviewer_user_id, actor, http_request=http_request, notes=notes, deadline=deadline)


# ── Workflow transitions ──────────────────────────────────────────────────────

async def start_linguist_work(
    db: AsyncIOMotorDatabase,
    job_id: str,
    lang: str,
    actor: User,
) -> LanguageQCState:
    """Linguist opens the language — transitions pending → in_progress."""
    job_doc = await db[_JOBS].find_one({"_id": job_id})
    if not job_doc:
        raise HTTPException(status_code=404, detail="Job not found")

    current_state_raw = (job_doc.get("language_qc") or {}).get(lang, {})
    current_status = current_state_raw.get("status", LanguageQCStatus.PENDING.value) if isinstance(current_state_raw, dict) else LanguageQCStatus.PENDING.value

    if current_status not in (LanguageQCStatus.PENDING.value, LanguageQCStatus.REJECTED.value):
        return LanguageQCState(**(current_state_raw if isinstance(current_state_raw, dict) else {}))

    assigned = current_state_raw.get("assigned_linguist_id") if isinstance(current_state_raw, dict) else None
    if assigned != str(actor.id) and actor.role not in (UserRole.PRODUCTION, UserRole.ADMIN):
        raise HTTPException(status_code=403, detail="Not the assigned linguist")

    now = datetime.utcnow()
    event = LanguageQCEvent(at=now, actor_user_id=str(actor.id), actor_email=actor.email, action="start_work")
    history = (current_state_raw.get("history", []) if isinstance(current_state_raw, dict) else []) + [event.model_dump()]

    updated_state = {
        **(current_state_raw if isinstance(current_state_raw, dict) else {}),
        "status": LanguageQCStatus.IN_PROGRESS.value,
        "submitted_for_review_at": None,
        "history": history,
    }

    await db[_JOBS].update_one(
        {"_id": job_id},
        {"$set": {
            f"language_qc.{lang}.status": LanguageQCStatus.IN_PROGRESS.value,
            f"language_qc.{lang}.submitted_for_review_at": None,
            f"language_qc.{lang}.history": history,
            "updated_at": now,
        }}
    )
    return LanguageQCState(**updated_state)


# Keep old name as alias so any existing callers don't break immediately
start_review = start_linguist_work


async def submit_for_review(
    db: AsyncIOMotorDatabase,
    job_id: str,
    lang: str,
    actor: User,
    *,
    http_request=None,
) -> LanguageQCState:
    """Linguist submits work — transitions in_progress → pending_review."""
    job_doc = await db[_JOBS].find_one({"_id": job_id})
    if not job_doc:
        raise HTTPException(status_code=404, detail="Job not found")

    current_state_raw = (job_doc.get("language_qc") or {}).get(lang, {})
    current_status = current_state_raw.get("status", LanguageQCStatus.PENDING.value) if isinstance(current_state_raw, dict) else LanguageQCStatus.PENDING.value
    assigned_linguist = current_state_raw.get("assigned_linguist_id") if isinstance(current_state_raw, dict) else None

    if actor.role not in (UserRole.PRODUCTION, UserRole.ADMIN) and assigned_linguist != str(actor.id):
        raise HTTPException(status_code=403, detail="Not the assigned linguist")

    if current_status not in (LanguageQCStatus.IN_PROGRESS.value, LanguageQCStatus.PENDING.value):
        raise HTTPException(status_code=400, detail=f"Cannot submit from status '{current_status}'")

    now = datetime.utcnow()
    event = LanguageQCEvent(at=now, actor_user_id=str(actor.id), actor_email=actor.email, action="submit_for_review")
    history = (current_state_raw.get("history", []) if isinstance(current_state_raw, dict) else []) + [event.model_dump()]

    updated_state = {
        **(current_state_raw if isinstance(current_state_raw, dict) else {}),
        "status": LanguageQCStatus.PENDING_REVIEW.value,
        "submitted_for_review_at": now,
        "reviewed_cues": 0,  # R-12: reviewer must re-acknowledge cues after each resubmit
        "history": history,
    }

    full_language_qc = {**(job_doc.get("language_qc") or {}), lang: updated_state}
    qc_assignments = _rebuild_qc_assignments(full_language_qc)

    await db[_JOBS].update_one(
        {"_id": job_id},
        {"$set": {
            f"language_qc.{lang}": updated_state,
            "qc_assignments": qc_assignments,
            "updated_at": now,
        }}
    )

    await audit_logger.log_action(
        AuditAction.LANGUAGE_QC_SUBMIT,
        f"Language QC submitted for review: {lang} on job {job_id}",
        user=actor,
        request=http_request,
        resource_type="job_language",
        resource_id=f"{job_id}:{lang}",
        details={"lang": lang},
    )

    # Notify reviewer
    reviewer_email = updated_state.get("assigned_reviewer_email")
    reviewer_name = updated_state.get("assigned_reviewer_name", "")
    if reviewer_email:
        try:
            from ..services.emailer import email_service
            await email_service.send_language_submitted_email(
                to_email=reviewer_email,
                full_name=reviewer_name,
                job_title=job_doc.get("title", job_id),
                lang=lang,
                linguist_name=updated_state.get("assigned_linguist_name", ""),
                deep_link=_deep_link(job_id, lang),
            )
        except Exception:
            logger.exception("Failed to send submission notification email")

    return LanguageQCState(**updated_state)


async def open_review(
    db: AsyncIOMotorDatabase,
    job_id: str,
    lang: str,
    actor: User,
    *,
    http_request=None,
) -> LanguageQCState:
    """Reviewer opens the language — transitions pending_review → in_review."""
    job_doc = await db[_JOBS].find_one({"_id": job_id})
    if not job_doc:
        raise HTTPException(status_code=404, detail="Job not found")

    current_state_raw = (job_doc.get("language_qc") or {}).get(lang, {})
    current_status = current_state_raw.get("status", LanguageQCStatus.PENDING.value) if isinstance(current_state_raw, dict) else LanguageQCStatus.PENDING.value
    assigned_reviewer = current_state_raw.get("assigned_reviewer_id") if isinstance(current_state_raw, dict) else None

    if actor.role not in (UserRole.PRODUCTION, UserRole.ADMIN) and assigned_reviewer != str(actor.id):
        raise HTTPException(status_code=403, detail="Not the assigned reviewer")

    if current_status != LanguageQCStatus.PENDING_REVIEW.value:
        return LanguageQCState(**(current_state_raw if isinstance(current_state_raw, dict) else {}))

    now = datetime.utcnow()
    event = LanguageQCEvent(at=now, actor_user_id=str(actor.id), actor_email=actor.email, action="open_review")
    history = (current_state_raw.get("history", []) if isinstance(current_state_raw, dict) else []) + [event.model_dump()]

    updated_state = {
        **(current_state_raw if isinstance(current_state_raw, dict) else {}),
        "status": LanguageQCStatus.IN_REVIEW.value,
        "review_started_at": now,
        "history": history,
    }

    await db[_JOBS].update_one(
        {"_id": job_id},
        {"$set": {
            f"language_qc.{lang}.status": LanguageQCStatus.IN_REVIEW.value,
            f"language_qc.{lang}.review_started_at": now,
            f"language_qc.{lang}.history": history,
            "updated_at": now,
        }}
    )

    await audit_logger.log_action(
        AuditAction.LANGUAGE_QC_OPEN_REVIEW,
        f"Language QC review opened: {lang} on job {job_id}",
        user=actor,
        request=http_request,
        resource_type="job_language",
        resource_id=f"{job_id}:{lang}",
        details={"lang": lang},
    )

    return LanguageQCState(**updated_state)


# ── Approve / Reject ──────────────────────────────────────────────────────────

async def approve_language(
    db: AsyncIOMotorDatabase,
    job_id: str,
    lang: str,
    actor: User,
    *,
    http_request=None,
    notes: str | None = None,
) -> LanguageQCState:
    job_doc = await db[_JOBS].find_one({"_id": job_id})
    if not job_doc:
        raise HTTPException(status_code=404, detail="Job not found")

    if job_doc["status"] not in (JobStatus.PENDING_QC.value, JobStatus.QC_FEEDBACK.value):
        raise HTTPException(status_code=400, detail="Job is not in QC status")

    _assert_can_approve(job_doc, lang, actor)

    now = datetime.utcnow()
    event = LanguageQCEvent(at=now, actor_user_id=str(actor.id), actor_email=actor.email, action="approve", notes=notes)
    current_state_raw = (job_doc.get("language_qc") or {}).get(lang, {})
    history = (current_state_raw.get("history", []) if isinstance(current_state_raw, dict) else []) + [event.model_dump()]

    updated_state = {
        **(current_state_raw if isinstance(current_state_raw, dict) else {}),
        "status": LanguageQCStatus.APPROVED.value,
        "reviewed_at": now,
        "reviewed_by_user_id": str(actor.id),
        "reviewed_by_email": actor.email,
        "notes": notes,
        "history": history,
    }

    full_language_qc = {**(job_doc.get("language_qc") or {}), lang: updated_state}
    qc_assignments = _rebuild_qc_assignments(full_language_qc)

    await db[_JOBS].update_one(
        {"_id": job_id},
        {"$set": {
            f"language_qc.{lang}": updated_state,
            "qc_assignments": qc_assignments,
            "updated_at": now,
        }}
    )

    await audit_logger.log_action(
        AuditAction.LANGUAGE_QC_APPROVE,
        f"Language QC approved: {lang} on job {job_id}",
        user=actor,
        request=http_request,
        resource_type="job_language",
        resource_id=f"{job_id}:{lang}",
        details={"lang": lang, "notes": notes},
    )

    # Notify linguist + any other recipients
    recipients = _qc_recipients(job_doc, current_state_raw if isinstance(current_state_raw, dict) else {}, exclude_user_id=actor.email)
    if recipients:
        try:
            from ..services.emailer import email_service
            await asyncio.gather(*[
                email_service.send_qc_approved_email(
                    to_email=email, full_name=name,
                    job_title=job_doc.get("title", job_id), lang=lang,
                    approver_name=actor.full_name or actor.email,
                    deep_link=_deep_link(job_id, lang),
                )
                for email, name in recipients
            ], return_exceptions=True)
        except Exception:
            logger.exception("Failed to send approval emails")

    refreshed = await db[_JOBS].find_one({"_id": job_id})

    # When the source language is approved, dispatch translation for any target
    # languages that don't have VTTs yet (EN-first gate).
    source_lang = (refreshed.get("source") or {}).get("language", "en")
    if lang == source_lang:
        target_langs = [lg for lg in _job_languages(refreshed) if lg != source_lang]
        if target_langs:
            outputs = refreshed.get("outputs") or {}
            untranslated = [lg for lg in target_langs if not (outputs.get(lg) or {}).get("captions_vtt_gcs")]
            if untranslated:
                try:
                    from ..services.cloud_run_dispatch import dispatch as _cr_dispatch
                    await db[_JOBS].update_one(
                        {"_id": job_id},
                        {
                            "$set": {
                                "status": JobStatus.TRANSLATING.value,
                                "updated_at": datetime.utcnow(),
                            },
                            "$push": {
                                "review.history": {
                                    "at": datetime.utcnow(),
                                    "status": JobStatus.TRANSLATING.value,
                                    "by": "system",
                                    "notes": f"EN approved — dispatching translation for {untranslated}",
                                }
                            },
                        },
                    )
                    await _cr_dispatch("translate", job_id, languages=untranslated)
                    logger.info(f"Job {job_id}: EN approved, dispatched translation for {untranslated}")
                    return LanguageQCState(**updated_state)
                except Exception as exc:
                    logger.error(f"Job {job_id}: failed to dispatch translation after EN approval: {exc}")
        elif (refreshed.get("requested_outputs") or {}).get("accessible_video_mp4"):
            # Source-only job requesting accessible video: no translation needed,
            # but TTS+render pipeline must run to produce the accessible MP4.
            try:
                from ..services.cloud_run_dispatch import dispatch as _cr_dispatch
                await db[_JOBS].update_one(
                    {"_id": job_id},
                    {
                        "$set": {
                            "status": JobStatus.TRANSLATING.value,
                            "updated_at": datetime.utcnow(),
                        },
                        "$push": {
                            "review.history": {
                                "at": datetime.utcnow(),
                                "status": JobStatus.TRANSLATING.value,
                                "by": "system",
                                "notes": "EN approved — dispatching TTS and accessible video render (source-only)",
                            }
                        },
                    },
                )
                await _cr_dispatch("translate", job_id)
                logger.info(f"Job {job_id}: EN approved (source-only), dispatched TTS+render pipeline")
                return LanguageQCState(**updated_state)
            except Exception as exc:
                logger.error(f"Job {job_id}: failed to dispatch TTS+render after EN approval: {exc}")

    await _maybe_advance_job(db, refreshed)

    return LanguageQCState(**updated_state)


REJECT_CATEGORIES = frozenset(["timing", "mistranslation", "terminology", "profanity", "length", "other"])


async def reject_language(
    db: AsyncIOMotorDatabase,
    job_id: str,
    lang: str,
    actor: User,
    notes: str,
    *,
    category: str | None = None,
    http_request=None,
) -> LanguageQCState:
    if not notes or not notes.strip():
        raise HTTPException(status_code=422, detail="Rejection notes are required")
    if category and category not in REJECT_CATEGORIES:
        raise HTTPException(status_code=422, detail=f"Invalid reject category. Must be one of: {', '.join(sorted(REJECT_CATEGORIES))}")

    job_doc = await db[_JOBS].find_one({"_id": job_id})
    if not job_doc:
        raise HTTPException(status_code=404, detail="Job not found")

    if job_doc["status"] not in (JobStatus.PENDING_QC.value, JobStatus.QC_FEEDBACK.value):
        raise HTTPException(status_code=400, detail="Job is not in QC status")

    _assert_can_approve(job_doc, lang, actor)

    now = datetime.utcnow()
    event = LanguageQCEvent(at=now, actor_user_id=str(actor.id), actor_email=actor.email, action="reject", notes=notes)
    current_state_raw = (job_doc.get("language_qc") or {}).get(lang, {})
    history = (current_state_raw.get("history", []) if isinstance(current_state_raw, dict) else []) + [event.model_dump()]

    updated_state = {
        **(current_state_raw if isinstance(current_state_raw, dict) else {}),
        "status": LanguageQCStatus.IN_PROGRESS.value,  # send back to linguist
        "reviewed_at": now,
        "reviewed_by_user_id": str(actor.id),
        "reviewed_by_email": actor.email,
        "notes": notes,
        "reject_category": category,
        "reviewed_cues": 0,
        "submitted_for_review_at": None,
        "history": history,
    }

    full_language_qc = {**(job_doc.get("language_qc") or {}), lang: updated_state}
    qc_assignments = _rebuild_qc_assignments(full_language_qc)

    await db[_JOBS].update_one(
        {"_id": job_id},
        {"$set": {
            f"language_qc.{lang}": updated_state,
            "qc_assignments": qc_assignments,
            "updated_at": now,
        }}
    )

    await db[_JOBS].update_one(
        {"_id": job_id},
        {
            "$set": {"status": JobStatus.QC_FEEDBACK.value, "updated_at": now},
            "$push": {"review.history": {"at": now, "status": JobStatus.QC_FEEDBACK.value, "by": str(actor.id), "notes": notes}},
        }
    )

    await audit_logger.log_action(
        AuditAction.LANGUAGE_QC_REJECT,
        f"Language QC rejected: {lang} on job {job_id}",
        user=actor,
        request=http_request,
        severity=AuditLogSeverity.WARNING,
        resource_type="job_language",
        resource_id=f"{job_id}:{lang}",
        details={"lang": lang, "notes": notes},
    )

    recipients = _qc_recipients(job_doc, current_state_raw if isinstance(current_state_raw, dict) else {}, exclude_user_id=actor.email)
    if recipients:
        try:
            from ..services.emailer import email_service
            await asyncio.gather(*[
                email_service.send_qc_rejected_email(
                    to_email=email, full_name=name,
                    job_title=job_doc.get("title", job_id), lang=lang,
                    reviewer_name=actor.full_name or actor.email,
                    reason=notes,
                    deep_link=_deep_link(job_id, lang),
                )
                for email, name in recipients
            ], return_exceptions=True)
        except Exception:
            logger.exception("Failed to send rejection emails")

    return LanguageQCState(**updated_state)


async def reopen_language(
    db: AsyncIOMotorDatabase,
    job_id: str,
    lang: str,
    actor: User,
    *,
    http_request=None,
    notes: str | None = None,
) -> LanguageQCState:
    """PROD/ADMIN only — resets an approved language back to pending for re-review."""
    if actor.role not in (UserRole.PRODUCTION, UserRole.ADMIN):
        raise HTTPException(status_code=403, detail="Only PRODUCTION or ADMIN can reopen a language")

    job_doc = await db[_JOBS].find_one({"_id": job_id})
    if not job_doc:
        raise HTTPException(status_code=404, detail="Job not found")

    current_state_raw = (job_doc.get("language_qc") or {}).get(lang, {})
    now = datetime.utcnow()
    event = LanguageQCEvent(at=now, actor_user_id=str(actor.id), actor_email=actor.email, action="reopen", notes=notes)
    history = (current_state_raw.get("history", []) if isinstance(current_state_raw, dict) else []) + [event.model_dump()]

    updated_state = {
        **(current_state_raw if isinstance(current_state_raw, dict) else {}),
        "status": LanguageQCStatus.PENDING.value,
        "reviewed_at": None,
        "reviewed_by_user_id": None,
        "reviewed_by_email": None,
        "submitted_for_review_at": None,
        "review_started_at": None,
        "notes": notes,
        "history": history,
    }

    full_language_qc = {**(job_doc.get("language_qc") or {}), lang: updated_state}
    qc_assignments = _rebuild_qc_assignments(full_language_qc)

    await db[_JOBS].update_one(
        {"_id": job_id},
        {"$set": {
            f"language_qc.{lang}": updated_state,
            "qc_assignments": qc_assignments,
            "updated_at": now,
        }}
    )

    if job_doc["status"] == JobStatus.PENDING_FINAL_REVIEW.value:
        await db[_JOBS].update_one(
            {"_id": job_id},
            {
                "$set": {"status": JobStatus.PENDING_QC.value, "updated_at": now},
                "$push": {"review.history": {"at": now, "status": JobStatus.PENDING_QC.value, "by": str(actor.id), "notes": f"Language {lang} reopened: {notes or ''}"}},
            }
        )

    await audit_logger.log_action(
        AuditAction.LANGUAGE_QC_REOPEN,
        f"Language QC reopened: {lang} on job {job_id}",
        user=actor,
        request=http_request,
        resource_type="job_language",
        resource_id=f"{job_id}:{lang}",
        details={"lang": lang, "notes": notes},
    )

    return LanguageQCState(**updated_state)


# ── Comments ──────────────────────────────────────────────────────────────────

async def add_comment(
    db: AsyncIOMotorDatabase,
    job_id: str,
    lang: str,
    actor: User,
    body: str,
    *,
    http_request=None,
) -> LanguageQCComment:
    if not body or not body.strip():
        raise HTTPException(status_code=422, detail="Comment body cannot be empty")
    if len(body) > 4000:
        raise HTTPException(status_code=422, detail="Comment too long (max 4000 chars)")

    job_doc = await db[_JOBS].find_one({"_id": job_id})
    if not job_doc:
        raise HTTPException(status_code=404, detail="Job not found")

    # Gate: only assigned linguist, assigned reviewer, or PM/PROD/ADMIN
    current_state_raw = (job_doc.get("language_qc") or {}).get(lang, {})
    assigned_linguist = current_state_raw.get("assigned_linguist_id") if isinstance(current_state_raw, dict) else None
    assigned_reviewer = current_state_raw.get("assigned_reviewer_id") if isinstance(current_state_raw, dict) else None
    if actor.role not in (UserRole.PRODUCTION, UserRole.ADMIN, UserRole.PROJECT_MANAGER):
        if str(actor.id) not in (assigned_linguist, assigned_reviewer):
            raise HTTPException(status_code=403, detail="Not authorized to comment on this language")

    now = datetime.utcnow()
    comment = LanguageQCComment(
        id=str(uuid4()),
        author_id=str(actor.id),
        author_name=actor.full_name or "",
        author_email=actor.email,
        body=body.strip(),
        created_at=now,
    )

    await db[_JOBS].update_one(
        {"_id": job_id},
        {
            "$push": {f"language_qc.{lang}.comments": comment.model_dump()},
            "$set": {"updated_at": now},
        }
    )

    await audit_logger.log_action(
        AuditAction.LANGUAGE_QC_COMMENT,
        f"Comment added to language {lang} on job {job_id}",
        user=actor,
        request=http_request,
        resource_type="job_language",
        resource_id=f"{job_id}:{lang}",
        details={"lang": lang},
    )

    # WS broadcast — live comment indicator for everyone on this job
    try:
        await connection_manager.broadcast_to_job(job_id, {
            "type": "language_qc_comment",
            "job_id": job_id,
            "lang": lang,
            "data": {
                "author_name": actor.full_name or actor.email,
                "lang": lang,
                "comment_id": comment.id,
            },
        })
    except Exception:
        pass

    # Fan-out email to all other assignees
    recipients = _qc_recipients(job_doc, current_state_raw if isinstance(current_state_raw, dict) else {}, exclude_user_id=actor.email)
    if recipients:
        try:
            from ..services.emailer import email_service
            await asyncio.gather(*[
                email_service.send_qc_comment_email(
                    to_email=email, full_name=name,
                    job_title=job_doc.get("title", job_id), lang=lang,
                    author_name=actor.full_name or actor.email,
                    comment_body=body.strip(),
                    deep_link=_deep_link(job_id, lang),
                )
                for email, name in recipients
            ], return_exceptions=True)
        except Exception:
            logger.exception("Failed to send comment notification emails")

    return comment


# ── Queue / list ──────────────────────────────────────────────────────────────

async def list_for_linguist(
    db: AsyncIOMotorDatabase,
    linguist_id: str,
    *,
    accessible_org_ids: list[str] | None = None,
    status_filter: str | None = None,
    skip: int = 0,
    limit: int = 50,
) -> list[dict]:
    """Return jobs where the linguist has an assignment, along with which languages."""
    query: dict = {"qc_assignments.linguist_id": linguist_id}
    if accessible_org_ids is not None:
        query["organization_id"] = {"$in": accessible_org_ids}
    if status_filter:
        query["qc_assignments"] = {"$elemMatch": {"linguist_id": linguist_id, "status": status_filter}}

    cursor = db[_JOBS].find(query, {"title": 1, "status": 1, "language_qc": 1, "qc_assignments": 1, "created_at": 1, "updated_at": 1}).skip(skip).limit(limit).sort("updated_at", -1)
    jobs = await cursor.to_list(length=limit)

    result = []
    for job in jobs:
        my_langs = [a for a in (job.get("qc_assignments") or []) if a.get("linguist_id") == linguist_id]
        result.append({**job, "_my_assignments": my_langs})
    return result


async def list_for_reviewer(
    db: AsyncIOMotorDatabase,
    reviewer_id: str,
    *,
    accessible_org_ids: list[str] | None = None,
    status_filter: str | None = None,
    skip: int = 0,
    limit: int = 50,
) -> list[dict]:
    """Return jobs where the reviewer is assigned to at least one language."""
    # language_qc is a dict keyed by lang; pre-filter by org then scan in Python for assigned reviewer
    base_query: dict = {}
    if accessible_org_ids is not None:
        base_query["organization_id"] = {"$in": accessible_org_ids}
    all_jobs_cursor = db[_JOBS].find(
        base_query,
        {"title": 1, "status": 1, "language_qc": 1, "qc_assignments": 1, "created_at": 1, "updated_at": 1}
    ).sort("updated_at", -1).skip(skip).limit(limit * 5)  # over-fetch, filter in Python

    all_jobs = await all_jobs_cursor.to_list(length=limit * 5)

    result = []
    for job in all_jobs:
        my_langs = []
        for lang, state in (job.get("language_qc") or {}).items():
            if isinstance(state, dict) and state.get("assigned_reviewer_id") == reviewer_id:
                if not status_filter or state.get("status") == status_filter:
                    my_langs.append({"lang": lang, "status": state.get("status", "pending")})
        if my_langs:
            result.append({**job, "_my_assignments": my_langs})
        if len(result) >= limit:
            break

    return result


async def seed_language_qc_for_job(db: AsyncIOMotorDatabase, job_doc: dict) -> None:
    """Idempotently seed language_qc entries for all languages in a job's outputs."""
    job_id = str(job_doc["_id"])
    outputs = job_doc.get("outputs") or {}
    source_lang = job_doc.get("source", {}).get("language", "en")
    all_langs = list({source_lang} | set(outputs.keys()))

    job_status = job_doc.get("status", "")
    is_approved = job_status in (
        JobStatus.APPROVED_ENGLISH.value, JobStatus.APPROVED_SOURCE.value,
        JobStatus.PENDING_FINAL_REVIEW.value, JobStatus.COMPLETED.value,
    )

    existing_qc = job_doc.get("language_qc") or {}
    updates: dict[str, Any] = {}
    updated_lang_qc = dict(existing_qc)

    for lang in all_langs:
        if lang in existing_qc:
            continue
        state: dict[str, Any] = {
            "status": LanguageQCStatus.APPROVED.value if is_approved else LanguageQCStatus.PENDING.value,
            "history": [],
            "comments": [],
        }
        if is_approved:
            state["reviewed_by_user_id"] = job_doc.get("review", {}).get("reviewer_id")
            state["reviewed_at"] = job_doc.get("updated_at")
            state["notes"] = job_doc.get("review", {}).get("notes")
        updates[f"language_qc.{lang}"] = state
        updated_lang_qc[lang] = state

    if updates:
        updates["qc_assignments"] = _rebuild_qc_assignments(updated_lang_qc)
        updates["updated_at"] = datetime.utcnow()
        await db[_JOBS].update_one({"_id": job_id}, {"$set": updates})


async def reset_all_for_return_to_qc(db: AsyncIOMotorDatabase, job_id: str) -> None:
    """Called by return_to_qc — resets statuses to pending while preserving assignments and history."""
    job_doc = await db[_JOBS].find_one({"_id": job_id}, {"language_qc": 1})
    if not job_doc:
        return

    lang_qc = job_doc.get("language_qc") or {}
    updates: dict[str, Any] = {}
    for lang, state in lang_qc.items():
        if isinstance(state, dict):
            updates[f"language_qc.{lang}.status"] = LanguageQCStatus.PENDING.value
            updates[f"language_qc.{lang}.reviewed_at"] = None
            updates[f"language_qc.{lang}.reviewed_by_user_id"] = None
            updates[f"language_qc.{lang}.reviewed_by_email"] = None
            updates[f"language_qc.{lang}.submitted_for_review_at"] = None
            updates[f"language_qc.{lang}.review_started_at"] = None

    if updates:
        updated_lang_qc = {}
        for lang, state in lang_qc.items():
            updated_lang_qc[lang] = {**(state if isinstance(state, dict) else {}), "status": LanguageQCStatus.PENDING.value}

        updates["qc_assignments"] = _rebuild_qc_assignments(updated_lang_qc)
        await db[_JOBS].update_one({"_id": job_id}, {"$set": updates})


# ── Internal ──────────────────────────────────────────────────────────────────

def _assert_can_approve(job_doc: dict, lang: str, actor: User) -> None:
    """Raise 403 if actor cannot approve this language.

    Two-stage QC is enforced: linguist must submit before reviewer can approve.
    English-first is enforced: source language must be approved before any target.
    PRODUCTION and ADMIN may override both gates.
    """
    source_lang = (job_doc.get("source") or {}).get("language", "en")
    if lang != source_lang and actor.role not in (UserRole.PRODUCTION, UserRole.ADMIN):
        source_state = (job_doc.get("language_qc") or {}).get(source_lang, {})
        if not isinstance(source_state, dict):
            source_state = {}
        if source_state.get("status") != LanguageQCStatus.APPROVED.value:
            raise HTTPException(
                status_code=status.HTTP_409_CONFLICT,
                detail=f"Source language '{source_lang}' must be approved before approving '{lang}'",
            )

    if actor.role in (UserRole.PRODUCTION, UserRole.ADMIN):
        return

    state = (job_doc.get("language_qc") or {}).get(lang, {})
    if not isinstance(state, dict):
        state = {}

    assigned_reviewer = state.get("assigned_reviewer_id")
    if assigned_reviewer is None:
        raise HTTPException(
            status_code=403,
            detail=f"Language '{lang}' has no assigned reviewer — a reviewer must be assigned before approving",
        )

    if assigned_reviewer != str(actor.id):
        raise HTTPException(status_code=403, detail=f"You are not the assigned reviewer for language '{lang}'")

    submitted_at = state.get("submitted_for_review_at")
    if not submitted_at:
        raise HTTPException(
            status_code=403,
            detail=f"Language '{lang}' has not been submitted for review by the linguist yet",
        )


# Keep old name for any remaining callers
_assert_can_act = _assert_can_approve


async def _maybe_advance_job(db: AsyncIOMotorDatabase, job_doc: dict) -> None:
    """Advance job to pending_final_review if all languages are approved."""
    job_id = str(job_doc["_id"])
    if job_doc["status"] not in (JobStatus.PENDING_QC.value, JobStatus.QC_FEEDBACK.value):
        return

    if not _all_approved(job_doc):
        return

    now = datetime.utcnow()
    result = await db[_JOBS].find_one_and_update(
        {"_id": job_id, "status": {"$in": [JobStatus.PENDING_QC.value, JobStatus.QC_FEEDBACK.value]}},
        {
            "$set": {"status": JobStatus.PENDING_FINAL_REVIEW.value, "updated_at": now},
            "$push": {"review.history": {"at": now, "status": JobStatus.PENDING_FINAL_REVIEW.value, "by": "system", "notes": "All languages approved"}},
        },
        return_document=True,
    )
    if result:
        logger.info(f"Job {job_id} auto-advanced to pending_final_review — all languages approved")
        try:
            await connection_manager.broadcast_to_job(
                job_id,
                {"type": "job_status_change", "job_id": job_id, "status": JobStatus.PENDING_FINAL_REVIEW.value},
            )
        except Exception:
            pass
