import asyncio
import hashlib
from datetime import datetime

from bson import ObjectId
from fastapi import (
    APIRouter,
    Depends,
    File,
    Form,
    HTTPException,
    Request,
    UploadFile,
    status,
)
from fastapi.responses import StreamingResponse
from motor.motor_asyncio import AsyncIOMotorDatabase

from ...core.authz import MembershipContext, get_job_or_403, get_membership_context
from ...core.config import settings
from ...core.database import get_database
from ...core.dependencies import (
    get_accessible_project_ids,
    get_current_user,
    require_roles,
)
from ...core.logging import get_logger
from ...lib.vtt import VTTEditor
from ...models.audit_log import AuditAction
from ...models.job import JobStatus, LanguageQCStatus, RequestedOutputs
from ...models.user import User, UserRole
from ...schemas.accessible_video import (
    AccessibleVideoEditStateResponse,
    PausePointResponse,
    PausePointUpdateRequest,
    RerenderAccessibleVideoRequest,
    TTSRegenerationItem,
    TTSRegenerationQueueRequest,
    VideoSegmentResponse,
)
from ...schemas.job import (
    ApproveEnglishRequest,
    ApproveSourceRequest,
    AssetValidationResponse,
    BlockedOnSourceRequest,
    BulkApproveRequest,
    BulkApproveResponse,
    BulkDeleteRequest,
    BulkDeleteResponse,
    BulkDownloadRequest,
    BulkReturnToQCRequest,
    BulkReturnToQCResponse,
    CompleteJobRequest,
    JobDeleteResponse,
    JobDownloadsResponse,
    JobListResponse,
    JobResponse,
    JobUpdateRequest,
    PromoteToQCRequest,
    RejectJobRequest,
    RetranslateLanguageRequest,
    ReturnToQCRequest,
    UpdateTTSPreferencesRequest,
    UploadCompleteRequest,
    UploadInitRequest,
    UploadInitResponse,
    VttContentResponse,
    VttTimingAdjustRequest,
    VttUpdateRequest,
)
from ...services import language_qc as lqc
from ...services import vtt_versioning
from ...services.audit_logger import audit_logger, log_job_action
from ...services.cloud_run_dispatch import dispatch as _cr_dispatch
from ...services.gcs import (
    create_resumable_upload_session,
    gcs_service,
    upload_file_to_gcs,
    upload_vtt_to_gcs,
)
from ...services.validation import asset_validation_service
from ...services.websocket import connection_manager
from ...services.zip_download import generate_zip_filename, generate_zip_stream
from ...tasks import celery_app

logger = get_logger(__name__)
router = APIRouter(prefix="/jobs", tags=["jobs"])


# ── PR-3: Resumable (chunked) upload ──────────────────────────────────────────
# Browser uploads large files directly to GCS in 8 MB chunks, bypassing the
# load balancer timeout. Flow: POST /upload/init → browser PUTs chunks to GCS
# → POST /upload/complete → job created and ingestion dispatched.

@router.post("/upload/init", response_model=UploadInitResponse)
async def init_chunked_upload(
    payload: UploadInitRequest,
    current_user: User = Depends(get_current_user),
):
    """Create a GCS resumable upload session. Returns a job_id and session URI."""
    if not payload.content_type.startswith("video/"):
        raise HTTPException(status_code=400, detail="File must be a video")

    max_bytes = settings.upload_max_video_bytes
    if payload.file_size > max_bytes:
        raise HTTPException(
            status_code=413,
            detail=f"File too large (max {max_bytes // (1024 ** 3)} GB)",
        )

    job_id = str(ObjectId())
    gcs_path = f"{job_id}/source.mp4"

    upload_url = await create_resumable_upload_session(gcs_path, payload.content_type)
    return UploadInitResponse(job_id=job_id, upload_url=upload_url)


@router.post("/upload/complete", response_model=JobResponse, status_code=status.HTTP_201_CREATED)
async def complete_chunked_upload(
    payload: UploadCompleteRequest,
    request: Request = None,
    current_user: User = Depends(get_current_user),
    db: AsyncIOMotorDatabase = Depends(get_database),
):
    """Verify the GCS upload finished, create the job document, dispatch ingestion."""
    gcs_path = f"{payload.job_id}/source.mp4"

    if not await gcs_service.file_exists(gcs_path):
        raise HTTPException(status_code=400, detail="Upload not found in storage — complete the upload first")

    import json
    try:
        outputs_data = json.loads(json.dumps(payload.requested_outputs))
        outputs = RequestedOutputs(**outputs_data)
    except Exception:
        raise HTTPException(status_code=400, detail="Invalid requested_outputs format") from None

    organization_id: str | None = None
    brief_doc = None

    if payload.brief_id:
        from ...models.job_brief import BriefStatus as _BriefStatus
        brief_doc = await db.job_briefs.find_one({"_id": payload.brief_id})
        if not brief_doc:
            raise HTTPException(status_code=404, detail="Brief not found")
        if brief_doc["status"] != _BriefStatus.APPROVED.value:
            raise HTTPException(status_code=400, detail="Brief must be APPROVED before creating a job from it")
        organization_id = brief_doc["organization_id"]

    if not organization_id and payload.project_id:
        project = await db.projects.find_one({"_id": payload.project_id}, {"client_id": 1})
        if project:
            organization_id = project.get("client_id")

    gcs_uri = f"gs://{settings.gcs_bucket}/{gcs_path}"

    job_data = {
        "_id": payload.job_id,
        "client_id": str(current_user.id),
        "organization_id": organization_id,
        "brief_id": payload.brief_id or None,
        "gcs_prefix": f"orgs/{organization_id}/jobs/{payload.job_id}" if organization_id else None,
        "title": payload.title,
        "source": {
            "filename": gcs_path,
            "original_filename": payload.original_filename,
            "gcs_uri": gcs_uri,
            "language": "auto",
        },
        "requested_outputs": outputs.dict(),
        "status": JobStatus.CREATED.value,
        "review": {
            "notes": "",
            "history": [{"at": datetime.utcnow(), "status": JobStatus.CREATED.value, "by": "system"}],
        },
        "brand_context": payload.brand_context or None,
        "project_id": payload.project_id or None,
        "deadline": datetime.fromisoformat(payload.deadline) if payload.deadline else None,
        "initial_linguist_id": payload.initial_linguist_id or None,
        "initial_reviewer_id": payload.initial_reviewer_id or None,
        "created_at": datetime.utcnow(),
        "updated_at": datetime.utcnow(),
    }

    await db.jobs.insert_one(job_data)

    if brief_doc:
        from ...models.job_brief import BriefStatus as _BriefStatus2
        await db.job_briefs.update_one(
            {"_id": payload.brief_id},
            {"$set": {"job_id": payload.job_id, "status": _BriefStatus2.FULFILLED.value, "updated_at": datetime.utcnow()}},
        )

    try:
        await _cr_dispatch("ingest", payload.job_id)
        logger.info("Dispatched ingest task for chunked-upload job %s", payload.job_id)
    except Exception as e:
        logger.error("Failed to dispatch ingest task for job %s: %s", payload.job_id, e)
        raise HTTPException(status_code=500, detail=f"Failed to start processing: {e}") from None

    await log_job_action(
        AuditAction.JOB_CREATE, payload.job_id, current_user, request,
        details={"title": payload.title, "filename": payload.original_filename, "upload_method": "chunked"},
    )

    return JobResponse(
        id=payload.job_id,
        title=payload.title,
        status=JobStatus.CREATED,
        source=job_data["source"],
        requested_outputs=outputs,
        review=job_data["review"],
        created_at=job_data["created_at"].isoformat(),
        updated_at=job_data["updated_at"].isoformat(),
    )


@router.post("", response_model=JobResponse, status_code=status.HTTP_201_CREATED)
async def create_job(
    title: str = Form(...),
    requested_outputs: str = Form(...),  # JSON string
    file: UploadFile = File(...),
    brand_context: str | None = Form(None),
    project_id: str | None = Form(None),
    brief_id: str | None = Form(None),
    deadline: str | None = Form(None),  # ISO date string e.g. "2026-05-15"
    initial_linguist_id: str | None = Form(None),
    initial_reviewer_id: str | None = Form(None),
    request: Request = None,
    current_user: User = Depends(get_current_user),
    db: AsyncIOMotorDatabase = Depends(get_database),
):
    # Validate file type
    if not file.content_type or not file.content_type.startswith("video/"):
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="File must be a video"
        )

    # Parse requested outputs
    import json
    try:
        outputs_data = json.loads(requested_outputs)
        outputs = RequestedOutputs(**outputs_data)
    except Exception:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Invalid requested_outputs format"
        ) from None

    # Resolve brief if provided — overrides some fields and sets organization_id
    brief_doc = None
    organization_id: str | None = None
    if brief_id:
        from ...models.job_brief import BriefStatus as _BriefStatus
        brief_doc = await db.job_briefs.find_one({"_id": brief_id})
        if not brief_doc:
            raise HTTPException(status_code=404, detail="Brief not found")
        if brief_doc["status"] != _BriefStatus.APPROVED.value:
            raise HTTPException(status_code=400, detail="Brief must be APPROVED before creating a job from it")
        organization_id = brief_doc["organization_id"]
        # Brief fields take precedence for pre-fill values
        if not project_id:
            project_id = brief_doc.get("project_id")
        if not deadline:
            deadline = brief_doc["deadline"].isoformat() if brief_doc.get("deadline") else None

    # Resolve organization_id from project if not yet set
    if not organization_id and project_id:
        project = await db.projects.find_one({"_id": project_id}, {"client_id": 1})
        if project:
            organization_id = project.get("client_id")

    # Generate job ID and upload file
    job_id = str(ObjectId())
    gcs_uri = await upload_file_to_gcs(
        file, f"{job_id}/source.mp4"
    )

    # Create job document
    # Language is always "auto" - will be detected by AI during processing
    job_data = {
        "_id": job_id,
        "client_id": str(current_user.id),
        "organization_id": organization_id,
        "brief_id": brief_id or None,
        "gcs_prefix": f"orgs/{organization_id}/jobs/{job_id}" if organization_id else None,
        "title": title,
        "source": {
            "filename": f"{job_id}/source.mp4",
            "original_filename": file.filename,
            "gcs_uri": gcs_uri,
            "language": "auto",
        },
        "requested_outputs": outputs.dict(),
        "status": JobStatus.CREATED.value,
        "review": {
            "notes": "",
            "history": [{
                "at": datetime.utcnow(),
                "status": JobStatus.CREATED.value,
                "by": "system"
            }]
        },
        "brand_context": brand_context or None,
        "project_id": project_id or None,
        "deadline": datetime.fromisoformat(deadline) if deadline else None,
        "initial_linguist_id": initial_linguist_id or None,
        "initial_reviewer_id": initial_reviewer_id or None,
        "created_at": datetime.utcnow(),
        "updated_at": datetime.utcnow()
    }

    await db.jobs.insert_one(job_data)

    # Mark brief as fulfilled if job was created from one
    if brief_doc:
        from ...models.job_brief import BriefStatus as _BriefStatus2
        await db.job_briefs.update_one(
            {"_id": brief_id},
            {"$set": {"job_id": job_id, "status": _BriefStatus2.FULFILLED.value, "updated_at": datetime.utcnow()}},
        )

    # Enqueue processing task
    try:
        await _cr_dispatch("ingest", job_id)
        logger.info("Dispatched ingest task for job %s", job_id)
    except Exception as e:
        logger.error("Failed to dispatch ingest task for job %s: %s", job_id, e)
        raise HTTPException(
            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
            detail=f"Failed to start processing: {e}",
        ) from None

    await log_job_action(
        AuditAction.JOB_CREATE, job_id, current_user, request,
        details={"title": title, "filename": file.filename},
    )
    return JobResponse(
        id=job_id,
        title=title,
        status=JobStatus.CREATED,
        source=job_data["source"],
        requested_outputs=outputs,
        review=job_data["review"],
        created_at=job_data["created_at"].isoformat(),
        updated_at=job_data["updated_at"].isoformat()
    )


@router.delete("/bulk", response_model=BulkDeleteResponse)
async def bulk_delete_jobs(
    request: BulkDeleteRequest,
    current_user: User = Depends(require_roles(UserRole.PRODUCTION, UserRole.ADMIN)),
    db: AsyncIOMotorDatabase = Depends(get_database),
):
    """Bulk delete jobs (production/admin only)"""
    # Deduplicate job IDs to avoid processing the same job twice
    unique_job_ids = list(dict.fromkeys(request.job_ids))
    if len(unique_job_ids) != len(request.job_ids):
        logger.warning(f"Removed {len(request.job_ids) - len(unique_job_ids)} duplicate job IDs from bulk delete request")

    logger.info(f"Bulk deleting {len(unique_job_ids)} jobs requested by {current_user.email}")

    deleted_count = 0
    already_deleted = 0
    errors = []

    for job_id in unique_job_ids:
        try:
            job_doc = await db.jobs.find_one({"_id": job_id})
            if not job_doc:
                # Job may have been deleted by a concurrent request
                already_deleted += 1
                logger.debug(f"Job {job_id} not found (may have been deleted by concurrent request)")
                continue

            # Cancel task if exists
            task_id = job_doc.get("task_id")
            if task_id:
                try:
                    celery_app.control.revoke(task_id, terminate=True)
                except Exception as e:
                    logger.warning(f"Could not cancel task {task_id} for job {job_id}: {e}")

            # Delete GCS assets (errors are logged but don't block deletion)
            await _delete_job_gcs_assets(job_id, job_doc)

            # Delete from database
            result = await db.jobs.delete_one({"_id": job_id})
            if result.deleted_count > 0:
                deleted_count += 1
                logger.info(f"Deleted job {job_id}")
            else:
                # Job was deleted between find_one and delete_one (race condition)
                already_deleted += 1
                logger.debug(f"Job {job_id} was already deleted")

        except Exception as e:
            errors.append(f"Job {job_id}: {str(e)}")
            logger.error(f"Failed to delete job {job_id}: {e}")

    # Consider already_deleted as successful (idempotent delete)
    total_successful = deleted_count + already_deleted

    return {
        "deleted_count": total_successful,
        "total_requested": len(unique_job_ids),
        "errors": errors
    }


@router.post("/bulk/approve", response_model=BulkApproveResponse)
async def bulk_approve_jobs(
    request: BulkApproveRequest,
    current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)),
    db: AsyncIOMotorDatabase = Depends(get_database),
):
    """Bulk approve multiple jobs with optional method selection for accessible video"""
    # Deduplicate job IDs
    unique_job_ids = list(dict.fromkeys(request.job_ids))
    if len(unique_job_ids) != len(request.job_ids):
        logger.warning(f"Removed {len(request.job_ids) - len(unique_job_ids)} duplicate job IDs from bulk approve request")

    logger.info(f"Bulk approving {len(unique_job_ids)} jobs requested by {current_user.email}")

    approved_count = 0
    errors = []

    for job_id in unique_job_ids:
        try:
            # Get job to check status and determine source language
            job_doc = await db.jobs.find_one({"_id": job_id})
            if not job_doc:
                errors.append(f"Job {job_id}: not found")
                continue

            if job_doc["status"] != JobStatus.PENDING_QC.value:
                errors.append(f"Job {job_id}: not in pending QC status")
                continue

            # Transition directly to PENDING_FINAL_REVIEW
            # Translation, TTS, and accessible video rendering now happen BEFORE QC
            new_status = JobStatus.PENDING_FINAL_REVIEW

            # Build update operations
            update_set = {
                "status": new_status.value,
                "review.notes": request.notes or "",
                "review.reviewer_id": str(current_user.id),
                "updated_at": datetime.utcnow()
            }

            # If TTS preferences provided, update
            if request.tts_preferences:
                update_set["requested_outputs.tts_preferences"] = request.tts_preferences.model_dump()

            # If accessible video method provided, update
            if request.accessible_video_method:
                update_set["requested_outputs.accessible_video_method"] = request.accessible_video_method.value

            result = await db.jobs.find_one_and_update(
                {"_id": job_id, "status": JobStatus.PENDING_QC.value},
                {
                    "$set": update_set,
                    "$push": {
                        "review.history": {
                            "at": datetime.utcnow(),
                            "status": new_status.value,
                            "by": str(current_user.id),
                            "notes": request.notes or ""
                        }
                    }
                },
                return_document=True
            )

            if result:
                approved_count += 1
                # No need to trigger translate_and_synthesize_task - all processing done before QC
                logger.info(f"Job {job_id} approved, transitioning directly to final review")
            else:
                errors.append(f"Job {job_id}: update failed (may have been modified concurrently)")

        except Exception as e:
            errors.append(f"Job {job_id}: {str(e)}")
            logger.error(f"Failed to approve job {job_id}: {e}")

    return {
        "approved_count": approved_count,
        "total_requested": len(unique_job_ids),
        "errors": errors
    }


@router.post("/bulk/return-to-qc", response_model=BulkReturnToQCResponse)
async def bulk_return_to_qc(
    request: BulkReturnToQCRequest,
    current_user: User = Depends(require_roles(UserRole.PRODUCTION, UserRole.ADMIN)),
    db: AsyncIOMotorDatabase = Depends(get_database),
):
    """Bulk return multiple jobs to QC review status."""
    unique_job_ids = list(dict.fromkeys(request.job_ids))
    if len(unique_job_ids) != len(request.job_ids):
        logger.warning(f"Removed {len(request.job_ids) - len(unique_job_ids)} duplicate job IDs from bulk return-to-qc request")

    logger.info(f"Bulk return-to-qc for {len(unique_job_ids)} jobs requested by {current_user.email}")

    returned_count = 0
    errors = []

    for job_id in unique_job_ids:
        try:
            job_doc = await db.jobs.find_one({"_id": job_id})
            if not job_doc:
                errors.append(f"Job {job_id}: not found")
                continue

            if job_doc["status"] not in RETURN_TO_QC_ELIGIBLE_STATUSES:
                errors.append(f"Job {job_id}: cannot return to QC from status '{job_doc['status']}'")
                continue

            result = await db.jobs.find_one_and_update(
                {"_id": job_id},
                {
                    "$set": {
                        "status": JobStatus.PENDING_QC.value,
                        "error": None,
                        "updated_at": datetime.utcnow()
                    },
                    "$push": {
                        "review.history": {
                            "at": datetime.utcnow(),
                            "status": JobStatus.PENDING_QC.value,
                            "by": str(current_user.id),
                            "notes": request.notes
                        }
                    }
                },
                return_document=True
            )

            if result:
                returned_count += 1
                logger.info(f"Job {job_id} returned to QC")
            else:
                errors.append(f"Job {job_id}: update failed (may have been modified concurrently)")

        except Exception as e:
            errors.append(f"Job {job_id}: {str(e)}")
            logger.error(f"Failed to return job {job_id} to QC: {e}")

    return {
        "returned_count": returned_count,
        "total_requested": len(unique_job_ids),
        "errors": errors
    }


# Downloadable job statuses
DOWNLOADABLE_STATUSES = [
    JobStatus.COMPLETED.value,
    JobStatus.APPROVED_ENGLISH.value,
    JobStatus.APPROVED_SOURCE.value,
]


@router.post("/bulk/download")
async def bulk_download_jobs(
    request: BulkDownloadRequest,
    current_user: User = Depends(get_current_user),
    db: AsyncIOMotorDatabase = Depends(get_database),
):
    """
    Generate a zip file containing all downloadable assets from specified jobs.

    Returns a streaming zip file with structure:
    {job_title}/
    +-- source.mp4
    +-- {lang}/
        +-- captions.vtt
        +-- ad.vtt
        +-- ad.mp3
        +-- accessible_video.mp4
        +-- accessible_captions.vtt

    Requirements:
    - Jobs must be in downloadable status (completed, approved_english, approved_source)
    - Client users can only download their own jobs
    - Maximum 50 jobs per request
    """
    # Validate request
    if not request.job_ids:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="No jobs specified"
        )

    # Deduplicate and limit
    unique_job_ids = list(dict.fromkeys(request.job_ids))
    MAX_JOBS = 50
    if len(unique_job_ids) > MAX_JOBS:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=f"Maximum {MAX_JOBS} jobs allowed per download"
        )

    # Fetch jobs
    jobs = await db.jobs.find({"_id": {"$in": unique_job_ids}}).to_list(length=MAX_JOBS)

    if not jobs:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="No jobs found"
        )

    # Filter by permissions
    _accessible_pids = await get_accessible_project_ids(current_user, db)
    valid_jobs = []
    for job_doc in jobs:
        if _accessible_pids is not None:
            _jpid = job_doc.get("project_id")
            _own_legacy = job_doc.get("client_id") == str(current_user.id) and not _jpid
            if not _own_legacy and not (_jpid and _jpid in _accessible_pids):
                continue

        # Check downloadable status
        if job_doc["status"] not in DOWNLOADABLE_STATUSES:
            continue

        # Check has outputs
        if not job_doc.get("outputs"):
            continue

        valid_jobs.append(job_doc)

    if not valid_jobs:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="No eligible jobs to download (jobs must be approved/completed and have outputs)"
        )

    # Log download request
    logger.info(
        f"Bulk download requested by {current_user.email}: "
        f"{len(valid_jobs)} jobs ({len(unique_job_ids)} requested)"
    )

    # Generate zip filename
    filename = generate_zip_filename()

    # Return streaming response
    return StreamingResponse(
        generate_zip_stream(valid_jobs),
        media_type="application/zip",
        headers={
            "Content-Disposition": f'attachment; filename="{filename}"',
            "Cache-Control": "no-cache",
            "X-Job-Count": str(len(valid_jobs)),
        }
    )


@router.get("", response_model=JobListResponse)
async def list_jobs(
    status: str | None = None,
    mine: bool = False,
    page: int = 1,
    size: int = 20,
    ctx: MembershipContext = Depends(get_membership_context),
    db: AsyncIOMotorDatabase = Depends(get_database),
):
    current_user = ctx.user
    try:
        query = {}

        if status:
            if ',' in status:
                status_list = [s.strip() for s in status.split(',')]
                query["status"] = {"$in": status_list}
            else:
                query["status"] = status

        if mine:
            query["client_id"] = str(current_user.id)
        elif not ctx.is_platform_admin:
            org_ids = ctx.accessible_org_ids()
            if org_ids:
                # Org-scoped: include org-tagged jobs + legacy project-scoped + own orphan jobs
                project_docs = await db.projects.find(
                    {"client_id": {"$in": org_ids}, "is_active": True}, {"_id": 1}
                ).to_list(None)
                project_ids = [str(p["_id"]) for p in project_docs]
                own_orphan = {"client_id": str(current_user.id), "organization_id": None, "project_id": None}
                org_filter: dict = {"organization_id": {"$in": org_ids}}
                clauses = [org_filter, own_orphan]
                if project_ids:
                    clauses.append({"project_id": {"$in": project_ids}, "organization_id": None})
                query["$or"] = clauses
            else:
                # No memberships yet: fall back to legacy project-scoped access
                project_ids = await get_accessible_project_ids(current_user, db)
                if project_ids is not None:
                    legacy_filter = {"client_id": str(current_user.id), "project_id": None}
                    if project_ids:
                        query["$or"] = [{"project_id": {"$in": project_ids}}, legacy_filter]
                    else:
                        query.update(legacy_filter)

        # Get total count
        total = await db.jobs.count_documents(query)

        # Get paginated results
        skip = (page - 1) * size
        cursor = db.jobs.find(query).sort("created_at", -1).skip(skip).limit(size)
        jobs = await cursor.to_list(length=size)

        # Batch fetch user names for all jobs
        client_ids = list({job_doc.get("client_id") for job_doc in jobs if job_doc.get("client_id")})
        user_names = {}
        if client_ids:
            users_cursor = db.users.find(
                {"_id": {"$in": client_ids}},
                {"_id": 1, "full_name": 1}
            )
            async for user_doc in users_cursor:
                user_names[str(user_doc["_id"])] = user_doc.get("full_name", "Unknown")

        job_responses = []
        print(f"🔍 DEBUG: Found {len(jobs)} jobs, building responses...")
        for i, job_doc in enumerate(jobs):
            try:
                print(f"🔍 DEBUG: Processing job {i}: {job_doc.get('_id')}")
                client_id = job_doc.get("client_id")
                created_by_name = user_names.get(client_id, "Unknown") if client_id else "Unknown"
                job_responses.append(JobResponse(
                    id=str(job_doc["_id"]),
                    client_id=client_id,
                    title=job_doc["title"],
                    status=job_doc["status"],
                    source=job_doc["source"],
                    requested_outputs=RequestedOutputs(**job_doc["requested_outputs"]),
                    review=job_doc.get("review", {"notes": "", "history": []}),
                    outputs=job_doc.get("outputs"),
                    created_at=job_doc["created_at"].isoformat(),
                    updated_at=job_doc["updated_at"].isoformat(),
                    created_by_name=created_by_name
                ))
                print(f"🔍 DEBUG: Job {i} processed successfully")
            except Exception as e:
                print(f"🚨 ERROR processing job {i}: {e}")
                import traceback
                print(traceback.format_exc())
                raise

        print("🔍 DEBUG: All jobs processed, returning response")
        return JobListResponse(
            jobs=job_responses,
            total=total,
            page=page,
            size=size
        )
    except Exception as e:
        print(f"🚨 EXCEPTION in list_jobs: {e}")
        import traceback
        print(traceback.format_exc())
        raise


@router.get("/{job_id}", response_model=JobResponse)
async def get_job(
    job_id: str,
    ctx: MembershipContext = Depends(get_membership_context),
    db: AsyncIOMotorDatabase = Depends(get_database),
):
    job_doc = await get_job_or_403(job_id, ctx, db)

    # Check task status if task_id exists
    task_id = job_doc.get("task_id")
    if task_id and job_doc["status"] == JobStatus.CREATED.value:
        try:
            task_result = celery_app.AsyncResult(task_id)
            if task_result.failed():
                logger.error(f"Task {task_id} failed for job {job_id} - State: {task_result.state}, Error: {task_result.result}")
                # Update job status to reflect task failure
                await db.jobs.update_one(
                    {"_id": job_id},
                    {
                        "$set": {
                            "error": {
                                "type": "task_failure",
                                "message": str(task_result.result) if task_result.result else "Unknown task failure",
                                "timestamp": datetime.utcnow().isoformat()
                            },
                            "updated_at": datetime.utcnow()
                        }
                    }
                )
        except Exception as e:
            logger.warning(f"Could not check task status for job {job_id}: {e}")

    return JobResponse(
        id=str(job_doc["_id"]),
        title=job_doc["title"],
        status=job_doc["status"],
        source=job_doc["source"],
        requested_outputs=RequestedOutputs(**job_doc["requested_outputs"]),
        review=job_doc.get("review", {"notes": "", "history": []}),
        outputs=job_doc.get("outputs"),
        accessible_video_progress=job_doc.get("accessible_video_progress"),
        failure=job_doc.get("failure"),
        error=job_doc.get("error"),
        created_at=job_doc["created_at"].isoformat(),
        updated_at=job_doc["updated_at"].isoformat(),
        cost_tracker_project_id=job_doc.get("cost_tracker_project_id"),
    )


@router.patch("/{job_id}", response_model=JobResponse)
async def update_job(
    job_id: str,
    request: JobUpdateRequest,
    current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.ADMIN)),
    ctx: MembershipContext = Depends(get_membership_context),
    db: AsyncIOMotorDatabase = Depends(get_database),
):
    """Update mutable job metadata (title, cost_tracker_project_id)."""
    await get_job_or_403(job_id, ctx, db)  # org check

    update_set: dict = {"updated_at": datetime.utcnow()}
    if request.title is not None:
        update_set["title"] = request.title
    if request.cost_tracker_project_id is not None:
        update_set["cost_tracker_project_id"] = request.cost_tracker_project_id

    if len(update_set) == 1:
        # Only updated_at — nothing to do
        pass
    else:
        await db.jobs.update_one({"_id": job_id}, {"$set": update_set})

    updated = await db.jobs.find_one({"_id": job_id})
    return JobResponse(
        id=str(updated["_id"]),
        title=updated["title"],
        status=updated["status"],
        source=updated["source"],
        requested_outputs=RequestedOutputs(**updated["requested_outputs"]),
        review=updated.get("review", {"notes": "", "history": []}),
        outputs=updated.get("outputs"),
        created_at=updated["created_at"].isoformat(),
        updated_at=updated["updated_at"].isoformat(),
        cost_tracker_project_id=updated.get("cost_tracker_project_id"),
    )


@router.post("/{job_id}/actions/approve_source", response_model=JobResponse)
async def approve_source(
    job_id: str,
    request: ApproveSourceRequest,
    http_request: Request = None,
    current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)),
    ctx: MembershipContext = Depends(get_membership_context),
    db: AsyncIOMotorDatabase = Depends(get_database),
):
    """Approve the source language version (works for any language)"""
    job_doc = await get_job_or_403(job_id, ctx, db)

    if job_doc["status"] != JobStatus.PENDING_QC.value:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Job not in pending QC status"
        )

    # Determine if there are target languages that still need translation.
    source_language = job_doc["source"].get("language", "en")
    requested_langs = job_doc.get("requested_outputs", {}).get("languages", [])
    target_languages = [lg for lg in requested_langs if lg != source_language]
    existing_outputs = job_doc.get("outputs") or {}
    untranslated = [
        lg for lg in target_languages
        if not (existing_outputs.get(lg) or {}).get("captions_vtt_gcs")
    ]

    # If untranslated target languages exist, dispatch translation instead of
    # jumping straight to final review. Any role can trigger this.
    if untranslated:
        new_status = JobStatus.TRANSLATING
    else:
        new_status = JobStatus.PENDING_FINAL_REVIEW

    # Build update operations
    update_set = {
        "status": new_status.value,
        "review.notes": request.notes or "",
        "review.reviewer_id": str(current_user.id),
        "updated_at": datetime.utcnow()
    }

    # If TTS preferences override provided, update requested_outputs.tts_preferences
    if request.tts_preferences:
        update_set["requested_outputs.tts_preferences"] = request.tts_preferences.model_dump()
        logger.info(f"Updating TTS preferences for job {job_id}: {request.tts_preferences}")

    # If accessible video method override provided, update requested_outputs.accessible_video_method
    if request.accessible_video_method:
        update_set["requested_outputs.accessible_video_method"] = request.accessible_video_method.value
        logger.info(f"Setting accessible video method for job {job_id}: {request.accessible_video_method}")

    result = await db.jobs.find_one_and_update(
        {"_id": job_id, "status": JobStatus.PENDING_QC.value},
        {
            "$set": update_set,
            "$push": {
                "review.history": {
                    "at": datetime.utcnow(),
                    "status": new_status.value,
                    "by": str(current_user.id),
                    "notes": request.notes or ""
                }
            }
        },
        return_document=True
    )

    if not result:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="Job not found or not in pending QC status"
        )

    if untranslated:
        await _cr_dispatch("translate", job_doc["_id"], languages=untranslated)
        logger.info(f"Job {job_id} source approved — dispatching translation for {untranslated}")
    else:
        logger.info(f"Job {job_id} approved, transitioning directly to final review")

    await log_job_action(
        AuditAction.JOB_APPROVE, job_id, current_user, http_request,
        details={"notes": request.notes, "new_status": new_status.value},
    )
    return JobResponse(
        id=str(result["_id"]),
        title=result["title"],
        status=result["status"],
        source=result["source"],
        requested_outputs=RequestedOutputs(**result["requested_outputs"]),
        review=result.get("review", {"notes": "", "history": []}),
        outputs=result.get("outputs"),
        created_at=result["created_at"].isoformat(),
        updated_at=result["updated_at"].isoformat()
    )


@router.post("/{job_id}/actions/approve_english", response_model=JobResponse)
async def approve_english(
    job_id: str,
    request: ApproveEnglishRequest,
    current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)),
    ctx: MembershipContext = Depends(get_membership_context),
    db: AsyncIOMotorDatabase = Depends(get_database),
):
    """Legacy endpoint - redirects to approve_source for backwards compatibility"""
    await get_job_or_403(job_id, ctx, db)  # org check before delegation
    return await approve_source(
        job_id,
        ApproveSourceRequest(notes=request.notes),
        current_user=current_user,
        ctx=ctx,
        db=db,
    )


@router.post("/{job_id}/actions/reject", response_model=JobResponse)
async def reject_job(
    job_id: str,
    request: RejectJobRequest,
    http_request: Request = None,
    current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)),
    ctx: MembershipContext = Depends(get_membership_context),
    db: AsyncIOMotorDatabase = Depends(get_database),
):
    await get_job_or_403(job_id, ctx, db)  # org check
    result = await db.jobs.find_one_and_update(
        {"_id": job_id, "status": JobStatus.PENDING_QC.value},
        {
            "$set": {
                "status": JobStatus.REJECTED.value,
                "review.notes": request.notes,
                "review.reviewer_id": str(current_user.id),
                "updated_at": datetime.utcnow()
            },
            "$push": {
                "review.history": {
                    "at": datetime.utcnow(),
                    "status": JobStatus.REJECTED.value,
                    "by": str(current_user.id),
                    "notes": request.notes
                }
            }
        },
        return_document=True
    )

    if not result:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="Job not found or not in pending QC status"
        )

    await log_job_action(
        AuditAction.JOB_REJECT, job_id, current_user, http_request,
        details={"notes": request.notes},
    )
    return JobResponse(
        id=str(result["_id"]),
        title=result["title"],
        status=result["status"],
        source=result["source"],
        requested_outputs=RequestedOutputs(**result["requested_outputs"]),
        review=result.get("review", {"notes": "", "history": []}),
        outputs=result.get("outputs"),
        created_at=result["created_at"].isoformat(),
        updated_at=result["updated_at"].isoformat()
    )


@router.post("/{job_id}/actions/complete", response_model=JobResponse)
async def complete_job(
    job_id: str,
    request: CompleteJobRequest,
    http_request: Request = None,
    current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN, UserRole.PROJECT_MANAGER)),
    ctx: MembershipContext = Depends(get_membership_context),
    db: AsyncIOMotorDatabase = Depends(get_database),
):
    job_doc = await get_job_or_403(job_id, ctx, db)

    if job_doc["status"] != JobStatus.PENDING_FINAL_REVIEW.value:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Job not ready for completion"
        )

    # Validate all assets before completion
    is_valid, validation_errors = await asset_validation_service.validate_job_assets(job_doc)
    if not is_valid:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=f"Asset validation failed: {'; '.join(validation_errors)}"
        )

    result = await db.jobs.find_one_and_update(
        {"_id": job_id, "status": JobStatus.PENDING_FINAL_REVIEW.value},
        {
            "$set": {
                "status": JobStatus.COMPLETED.value,
                "review.notes": request.notes or "",
                "updated_at": datetime.utcnow()
            },
            "$push": {
                "review.history": {
                    "at": datetime.utcnow(),
                    "status": JobStatus.COMPLETED.value,
                    "by": str(current_user.id),
                    "notes": request.notes or ""
                }
            }
        },
        return_document=True
    )

    if not result:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="Job not found or not in pending final review status"
        )


    await log_job_action(
        AuditAction.JOB_APPROVE, job_id, current_user, http_request,
        details={"notes": request.notes, "new_status": JobStatus.COMPLETED.value},
    )

    # Trigger client notification task now that job is completed
    try:
        from ...tasks.notify import notify_client_task
        notify_client_task.delay(job_id)
        logger.info(f"Triggered client notification task for completed job {job_id}")
    except Exception as e:
        logger.warning(f"Failed to trigger client notification task for job {job_id}: {e}")

    return JobResponse(
        id=str(result["_id"]),
        title=result["title"],
        status=result["status"],
        source=result["source"],
        requested_outputs=RequestedOutputs(**result["requested_outputs"]),
        review=result.get("review", {"notes": "", "history": []}),
        outputs=result.get("outputs"),
        created_at=result["created_at"].isoformat(),
        updated_at=result["updated_at"].isoformat()
    )


@router.post("/{job_id}/actions/reject_final", response_model=JobResponse)
async def reject_final_review(
    job_id: str,
    request: RejectJobRequest,
    http_request: Request = None,
    current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)),
    ctx: MembershipContext = Depends(get_membership_context),
    db: AsyncIOMotorDatabase = Depends(get_database),
):
    await get_job_or_403(job_id, ctx, db)  # org check
    result = await db.jobs.find_one_and_update(
        {"_id": job_id, "status": JobStatus.PENDING_FINAL_REVIEW.value},
        {
            "$set": {
                "status": JobStatus.QC_FEEDBACK.value,
                "review.notes": request.notes,
                "review.reviewer_id": str(current_user.id),
                "updated_at": datetime.utcnow()
            },
            "$push": {
                "review.history": {
                    "at": datetime.utcnow(),
                    "status": JobStatus.QC_FEEDBACK.value,
                    "by": str(current_user.id),
                    "notes": request.notes
                }
            }
        },
        return_document=True
    )

    if not result:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="Job not found or not in pending final review status"
        )

    await log_job_action(
        AuditAction.JOB_REJECT, job_id, current_user, http_request,
        details={"notes": request.notes, "stage": "final_review"},
    )
    return JobResponse(
        id=str(result["_id"]),
        title=result["title"],
        status=result["status"],
        source=result["source"],
        requested_outputs=RequestedOutputs(**result["requested_outputs"]),
        review=result.get("review", {"notes": "", "history": []}),
        outputs=result.get("outputs"),
        created_at=result["created_at"].isoformat(),
        updated_at=result["updated_at"].isoformat()
    )


# Statuses eligible for "Return to QC" action
RETURN_TO_QC_ELIGIBLE_STATUSES = [
    JobStatus.COMPLETED.value,
    JobStatus.PENDING_FINAL_REVIEW.value,
    JobStatus.REJECTED.value,
    JobStatus.QC_FEEDBACK.value,
    JobStatus.TTS_FAILED.value,
    JobStatus.RENDER_FAILED.value,
    JobStatus.APPROVED_ENGLISH.value,
    JobStatus.APPROVED_SOURCE.value,
]


@router.post("/{job_id}/actions/return_to_qc", response_model=JobResponse)
async def return_to_qc(
    job_id: str,
    request: ReturnToQCRequest,
    http_request: Request = None,
    current_user: User = Depends(require_roles(UserRole.PRODUCTION, UserRole.ADMIN)),
    ctx: MembershipContext = Depends(get_membership_context),
    db: AsyncIOMotorDatabase = Depends(get_database),
):
    """Return a job to QC review status for re-editing."""
    job_doc = await get_job_or_403(job_id, ctx, db)

    if job_doc["status"] not in RETURN_TO_QC_ELIGIBLE_STATUSES:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=f"Job cannot be returned to QC from status '{job_doc['status']}'"
        )

    result = await db.jobs.find_one_and_update(
        {"_id": job_id},
        {
            "$set": {
                "status": JobStatus.PENDING_QC.value,
                "error": None,
                "updated_at": datetime.utcnow()
            },
            "$push": {
                "review.history": {
                    "at": datetime.utcnow(),
                    "status": JobStatus.PENDING_QC.value,
                    "by": str(current_user.id),
                    "notes": request.notes
                }
            }
        },
        return_document=True
    )

    if not result:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="Job not found or status changed"
        )

    # Reset per-language QC statuses while preserving assignments
    await lqc.reset_all_for_return_to_qc(db, job_id)

    logger.info(f"Job {job_id} returned to QC by {current_user.email}: {request.notes}")

    await log_job_action(
        AuditAction.JOB_STATUS_CHANGE, job_id, current_user, http_request,
        details={"new_status": JobStatus.PENDING_QC.value, "notes": request.notes},
    )
    return JobResponse(
        id=str(result["_id"]),
        title=result["title"],
        status=result["status"],
        source=result["source"],
        requested_outputs=RequestedOutputs(**result["requested_outputs"]),
        review=result.get("review", {"notes": "", "history": []}),
        outputs=result.get("outputs"),
        created_at=result["created_at"].isoformat(),
        updated_at=result["updated_at"].isoformat()
    )


@router.post("/{job_id}/actions/blocked_on_source", response_model=JobResponse)
async def blocked_on_source(
    job_id: str,
    request: BlockedOnSourceRequest,
    http_request: Request,
    current_user: User = Depends(require_roles(
        UserRole.LINGUIST, UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.ADMIN,
    )),
    ctx: MembershipContext = Depends(get_membership_context),
    db: AsyncIOMotorDatabase = Depends(get_database),
):
    """Linguist/Reviewer flags that the source video has an issue requiring PM attention (L-18).
    Transitions job to QC_FEEDBACK and notifies the PM."""
    await get_job_or_403(job_id, ctx, db)  # org check

    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.QC_FEEDBACK.value,
            "review.notes": f"[BLOCKED ON SOURCE] {request.reason}",
            "blocked_on_source_reason": request.reason,
            "blocked_on_source_at": now,
            "blocked_on_source_by": str(current_user.id),
            "updated_at": now,
        }},
        return_document=True,
    )
    if not result:
        raise HTTPException(status_code=400, detail="Job is not in a QC status")

    await log_job_action(
        AuditAction.JOB_STATUS_CHANGE, job_id, current_user, http_request,
        details={"new_status": JobStatus.QC_FEEDBACK.value, "reason": request.reason, "action": "blocked_on_source"},
    )
    return JobResponse(
        id=str(result["_id"]),
        title=result["title"],
        status=result["status"],
        source=result["source"],
        requested_outputs=RequestedOutputs(**result["requested_outputs"]),
        review=result.get("review", {"notes": "", "history": []}),
        outputs=result.get("outputs"),
        created_at=result["created_at"].isoformat(),
        updated_at=result["updated_at"].isoformat(),
    )


@router.post("/{job_id}/actions/promote_to_qc", response_model=JobResponse)
async def promote_to_qc(
    job_id: str,
    request: PromoteToQCRequest,
    http_request: Request,
    current_user: User = Depends(require_roles(UserRole.PRODUCTION, UserRole.ADMIN)),
    ctx: MembershipContext = Depends(get_membership_context),
    db: AsyncIOMotorDatabase = Depends(get_database),
):
    """Production/Admin manually promotes a job to PENDING_QC, bypassing AI processing (PR-10).
    Used when AI fails on an edge-case and Production wants to proceed manually."""
    await get_job_or_403(job_id, ctx, db)  # org check

    promotable_statuses = [
        JobStatus.AI_PROCESSING.value,
        JobStatus.INGESTING.value,
        JobStatus.PROCESSING_FAILED.value,
        JobStatus.TTS_FAILED.value,
        JobStatus.RENDER_FAILED.value,
    ]
    now = datetime.utcnow()
    result = await db.jobs.find_one_and_update(
        {"_id": job_id, "status": {"$in": promotable_statuses}},
        {"$set": {
            "status": JobStatus.PENDING_QC.value,
            "updated_at": now,
        },
        "$push": {
            "review.history": {
                "at": now,
                "status": JobStatus.PENDING_QC.value,
                "by": str(current_user.id),
                "notes": request.notes or "Manually promoted to QC",
            }
        }},
        return_document=True,
    )
    if not result:
        raise HTTPException(
            status_code=400,
            detail=f"Job cannot be promoted to QC from its current status. Must be one of: {', '.join(promotable_statuses)}",
        )

    await log_job_action(
        AuditAction.JOB_STATUS_CHANGE, job_id, current_user, http_request,
        details={"new_status": JobStatus.PENDING_QC.value, "notes": request.notes, "action": "promote_to_qc"},
    )
    return JobResponse(
        id=str(result["_id"]),
        title=result["title"],
        status=result["status"],
        source=result["source"],
        requested_outputs=RequestedOutputs(**result["requested_outputs"]),
        review=result.get("review", {"notes": "", "history": []}),
        outputs=result.get("outputs"),
        created_at=result["created_at"].isoformat(),
        updated_at=result["updated_at"].isoformat(),
    )


@router.get("/{job_id}/downloads", response_model=JobDownloadsResponse)
async def get_job_downloads(
    job_id: str,
    current_user: User = Depends(get_current_user),
    db: AsyncIOMotorDatabase = Depends(get_database),
):
    from ...services.gcs import get_signed_download_url

    job_doc = await db.jobs.find_one({"_id": job_id})
    if not job_doc:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="Job not found"
        )

    # Check access permissions
    if current_user.role in (UserRole.CLIENT, UserRole.PROJECT_MANAGER):
        _ap = await get_accessible_project_ids(current_user, db)
        _jpid = job_doc.get("project_id")
        _own = job_doc.get("client_id") == str(current_user.id) and not _jpid
        if _ap is not None and not _own and not (_jpid and _jpid in (_ap or [])):
            raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")

    # Block only the statuses where outputs can't possibly exist yet
    _no_output_statuses = {
        JobStatus.CREATED.value,
        JobStatus.INGESTING.value,
        JobStatus.AI_PROCESSING.value,
    }
    if job_doc["status"] in _no_output_statuses:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Job is still being processed"
        )

    # No outputs yet — return empty instead of 400 (e.g. failed jobs with partial state)
    if not job_doc.get("outputs"):
        return JobDownloadsResponse(downloads={})

    # Generate signed URLs for all outputs
    downloads = {}

    # Add source video URL
    if job_doc.get("source", {}).get("gcs_uri"):
        source_blob_path = job_doc["source"]["gcs_uri"].replace(f"gs://{settings.gcs_bucket}/", "")
        try:
            source_signed_url = await get_signed_download_url(source_blob_path, 24)
            downloads["source_video"] = source_signed_url
        except Exception as e:
            logger.warning(f"Failed to generate signed URL for source video: {e}")

    outputs = job_doc.get("outputs", {})

    for language, lang_output in outputs.items():
        if not isinstance(lang_output, dict):
            continue

        lang_downloads = {}

        # Captions VTT
        if "captions_vtt_gcs" in lang_output:
            blob_path = lang_output["captions_vtt_gcs"].replace(f"gs://{settings.gcs_bucket}/", "")
            try:
                signed_url = await get_signed_download_url(blob_path, 24)
                lang_downloads["captions_vtt"] = signed_url
            except Exception as e:
                logger.warning(f"Failed to generate signed URL for captions {language}: {e}")

        # Audio Description VTT
        if "ad_vtt_gcs" in lang_output:
            blob_path = lang_output["ad_vtt_gcs"].replace(f"gs://{settings.gcs_bucket}/", "")
            try:
                signed_url = await get_signed_download_url(blob_path, 24)
                lang_downloads["audio_description_vtt"] = signed_url
            except Exception as e:
                logger.warning(f"Failed to generate signed URL for AD VTT {language}: {e}")

        # Audio Description MP3
        if "ad_mp3_gcs" in lang_output:
            blob_path = lang_output["ad_mp3_gcs"].replace(f"gs://{settings.gcs_bucket}/", "")
            try:
                signed_url = await get_signed_download_url(blob_path, 24)
                lang_downloads["audio_description_mp3"] = signed_url
            except Exception as e:
                logger.warning(f"Failed to generate signed URL for AD MP3 {language}: {e}")

        # Accessible Video MP4
        if "accessible_video_gcs" in lang_output:
            blob_path = lang_output["accessible_video_gcs"].replace(f"gs://{settings.gcs_bucket}/", "")
            try:
                signed_url = await get_signed_download_url(blob_path, 24)
                lang_downloads["accessible_video_mp4"] = signed_url
            except Exception as e:
                logger.warning(f"Failed to generate signed URL for accessible video {language}: {e}")

        # SDH Captions VTT
        if "sdh_captions_vtt_gcs" in lang_output:
            blob_path = lang_output["sdh_captions_vtt_gcs"].replace(f"gs://{settings.gcs_bucket}/", "")
            try:
                signed_url = await get_signed_download_url(blob_path, 24)
                lang_downloads["sdh_captions_vtt"] = signed_url
            except Exception as e:
                logger.warning(f"Failed to generate signed URL for SDH captions {language}: {e}")

        # Re-timed Captions VTT (for pause-insert accessible videos)
        if "retimed_captions_vtt_gcs" in lang_output:
            blob_path = lang_output["retimed_captions_vtt_gcs"].replace(f"gs://{settings.gcs_bucket}/", "")
            try:
                signed_url = await get_signed_download_url(blob_path, 24)
                lang_downloads["accessible_captions_vtt"] = signed_url
            except Exception as e:
                logger.warning(f"Failed to generate signed URL for retimed captions {language}: {e}")

        # Descriptive Transcript TXT (WCAG 2.1 1.2.1)
        if "descriptive_transcript_gcs" in lang_output:
            blob_path = lang_output["descriptive_transcript_gcs"].replace(f"gs://{settings.gcs_bucket}/", "")
            try:
                signed_url = await get_signed_download_url(blob_path, 24)
                lang_downloads["descriptive_transcript"] = signed_url
            except Exception as e:
                logger.warning(f"Failed to generate signed URL for descriptive transcript {language}: {e}")

        if lang_downloads:
            downloads[language] = lang_downloads

    return JobDownloadsResponse(downloads=downloads)


@router.get("/{job_id}/vtt", response_model=VttContentResponse)
async def get_job_vtt_content(
    job_id: str,
    language: str | None = None,  # If None, defaults to source language
    current_user: User = Depends(get_current_user),
    db: AsyncIOMotorDatabase = Depends(get_database),
):
    """Get VTT content for editing. If language is not specified, returns source language content."""
    job_doc = await db.jobs.find_one({"_id": job_id})
    if not job_doc:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="Job not found"
        )

    # Check access permissions
    if current_user.role in (UserRole.CLIENT, UserRole.PROJECT_MANAGER):
        _ap = await get_accessible_project_ids(current_user, db)
        _jpid = job_doc.get("project_id")
        _own = job_doc.get("client_id") == str(current_user.id) and not _jpid
        if _ap is not None and not _own and not (_jpid and _jpid in (_ap or [])):
            raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")

    # Default to source language if not specified
    target_language = language or job_doc["source"].get("language", "en")

    outputs = job_doc.get("outputs", {})
    lang_output = outputs.get(target_language, {})

    response = VttContentResponse()

    # Fetch captions VTT if available
    if "captions_vtt_gcs" in lang_output:
        blob_path = lang_output["captions_vtt_gcs"].replace(f"gs://{settings.gcs_bucket}/", "")
        try:
            blob = gcs_service.bucket.blob(blob_path)
            response.captions_vtt = blob.download_as_text()
        except Exception as e:
            logger.warning(f"Failed to fetch captions VTT: {e}")

    # Fetch audio description VTT if available
    if "ad_vtt_gcs" in lang_output:
        blob_path = lang_output["ad_vtt_gcs"].replace(f"gs://{settings.gcs_bucket}/", "")
        try:
            blob = gcs_service.bucket.blob(blob_path)
            response.audio_description_vtt = blob.download_as_text()
        except Exception as e:
            logger.warning(f"Failed to fetch AD VTT: {e}")

    # Fetch retimed captions VTT if available (for accessible videos)
    if "retimed_captions_vtt_gcs" in lang_output:
        blob_path = lang_output["retimed_captions_vtt_gcs"].replace(f"gs://{settings.gcs_bucket}/", "")
        try:
            blob = gcs_service.bucket.blob(blob_path)
            response.retimed_captions_vtt = blob.download_as_text()
        except Exception as e:
            logger.warning(f"Failed to fetch retimed captions VTT: {e}")

    # Compute ETag for optimistic locking — SHA1 of combined VTT content
    combined = (response.captions_vtt or "") + "|" + (response.audio_description_vtt or "")
    response.etag = hashlib.sha1(combined.encode()).hexdigest()

    # L-3 WS: broadcast editing-started indicator to other viewers of this job
    if current_user.role in (UserRole.LINGUIST, UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.ADMIN):
        try:
            from ...services.websocket import connection_manager
            await connection_manager.broadcast_to_job(job_id, {
                "type": "language_editing_started",
                "job_id": job_id,
                "data": {
                    "lang": target_language,
                    "user_name": current_user.full_name or current_user.email,
                    "user_id": str(current_user.id),
                },
            })
        except Exception:
            pass

    return response


@router.patch("/{job_id}/vtt", response_model=JobResponse)
async def update_job_vtt_content(
    job_id: str,
    request: VttUpdateRequest,
    http_request: Request = None,
    current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)),
    ctx: MembershipContext = Depends(get_membership_context),
    db: AsyncIOMotorDatabase = Depends(get_database),
):
    """Update VTT content for a job. If language is not specified, updates source language content."""
    job_doc = await get_job_or_403(job_id, ctx, db)

    # Only allow editing during QC phase
    if job_doc["status"] not in [JobStatus.PENDING_QC.value, JobStatus.QC_FEEDBACK.value]:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="VTT content can only be edited during QC phase"
        )

    # Default to source language if not specified
    target_language = request.language or job_doc["source"].get("language", "en")

    # Linguists can only edit their assigned language
    if current_user.role == UserRole.LINGUIST:
        try:
            lqc._assert_can_act(job_doc, target_language, current_user)
        except HTTPException:
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail=f"You are not assigned to language '{target_language}'"
            ) from None

    outputs = job_doc.get("outputs", {})
    lang_output = outputs.get(target_language, {})

    # Optimistic locking — check If-Match before any mutation
    if request.if_match:
        current_caps = ""
        current_ad = ""
        if "captions_vtt_gcs" in lang_output:
            try:
                blob_path = lang_output["captions_vtt_gcs"].replace(f"gs://{settings.gcs_bucket}/", "")
                blob = gcs_service.bucket.blob(blob_path)
                current_caps = blob.download_as_text()
            except Exception:
                pass
        if "ad_vtt_gcs" in lang_output:
            try:
                blob_path = lang_output["ad_vtt_gcs"].replace(f"gs://{settings.gcs_bucket}/", "")
                blob = gcs_service.bucket.blob(blob_path)
                current_ad = blob.download_as_text()
            except Exception:
                pass
        current_etag = hashlib.sha1((current_caps + "|" + current_ad).encode()).hexdigest()
        if current_etag != request.if_match:
            raise HTTPException(
                status_code=status.HTTP_409_CONFLICT,
                detail="Conflict: another user has modified this VTT since you last loaded it. Reload to see their changes.",
            )

    # Validate and update captions VTT
    if request.captions_vtt:  # treat empty string same as None — nothing to update
        # Auto-fix minor overlaps before validation (mirrors AI-generation pipeline)
        captions_vtt_fixed = VTTEditor.fix_overlapping_cues(request.captions_vtt)
        is_valid, errors = VTTEditor.validate_vtt(captions_vtt_fixed)
        if not is_valid:
            raise HTTPException(
                status_code=status.HTTP_400_BAD_REQUEST,
                detail=f"Invalid captions VTT: {'; '.join(errors)}"
            )

        # Snapshot before overwriting live file
        await vtt_versioning.create_version(
            db, job_id, target_language, "captions", captions_vtt_fixed, current_user,
            note=request.note,
        )

        # Upload updated VTT
        new_captions_uri = await upload_vtt_to_gcs(
            captions_vtt_fixed,
            f"{job_id}/{target_language}/captions.vtt"
        )
        lang_output["captions_vtt_gcs"] = new_captions_uri

    # Validate and update audio description VTT
    if request.audio_description_vtt:  # treat empty string same as None — nothing to update
        ad_vtt_fixed = VTTEditor.fix_overlapping_cues(request.audio_description_vtt)
        is_valid, errors = VTTEditor.validate_vtt(ad_vtt_fixed)
        if not is_valid:
            raise HTTPException(
                status_code=status.HTTP_400_BAD_REQUEST,
                detail=f"Invalid audio description VTT: {'; '.join(errors)}"
            )

        # Diff old vs new VTT to auto-queue TTS for changed/added cues
        from ...tasks.tts_synthesis import parse_ad_cues as _parse_ad_cues_for_diff

        old_cues: list[str] = []
        old_ad_vtt_gcs = lang_output.get("ad_vtt_gcs")
        if old_ad_vtt_gcs:
            try:
                import asyncio as _asyncio
                old_blob_path = old_ad_vtt_gcs.replace(f"gs://{settings.gcs_bucket}/", "")
                old_blob = gcs_service.bucket.blob(old_blob_path)
                loop = _asyncio.get_event_loop()
                old_vtt_text = await loop.run_in_executor(
                    gcs_service.executor, old_blob.download_as_text
                )
                old_cues = [c["text"] for c in _parse_ad_cues_for_diff(old_vtt_text)]
            except Exception as _e:
                logger.warning(f"Could not read old AD VTT for diff: {_e}")

        new_cues = [c["text"] for c in _parse_ad_cues_for_diff(ad_vtt_fixed)]

        # Queue TTS regeneration for any cue whose text changed or that is newly added
        edit_state = lang_output.get("accessible_video_edit_state") or {}
        tts_queue = edit_state.get("tts_regeneration_queue") or []
        queued_indices = {q["cue_index"] for q in tts_queue if q.get("status") == "pending"}

        for idx, new_text in enumerate(new_cues):
            old_text = old_cues[idx] if idx < len(old_cues) else None
            if old_text != new_text and idx not in queued_indices:
                tts_queue.append({
                    "cue_index": idx,
                    "requested_at": datetime.utcnow().isoformat(),
                    "new_text": new_text,
                    "status": "pending",
                    "error_message": None
                })
                queued_indices.add(idx)

        # Update manifest indices for inserted/deleted cues so re-render uses correct MP3s.
        # Any cue that exists in both old and new with the same text keeps its old gcs_uri.
        current_manifest: list[dict] = lang_output.get("ad_cue_manifest") or []
        if current_manifest and len(new_cues) != len(old_cues):
            # Build a text -> manifest entry lookup from old manifest
            old_text_to_entry = {
                e.get("text", "")[:80]: e
                for e in current_manifest
            }
            new_manifest = []
            for idx, new_text in enumerate(new_cues):
                snippet = new_text[:80]
                if snippet in old_text_to_entry:
                    entry = dict(old_text_to_entry[snippet])
                    entry["cue_index"] = idx  # re-index
                    new_manifest.append(entry)
                # New/changed cues will be filled in after TTS regen; skip for now
            lang_output["ad_cue_manifest"] = new_manifest

        edit_state["tts_regeneration_queue"] = tts_queue
        # Clear stale pause points — old positions are invalid with new VTT timings.
        # Re-render will fall back to VTT cue start_time for placement.
        edit_state["pause_points"] = []
        edit_state["video_segments"] = []
        lang_output["accessible_video_edit_state"] = edit_state

        # Snapshot before overwriting live file
        await vtt_versioning.create_version(
            db, job_id, target_language, "ad", ad_vtt_fixed, current_user,
            note=request.note,
        )

        # Upload updated VTT
        new_ad_uri = await upload_vtt_to_gcs(
            ad_vtt_fixed,
            f"{job_id}/{target_language}/ad.vtt"
        )
        lang_output["ad_vtt_gcs"] = new_ad_uri

    # Regenerate descriptive transcript whenever CC or AD was updated.
    # The transcript merges both streams; skipping this left users downloading stale text.
    if request.captions_vtt or request.audio_description_vtt:
        try:
            from ...services.descriptive_transcript import (
                generate_descriptive_transcript as _gen_transcript,
            )

            captions_text = captions_vtt_fixed if request.captions_vtt else None
            if not captions_text:
                cc_gcs = lang_output.get("captions_vtt_gcs")
                if cc_gcs:
                    _cc_blob = gcs_service.bucket.blob(
                        cc_gcs.replace(f"gs://{settings.gcs_bucket}/", "")
                    )
                    captions_text = await asyncio.get_event_loop().run_in_executor(
                        gcs_service.executor, _cc_blob.download_as_text
                    )

            ad_text = ad_vtt_fixed if request.audio_description_vtt else None
            if not ad_text:
                ad_gcs = lang_output.get("ad_vtt_gcs")
                if ad_gcs:
                    _ad_blob = gcs_service.bucket.blob(
                        ad_gcs.replace(f"gs://{settings.gcs_bucket}/", "")
                    )
                    ad_text = await asyncio.get_event_loop().run_in_executor(
                        gcs_service.executor, _ad_blob.download_as_text
                    )

            transcript_text = _gen_transcript(captions_text or "", ad_text or "")
            if transcript_text:
                transcript_uri = await upload_vtt_to_gcs(
                    transcript_text,
                    f"{job_id}/{target_language}/descriptive_transcript.txt"
                )
                lang_output["descriptive_transcript_gcs"] = transcript_uri
                logger.info(f"Regenerated descriptive transcript for job {job_id} lang={target_language}")
        except Exception as _tr_err:
            logger.warning(f"Failed to regenerate descriptive transcript for job {job_id}: {_tr_err}")

    # Update job with new VTT content
    outputs[target_language] = lang_output

    result = await db.jobs.find_one_and_update(
        {"_id": job_id},
        {
            "$set": {
                "outputs": outputs,
                "updated_at": datetime.utcnow()
            },
            "$push": {
                "review.history": {
                    "at": datetime.utcnow(),
                    "status": "vtt_updated",
                    "by": str(current_user.id),
                    "notes": f"Updated VTT content for {target_language}"
                }
            }
        },
        return_document=True
    )

    await audit_logger.log_action(
        action=AuditAction.VTT_EDIT,
        description=f"VTT content updated for job {job_id} lang={target_language}",
        user=current_user,
        request=http_request,
        resource_type="job",
        resource_id=job_id,
        details={
            "lang": target_language,
            "captions_updated": request.captions_vtt is not None,
            "ad_updated": request.audio_description_vtt is not None,
            "retranslate": request.retranslate_languages,
        },
    )

    # When source language VTT is edited, reset downstream target QC states.
    # Approvals on translated content are stale once the source changes.
    source_language = job_doc["source"].get("language", "en")
    if target_language == source_language:
        lang_qc = job_doc.get("language_qc") or {}
        qc_reset: dict = {}
        stale_statuses = {
            LanguageQCStatus.APPROVED.value,
            LanguageQCStatus.PENDING_REVIEW.value,
            LanguageQCStatus.IN_REVIEW.value,
        }
        for lang, state_raw in lang_qc.items():
            if lang == source_language:
                continue
            state_dict = state_raw if isinstance(state_raw, dict) else {}
            if state_dict.get("status") in stale_statuses:
                qc_reset[f"language_qc.{lang}.status"] = LanguageQCStatus.PENDING.value
                qc_reset[f"language_qc.{lang}.approved_by"] = None
                qc_reset[f"language_qc.{lang}.approved_at"] = None
        if qc_reset:
            qc_reset["updated_at"] = datetime.utcnow()
            await db.jobs.update_one({"_id": job_id}, {"$set": qc_reset})
            result = await db.jobs.find_one({"_id": job_id})
            logger.info(
                f"Source VTT edit on job {job_id}: reset QC for {list(qc_reset.keys())}"
            )

    # Trigger retranslation of all target languages if requested
    if request.retranslate_languages and target_language == source_language:
        await _trigger_retranslation(job_id, job_doc, db, current_user)

    return JobResponse(
        id=str(result["_id"]),
        title=result["title"],
        status=result["status"],
        source=result["source"],
        requested_outputs=RequestedOutputs(**result["requested_outputs"]),
        review=result.get("review", {"notes": "", "history": []}),
        outputs=result.get("outputs"),
        created_at=result["created_at"].isoformat(),
        updated_at=result["updated_at"].isoformat()
    )


async def _trigger_retranslation(job_id: str, job_doc: dict, db, current_user) -> None:
    """Re-translate all target languages from the updated source VTT and return job to PENDING_QC."""
    source_language = job_doc["source"].get("language", "en")
    target_languages = [
        lang for lang in job_doc.get("requested_outputs", {}).get("languages", [])
        if lang != source_language
    ]
    if not target_languages:
        return

    await db.jobs.update_one(
        {"_id": job_id},
        {
            "$set": {
                "status": JobStatus.TRANSLATING.value,
                "retranslation_triggered_at": datetime.utcnow(),
                "updated_at": datetime.utcnow(),
            },
            "$push": {
                "review.history": {
                    "at": datetime.utcnow(),
                    "status": JobStatus.TRANSLATING.value,
                    "by": str(current_user.id),
                    "notes": "Re-translation triggered from updated source VTT"
                }
            }
        }
    )

    await audit_logger.log_action(
        action=AuditAction.VTT_RETRANSLATE,
        description=f"Re-translation triggered for job {job_id} ({len(target_languages)} languages)",
        user=current_user,
        resource_type="job",
        resource_id=job_id,
        details={"target_languages": target_languages},
    )

    from ...tasks.translate_and_synthesize import translate_and_synthesize_task
    translate_and_synthesize_task.delay(job_id, languages=target_languages, retranslate=True)


@router.post("/{job_id}/retranslate-language", response_model=JobResponse)
async def retranslate_language(
    job_id: str,
    request: RetranslateLanguageRequest,
    http_request: Request = None,
    current_user: User = Depends(require_roles(UserRole.PRODUCTION, UserRole.ADMIN)),
    ctx: MembershipContext = Depends(get_membership_context),
    db: AsyncIOMotorDatabase = Depends(get_database),
):
    """Re-translate a single target language from the approved EN master VTT.
    Used by PM/admin to fix drift on existing jobs (e.g. legacy video_native translations).
    EN must be approved before retranslation can be triggered.
    """
    job_doc = await get_job_or_403(job_id, ctx, db)
    source_language = job_doc["source"].get("language", "en")
    lang = request.language

    if lang == source_language:
        raise HTTPException(status_code=400, detail="Cannot retranslate the source language")

    requested_langs = job_doc.get("requested_outputs", {}).get("languages", [])
    if lang not in requested_langs:
        raise HTTPException(status_code=400, detail=f"Language '{lang}' is not in the job's requested languages")

    # Enforce EN-first gate
    source_qc = (job_doc.get("language_qc") or {}).get(source_language, {})
    if source_qc.get("status") != LanguageQCStatus.APPROVED.value:
        raise HTTPException(
            status_code=409,
            detail=f"Source language '{source_language}' must be approved before retranslation"
        )

    now = datetime.utcnow()
    await db.jobs.update_one(
        {"_id": job_id},
        {
            "$set": {
                "status": JobStatus.TRANSLATING.value,
                f"language_qc.{lang}.status": LanguageQCStatus.PENDING.value,
                f"language_qc.{lang}.approved_by": None,
                f"language_qc.{lang}.approved_at": None,
                "updated_at": now,
            },
            "$push": {
                "review.history": {
                    "at": now,
                    "status": JobStatus.TRANSLATING.value,
                    "by": str(current_user.id),
                    "notes": f"Retranslate '{lang}' from EN master. Reason: {request.reason or 'not provided'}",
                }
            },
        },
    )

    await log_job_action(
        AuditAction.VTT_RETRANSLATE, job_id, current_user, http_request,
        details={"language": lang, "reason": request.reason},
    )

    from ...tasks.translate_and_synthesize import translate_and_synthesize_task
    translate_and_synthesize_task.delay(job_id, languages=[lang], retranslate=True)

    result = await db.jobs.find_one({"_id": job_id})
    return JobResponse(
        id=str(result["_id"]),
        title=result["title"],
        status=result["status"],
        source=result["source"],
        requested_outputs=RequestedOutputs(**result["requested_outputs"]),
        review=result.get("review", {"notes": "", "history": []}),
        outputs=result.get("outputs"),
        created_at=result["created_at"].isoformat(),
        updated_at=result["updated_at"].isoformat(),
    )


@router.post("/{job_id}/vtt/adjust-timing", response_model=JobResponse)
async def adjust_vtt_timing(
    job_id: str,
    request: VttTimingAdjustRequest,
    current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)),
    ctx: MembershipContext = Depends(get_membership_context),
    db: AsyncIOMotorDatabase = Depends(get_database),
):
    """Adjust timing of VTT content by a specified offset"""
    job_doc = await get_job_or_403(job_id, ctx, db)

    # Only allow timing adjustment during QC phase
    if job_doc["status"] not in [JobStatus.PENDING_QC.value, JobStatus.QC_FEEDBACK.value]:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="VTT timing can only be adjusted during QC phase"
        )

    # Get current VTT content from GCS
    lang_key = request.language
    outputs = job_doc.get("outputs", {}).get(lang_key, {})

    if not outputs:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"No VTT content found for language: {request.language}"
        )

    update_operations = {}
    adjusted_content = {}

    # Adjust captions VTT if requested and exists
    if request.adjust_captions and "captions_vtt_gcs" in outputs:
        try:
            # Download current captions VTT
            captions_blob = gcs_service.bucket.blob(
                outputs["captions_vtt_gcs"].replace(f"gs://{settings.gcs_bucket}/", "")
            )
            current_captions_vtt = captions_blob.download_as_text()

            # Apply timing adjustment
            adjusted_captions_vtt = VTTEditor.adjust_timing_offset(
                current_captions_vtt,
                request.offset_seconds
            )

            # Upload adjusted content back to GCS
            new_captions_gcs_uri = await upload_vtt_to_gcs(
                adjusted_captions_vtt,
                f"{job_id}/{request.language}/captions.vtt"
            )

            update_operations[f"outputs.{lang_key}.captions_vtt_gcs"] = new_captions_gcs_uri
            adjusted_content["captions"] = True

        except Exception as e:
            logger.error(f"Failed to adjust captions timing: {e}")
            raise HTTPException(
                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
                detail="Failed to adjust captions timing"
            ) from None

    # Adjust audio description VTT if requested and exists
    if request.adjust_audio_description and "ad_vtt_gcs" in outputs:
        try:
            # Download current AD VTT
            ad_blob = gcs_service.bucket.blob(
                outputs["ad_vtt_gcs"].replace(f"gs://{settings.gcs_bucket}/", "")
            )
            current_ad_vtt = ad_blob.download_as_text()

            # Apply timing adjustment
            adjusted_ad_vtt = VTTEditor.adjust_timing_offset(
                current_ad_vtt,
                request.offset_seconds
            )

            # Upload adjusted content back to GCS
            new_ad_gcs_uri = await upload_vtt_to_gcs(
                adjusted_ad_vtt,
                f"{job_id}/{request.language}/ad.vtt"
            )

            update_operations[f"outputs.{lang_key}.ad_vtt_gcs"] = new_ad_gcs_uri
            adjusted_content["audio_description"] = True

        except Exception as e:
            logger.error(f"Failed to adjust audio description timing: {e}")
            raise HTTPException(
                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
                detail="Failed to adjust audio description timing"
            ) from None

    if not update_operations:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="No VTT content to adjust"
        )

    # Update job document
    result = await db.jobs.find_one_and_update(
        {"_id": job_id},
        {
            "$set": {
                **update_operations,
                "updated_at": datetime.utcnow()
            },
            "$push": {
                "review.history": {
                    "at": datetime.utcnow(),
                    "status": "timing_adjusted",
                    "by": str(current_user.id),
                    "notes": f"Adjusted timing by {request.offset_seconds:+.1f}s for {', '.join(adjusted_content.keys())}"
                }
            }
        },
        return_document=True
    )

    return JobResponse(
        id=str(result["_id"]),
        title=result["title"],
        status=result["status"],
        source=result["source"],
        requested_outputs=RequestedOutputs(**result["requested_outputs"]),
        review=result.get("review", {"notes": "", "history": []}),
        outputs=result.get("outputs"),
        created_at=result["created_at"].isoformat(),
        updated_at=result["updated_at"].isoformat()
    )


@router.post("/{job_id}/clone", response_model=JobResponse, status_code=status.HTTP_201_CREATED)
async def clone_job(
    job_id: str,
    current_user: User = Depends(require_roles(UserRole.PROJECT_MANAGER, UserRole.PRODUCTION, UserRole.ADMIN)),
    ctx: MembershipContext = Depends(get_membership_context),
    db: AsyncIOMotorDatabase = Depends(get_database),
):
    """Clone a job config (no file) — creates a new job in 'created' state with same settings."""
    job_doc = await get_job_or_403(job_id, ctx, db)

    new_id = str(ObjectId())
    now = datetime.utcnow()
    clone = {
        "_id": new_id,
        "client_id": job_doc.get("client_id"),
        "title": f"{job_doc.get('title', 'Untitled')} (Copy)",
        "source": job_doc.get("source", {}),
        "requested_outputs": job_doc.get("requested_outputs", {}),
        "status": JobStatus.CREATED.value,
        "review": {"notes": "", "history": [{"at": now, "status": JobStatus.CREATED.value, "by": str(current_user.id)}]},
        "brand_context": job_doc.get("brand_context"),
        "project_id": job_doc.get("project_id"),
        "organization_id": job_doc.get("organization_id"),
        "deadline": job_doc.get("deadline"),
        "language_qc": {},
        "qc_assignments": [],
        "created_at": now,
        "updated_at": now,
    }
    await db.jobs.insert_one(clone)

    result = await db.jobs.find_one({"_id": new_id})
    return JobResponse(
        id=str(result["_id"]),
        client_id=result["client_id"],
        title=result["title"],
        source=result["source"],
        requested_outputs=result["requested_outputs"],
        status=result["status"],
        review=result.get("review", {}),
        created_at=result["created_at"].isoformat(),
        updated_at=result["updated_at"].isoformat()
    )


@router.delete("/{job_id}", response_model=JobDeleteResponse)
async def delete_job(
    job_id: str,
    http_request: Request = None,
    current_user: User = Depends(get_current_user),
    ctx: MembershipContext = Depends(get_membership_context),
    db: AsyncIOMotorDatabase = Depends(get_database),
):
    """Delete a job and all associated assets"""
    job_doc = await get_job_or_403(job_id, ctx, db)

    # Check permissions: clients and PMs can only delete accessible jobs
    if current_user.role in (UserRole.CLIENT, UserRole.PROJECT_MANAGER):
        _ap = await get_accessible_project_ids(current_user, db)
        _jpid = job_doc.get("project_id")
        _own = job_doc.get("client_id") == str(current_user.id) and not _jpid
        if _ap is not None and not _own and not (_jpid and _jpid in (_ap or [])):
            raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")

    logger.info(f"Deleting job {job_id} requested by {current_user.email}")

    try:
        # 1. Cancel running Celery task if exists
        task_id = job_doc.get("task_id")
        if task_id:
            try:
                celery_app.control.revoke(task_id, terminate=True)
                logger.info(f"Cancelled Celery task {task_id} for job {job_id}")
            except Exception as e:
                logger.warning(f"Could not cancel task {task_id}: {e}")

        # 2. Delete GCS assets
        await _delete_job_gcs_assets(job_id, job_doc)

        # 3. Delete from database
        result = await db.jobs.delete_one({"_id": job_id})

        if result.deleted_count == 0:
            raise HTTPException(
                status_code=status.HTTP_404_NOT_FOUND,
                detail="Job not found"
            )

        logger.info(f"Successfully deleted job {job_id}")
        await log_job_action(
            AuditAction.JOB_DELETE, job_id, current_user, http_request,
            details={"title": job_doc.get("title")},
        )
        return {"message": f"Job {job_id} deleted successfully"}

    except Exception as e:
        logger.error(f"Failed to delete job {job_id}: {e}")
        raise HTTPException(
            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
            detail=f"Failed to delete job: {str(e)}"
        ) from None


async def _delete_job_gcs_assets(job_id: str, job_doc: dict):
    """Delete all GCS assets for a job using prefix-based cleanup.

    All job files are stored under the {job_id}/ prefix in GCS, including:
    - source.mp4 (original video)
    - {lang}/captions.vtt (translated captions)
    - {lang}/ad.vtt (audio description cues)
    - {lang}/ad.mp3 (full AD audio)
    - {lang}/ad_cues/cue_*.mp3 (per-cue TTS segments)
    - {lang}/accessible_video.mp4 (rendered video)
    - {lang}/accessible_captions.vtt (re-timed captions for pause-insert)
    """
    from google.api_core.exceptions import NotFound

    try:
        deleted_count = 0
        not_found_count = 0

        # Convert lazy iterator to list upfront to avoid issues with
        # concurrent deletions causing stale generation numbers
        blobs = list(gcs_service.bucket.list_blobs(prefix=f"{job_id}/"))

        for blob in blobs:
            try:
                # Delete by name to avoid generation mismatch errors when
                # another process already deleted the blob
                gcs_service.bucket.delete_blob(blob.name)
                deleted_count += 1
                logger.debug(f"Deleted GCS file: {blob.name}")
            except NotFound:
                # Blob was already deleted (likely by concurrent request)
                not_found_count += 1
                logger.debug(f"Blob already deleted: {blob.name}")
            except Exception as e:
                logger.warning(f"Could not delete {blob.name}: {e}")

        if not_found_count > 0:
            logger.info(f"Deleted {deleted_count} GCS files for job {job_id} ({not_found_count} already deleted)")
        else:
            logger.info(f"Deleted {deleted_count} GCS files for job {job_id}")
    except Exception as e:
        logger.error(f"Error deleting GCS assets for job {job_id}: {e}")
        # Don't re-raise - GCS cleanup failures shouldn't block job deletion
        # The job record will be deleted and files will eventually be cleaned up


@router.post("/{job_id}/actions/retry_tts", response_model=JobResponse)
async def retry_tts(
    job_id: str,
    current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)),
    ctx: MembershipContext = Depends(get_membership_context),
    db: AsyncIOMotorDatabase = Depends(get_database),
):
    """Retry TTS generation for a job that failed during TTS synthesis.

    This is a lightweight retry that:
    1. Resets the job status from tts_failed back to the approved state
    2. Clears the error field
    3. Re-triggers the translation/TTS pipeline

    Use this when TTS failed due to transient issues or after editing the AD VTT
    to fix content that triggered safety filters.
    """
    job_doc = await get_job_or_403(job_id, ctx, db)

    # Only allow retry from tts_failed status
    if job_doc["status"] != JobStatus.TTS_FAILED.value:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=f"Job is not in TTS failed status (current: {job_doc['status']})"
        )

    # Determine the appropriate approved status based on source language
    source_language = job_doc["source"].get("language", "en")
    approved_status = JobStatus.APPROVED_ENGLISH if source_language == "en" else JobStatus.APPROVED_SOURCE

    # Reset job status and clear error
    result = await db.jobs.find_one_and_update(
        {"_id": job_id, "status": JobStatus.TTS_FAILED.value},
        {
            "$set": {
                "status": approved_status.value,
                "error": None,
                "updated_at": datetime.utcnow()
            },
            "$push": {
                "review.history": {
                    "at": datetime.utcnow(),
                    "status": "retry_tts",
                    "by": str(current_user.id),
                    "notes": "TTS retry requested"
                }
            }
        },
        return_document=True
    )

    if not result:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="Job not found or status changed"
        )

    # Broadcast status update
    try:
        await connection_manager.broadcast_job_status_update(
            job_id=job_id,
            status=approved_status.value,
            job_title=result.get("title"),
            message=f"{result.get('title', 'Job')} TTS retry initiated"
        )
    except Exception as e:
        logger.warning(f"Failed to broadcast status update for TTS retry {job_id}: {e}")

    # Re-trigger the translation/TTS pipeline
    try:
        await _cr_dispatch("translate", job_id)
        logger.info(f"Triggered TTS retry for job {job_id} by {current_user.email}")
    except Exception as e:
        logger.error(f"Failed to trigger TTS retry task for job {job_id}: {e}")
        raise HTTPException(
            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
            detail="Failed to start TTS retry"
        ) from None

    return JobResponse(
        id=str(result["_id"]),
        title=result["title"],
        status=result["status"],
        source=result["source"],
        requested_outputs=RequestedOutputs(**result["requested_outputs"]),
        review=result.get("review", {"notes": "", "history": []}),
        outputs=result.get("outputs"),
        created_at=result["created_at"].isoformat(),
        updated_at=result["updated_at"].isoformat()
    )


@router.post("/{job_id}/retry", response_model=JobResponse)
async def retry_job(
    job_id: str,
    from_step: str | None = None,
    ctx: MembershipContext = Depends(get_membership_context),
    db: AsyncIOMotorDatabase = Depends(get_database),
):
    """Generic retry endpoint for any failed job step.

    Reads job.failure.step (or from_step override) to dispatch the correct pipeline task.
    Allowed statuses: processing_failed, tts_failed, render_failed.
    """
    job_doc = await get_job_or_403(job_id, ctx, db)
    current_user = ctx.user

    allowed_statuses = {
        JobStatus.PROCESSING_FAILED.value,
        JobStatus.TTS_FAILED.value,
        JobStatus.RENDER_FAILED.value,
    }
    if job_doc["status"] not in allowed_statuses:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=f"Job is not in a failed status (current: {job_doc['status']})",
        )

    failure = job_doc.get("failure") or {}
    step = from_step or failure.get("step")
    if not step:
        # Fall back by status for legacy failures without structured failure field
        if job_doc["status"] == JobStatus.TTS_FAILED.value:
            step = "tts"
        elif job_doc["status"] == JobStatus.RENDER_FAILED.value:
            step = "render"
        else:
            raise HTTPException(
                status_code=status.HTTP_400_BAD_REQUEST,
                detail="Cannot determine retry step — job has no failure record. Use from_step parameter.",
            )

    now = datetime.utcnow()

    if step in ("ingestion", "ai_processing"):
        reset_status = JobStatus.CREATED.value
    elif step == "translation":
        reset_status = JobStatus.AI_PROCESSING.value
    elif step == "tts":
        source_language = job_doc["source"].get("language", "en")
        reset_status = (
            JobStatus.APPROVED_ENGLISH.value
            if source_language == "en"
            else JobStatus.APPROVED_SOURCE.value
        )
    elif step == "render":
        reset_status = JobStatus.PENDING_QC.value
    else:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=f"Unknown failure step: {step}",
        )

    result = await db.jobs.find_one_and_update(
        {"_id": job_id},
        {
            "$set": {
                "status": reset_status,
                "error": None,
                "updated_at": now,
            },
            "$inc": {"retry_count": 1},
            "$push": {
                "review.history": {
                    "at": now,
                    "status": f"retry_{step}",
                    "by": str(current_user.id),
                    "notes": f"Retry from step '{step}' requested",
                }
            },
        },
        return_document=True,
    )

    if not result:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found or status changed")

    try:
        await audit_logger.log(
            action=AuditAction.JOB_RETRY,
            user_id=str(current_user.id),
            user_email=current_user.email,
            user_role=current_user.role.value if current_user.role else None,
            resource_type="job",
            resource_id=job_id,
            resource_name=result.get("title"),
            description=f"Retry job from step '{step}'",
            details={"step": step, "retry_count": result.get("retry_count", 1)},
        )
    except Exception as e:
        logger.warning(f"Failed to write audit log for job retry {job_id}: {e}")

    try:
        await connection_manager.broadcast_job_status_update(
            job_id=job_id,
            status=reset_status,
            job_title=result.get("title"),
            message=f"{result.get('title', 'Job')} retry initiated (step: {step})",
        )
    except Exception as e:
        logger.warning(f"Failed to broadcast retry status for {job_id}: {e}")

    try:
        if step in ("ingestion", "ai_processing"):
            await _cr_dispatch("ingest", job_id)
        elif step in ("translation", "tts"):
            await _cr_dispatch("translate", job_id)
        elif step == "render":
            await _cr_dispatch("rerender", job_id, language=job_doc.get("last_render_language", "en"))
        logger.info(f"Triggered retry for job {job_id} step='{step}' by {current_user.email}")
    except Exception as e:
        logger.error(f"Failed to dispatch retry task for job {job_id}: {e}")
        raise HTTPException(
            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
            detail="Failed to start retry task",
        ) from None

    return JobResponse(
        id=str(result["_id"]),
        title=result["title"],
        status=result["status"],
        source=result["source"],
        requested_outputs=RequestedOutputs(**result["requested_outputs"]),
        review=result.get("review", {"notes": "", "history": []}),
        outputs=result.get("outputs"),
        created_at=result["created_at"].isoformat(),
        updated_at=result["updated_at"].isoformat(),
    )


CANCELLABLE_STATUSES = {
    JobStatus.INGESTING.value,
    JobStatus.AI_PROCESSING.value,
    JobStatus.TRANSLATING.value,
    JobStatus.TTS_GENERATING.value,
    JobStatus.RENDERING_VIDEO.value,
    JobStatus.RENDERING_QC.value,
}


@router.post("/{job_id}/cancel", response_model=JobResponse)
async def cancel_job(
    job_id: str,
    ctx: MembershipContext = Depends(get_membership_context),
    db: AsyncIOMotorDatabase = Depends(get_database),
):
    """Cancel a job that is currently being processed.

    Revokes the Celery task and sets the job status to cancelled.
    Only allowed for jobs in active processing states.
    """
    job_doc = await get_job_or_403(job_id, ctx, db)
    current_user = ctx.user

    if job_doc["status"] not in CANCELLABLE_STATUSES:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=f"Job cannot be cancelled in its current state ({job_doc['status']})",
        )

    now = datetime.utcnow()

    # Revoke Celery task if one is tracked
    task_id = job_doc.get("task_id")
    if task_id:
        try:
            celery_app.control.revoke(task_id, terminate=True)
        except Exception as e:
            logger.warning(f"Could not revoke task {task_id} for job {job_id}: {e}")

    result = await db.jobs.find_one_and_update(
        {"_id": job_id},
        {
            "$set": {
                "status": JobStatus.CANCELLED.value,
                "updated_at": now,
            },
            "$push": {
                "history": {
                    "at": now,
                    "status": JobStatus.CANCELLED.value,
                    "by": current_user.email,
                    "notes": "Cancelled by user",
                }
            },
        },
        return_document=True,
    )

    if not result:
        raise HTTPException(status_code=404, detail="Job not found")

    try:
        await log_job_action(
            db=db,
            user=current_user,
            action=AuditAction.JOB_UPDATED,
            resource_type="job",
            resource_id=job_id,
            resource_name=result.get("title"),
            description="Job cancelled by user",
            details={"previous_status": job_doc["status"]},
        )
    except Exception as e:
        logger.warning(f"Failed to write audit log for job cancel {job_id}: {e}")

    try:
        await connection_manager.broadcast_job_status_update(
            job_id=job_id,
            status=JobStatus.CANCELLED.value,
            job_title=result.get("title"),
            message=f"{result.get('title', 'Job')} was cancelled",
        )
    except Exception as e:
        logger.warning(f"Failed to broadcast cancel status for {job_id}: {e}")

    logger.info(f"Job {job_id} cancelled by {current_user.email}")

    return JobResponse(
        id=str(result["_id"]),
        title=result["title"],
        status=result["status"],
        source=result["source"],
        requested_outputs=RequestedOutputs(**result["requested_outputs"]),
        review=result.get("review", {"notes": "", "history": []}),
        outputs=result.get("outputs"),
        created_at=result["created_at"].isoformat(),
        updated_at=result["updated_at"].isoformat(),
    )


@router.get("/{job_id}/validate", response_model=AssetValidationResponse)
async def validate_job_assets(
    job_id: str,
    current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)),
    db: AsyncIOMotorDatabase = Depends(get_database),
):
    """Validate job assets before completion"""
    job_doc = await db.jobs.find_one({"_id": job_id})
    if not job_doc:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="Job not found"
        )

    is_valid, errors = await asset_validation_service.validate_job_assets(job_doc)

    return AssetValidationResponse(
        is_valid=is_valid,
        errors=errors,
        warnings=[]  # Can be extended for non-blocking warnings
    )


# ==============================================================================
# Accessible Video QC Editing Endpoints
# ==============================================================================


@router.get("/{job_id}/accessible-video/{language}/edit-state", response_model=AccessibleVideoEditStateResponse)
async def get_accessible_video_edit_state(
    job_id: str,
    language: str,
    current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)),
    db: AsyncIOMotorDatabase = Depends(get_database),
):
    """Get current pause points, segment metadata, and TTS regeneration queue for QC editing."""
    job_doc = await db.jobs.find_one({"_id": job_id})
    if not job_doc:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="Job not found"
        )

    # Check job is in QC status
    if job_doc["status"] not in [JobStatus.PENDING_QC.value, JobStatus.RENDERING_QC.value]:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=f"Job must be in pending_qc or rendering_qc status (current: {job_doc['status']})"
        )

    # Get language outputs
    lang_output = job_doc.get("outputs", {}).get(language)
    if not lang_output:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"No outputs found for language {language}"
        )

    # Get edit state
    edit_state = lang_output.get("accessible_video_edit_state")
    if not edit_state:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"No accessible video edit state found for language {language}"
        )

    # Calculate total duration from segments
    segments = edit_state.get("video_segments", [])
    total_duration_ms = sum(s.get("duration_ms", 0) for s in segments)

    # Get signed URL for accessible video
    accessible_video_gcs = lang_output.get("accessible_video_gcs")
    accessible_video_url = None
    if accessible_video_gcs:
        blob_path = accessible_video_gcs.replace(f"gs://{settings.gcs_bucket}/", "")
        try:
            accessible_video_url = await gcs_service.get_signed_url(blob_path, expiration_hours=1)
        except HTTPException:
            # File may not exist yet
            accessible_video_url = None

    # Convert to response format
    return AccessibleVideoEditStateResponse(
        pause_points=[
            PausePointResponse(
                cue_index=pp.get("cue_index"),
                original_ms=pp.get("original_ms"),
                source_ms=pp.get("source_ms", pp.get("original_ms")),  # Fallback for old data
                adjusted_ms=pp.get("adjusted_ms"),
                min_bound_ms=pp.get("min_bound_ms"),
                max_bound_ms=pp.get("max_bound_ms")
            )
            for pp in edit_state.get("pause_points", [])
        ],
        video_segments=[
            VideoSegmentResponse(
                segment_index=seg.get("segment_index"),
                start_ms=seg.get("start_ms"),
                end_ms=seg.get("end_ms"),
                gcs_uri=seg.get("gcs_uri"),
                duration_ms=seg.get("duration_ms"),
                is_freeze_frame=seg.get("is_freeze_frame", False),
                cue_index=seg.get("cue_index")
            )
            for seg in segments
        ],
        tts_regeneration_queue=[
            TTSRegenerationItem(
                cue_index=req.get("cue_index"),
                requested_at=req.get("requested_at").isoformat() if hasattr(req.get("requested_at"), "isoformat") else req.get("requested_at"),
                new_text=req.get("new_text"),
                status=req.get("status", "pending"),
                error_message=req.get("error_message")
            )
            for req in edit_state.get("tts_regeneration_queue", [])
        ],
        last_render_at=edit_state.get("last_render_at").isoformat() if edit_state.get("last_render_at") else None,
        total_duration_ms=total_duration_ms,
        accessible_video_url=accessible_video_url
    )


@router.patch("/{job_id}/accessible-video/{language}/pause-points/{cue_index}", response_model=PausePointResponse)
async def update_pause_point(
    job_id: str,
    language: str,
    cue_index: int,
    request: PausePointUpdateRequest,
    current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)),
    ctx: MembershipContext = Depends(get_membership_context),
    db: AsyncIOMotorDatabase = Depends(get_database),
):
    """Update a single pause point timing with millisecond precision."""
    job_doc = await get_job_or_403(job_id, ctx, db)

    # Check job is in QC status
    if job_doc["status"] not in [JobStatus.PENDING_QC.value]:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=f"Job must be in pending_qc status for editing (current: {job_doc['status']})"
        )

    # Get edit state
    lang_output = job_doc.get("outputs", {}).get(language)
    if not lang_output:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"No outputs found for language {language}"
        )

    edit_state = lang_output.get("accessible_video_edit_state")
    if not edit_state:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"No edit state found for language {language}"
        )

    # Find the pause point
    pause_points = edit_state.get("pause_points", [])
    pause_point = next((pp for pp in pause_points if pp.get("cue_index") == cue_index), None)
    if not pause_point:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Pause point for cue {cue_index} not found"
        )

    # Validate bounds
    min_bound = pause_point.get("min_bound_ms", 0)
    max_bound = pause_point.get("max_bound_ms", float("inf"))

    if request.adjusted_ms < min_bound:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=f"Adjusted value {request.adjusted_ms}ms is below minimum bound {min_bound}ms"
        )
    if request.adjusted_ms > max_bound:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=f"Adjusted value {request.adjusted_ms}ms is above maximum bound {max_bound}ms"
        )

    # Update the pause point
    pause_point["adjusted_ms"] = request.adjusted_ms

    # Save back to database
    await db.jobs.update_one(
        {"_id": job_id},
        {
            "$set": {
                f"outputs.{language}.accessible_video_edit_state.pause_points": pause_points,
                "updated_at": datetime.utcnow()
            }
        }
    )

    logger.info(f"Updated pause point for cue {cue_index} in job {job_id}/{language}: {request.adjusted_ms}ms")

    return PausePointResponse(
        cue_index=pause_point["cue_index"],
        original_ms=pause_point["original_ms"],
        source_ms=pause_point.get("source_ms", pause_point["original_ms"]),  # Fallback for old data
        adjusted_ms=pause_point["adjusted_ms"],
        min_bound_ms=pause_point["min_bound_ms"],
        max_bound_ms=pause_point["max_bound_ms"]
    )


@router.post("/{job_id}/accessible-video/{language}/tts-regeneration")
async def queue_tts_regeneration(
    job_id: str,
    language: str,
    request: TTSRegenerationQueueRequest,
    current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)),
    ctx: MembershipContext = Depends(get_membership_context),
    db: AsyncIOMotorDatabase = Depends(get_database),
):
    """Queue TTS regeneration for specific cues (uses current AD VTT text)."""
    job_doc = await get_job_or_403(job_id, ctx, db)

    # Check job is in QC status
    if job_doc["status"] not in [JobStatus.PENDING_QC.value]:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=f"Job must be in pending_qc status for editing (current: {job_doc['status']})"
        )

    # Get edit state
    lang_output = job_doc.get("outputs", {}).get(language)
    if not lang_output:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"No outputs found for language {language}"
        )

    edit_state = lang_output.get("accessible_video_edit_state")
    if not edit_state:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"No edit state found for language {language}"
        )

    # Get current queue
    current_queue = edit_state.get("tts_regeneration_queue", [])
    existing_cues = {req.get("cue_index") for req in current_queue}

    # Add new requests (avoid duplicates)
    added = []
    for cue_idx in request.cue_indices:
        if cue_idx not in existing_cues:
            new_request = {
                "cue_index": cue_idx,
                "requested_at": datetime.utcnow().isoformat(),
                "new_text": None,  # Will use current VTT text
                "status": "pending",
                "error_message": None
            }
            current_queue.append(new_request)
            added.append(cue_idx)

    # Save back to database
    await db.jobs.update_one(
        {"_id": job_id},
        {
            "$set": {
                f"outputs.{language}.accessible_video_edit_state.tts_regeneration_queue": current_queue,
                "updated_at": datetime.utcnow()
            }
        }
    )

    logger.info(f"Queued TTS regeneration for cues {added} in job {job_id}/{language}")

    return {"message": f"Queued {len(added)} cue(s) for regeneration", "queued_cues": added}


@router.delete("/{job_id}/accessible-video/{language}/tts-regeneration/{cue_index}")
async def remove_tts_regeneration(
    job_id: str,
    language: str,
    cue_index: int,
    current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)),
    ctx: MembershipContext = Depends(get_membership_context),
    db: AsyncIOMotorDatabase = Depends(get_database),
):
    """Remove a cue from the TTS regeneration queue."""
    job_doc = await get_job_or_403(job_id, ctx, db)

    # Get edit state
    lang_output = job_doc.get("outputs", {}).get(language)
    if not lang_output or not lang_output.get("accessible_video_edit_state"):
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"No edit state found for language {language}"
        )

    edit_state = lang_output["accessible_video_edit_state"]
    current_queue = edit_state.get("tts_regeneration_queue", [])

    # Remove the cue from queue
    new_queue = [req for req in current_queue if req.get("cue_index") != cue_index]

    if len(new_queue) == len(current_queue):
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Cue {cue_index} not in regeneration queue"
        )

    # Save back to database
    await db.jobs.update_one(
        {"_id": job_id},
        {
            "$set": {
                f"outputs.{language}.accessible_video_edit_state.tts_regeneration_queue": new_queue,
                "updated_at": datetime.utcnow()
            }
        }
    )

    logger.info(f"Removed cue {cue_index} from TTS regeneration queue for job {job_id}/{language}")

    return {"message": f"Removed cue {cue_index} from regeneration queue"}


@router.post("/{job_id}/accessible-video/{language}/re-render", response_model=JobResponse)
async def trigger_accessible_video_rerender(
    job_id: str,
    language: str,
    request: RerenderAccessibleVideoRequest,
    current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)),
    ctx: MembershipContext = Depends(get_membership_context),
    db: AsyncIOMotorDatabase = Depends(get_database),
):
    """
    Trigger re-synthesis of accessible video with QC changes.
    - Regenerates only queued TTS segments (others reuse existing MP3s)
    - Optionally runs Whisper pause point refinement
    """
    job_doc = await get_job_or_403(job_id, ctx, db)

    # Check job is in QC status
    if job_doc["status"] not in [JobStatus.PENDING_QC.value]:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=f"Job must be in pending_qc status to re-render (current: {job_doc['status']})"
        )

    # Linguists can only re-render their assigned language
    if current_user.role == UserRole.LINGUIST:
        try:
            lqc._assert_can_act(job_doc, language, current_user)
        except HTTPException:
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail=f"You are not assigned to language '{language}'"
            ) from None

    # Get edit state
    lang_output = job_doc.get("outputs", {}).get(language)
    if not lang_output or not lang_output.get("accessible_video_edit_state"):
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"No edit state found for language {language}"
        )

    edit_state = lang_output["accessible_video_edit_state"]

    # Get cues to regenerate
    regenerate_cues = [
        req.get("cue_index")
        for req in edit_state.get("tts_regeneration_queue", [])
        if req.get("status") == "pending"
    ]

    # Update job status to RENDERING_QC — conditional to prevent concurrent render races
    transition_result = await db.jobs.update_one(
        {"_id": job_id, "status": JobStatus.PENDING_QC.value},  # Only transition from PENDING_QC
        {
            "$set": {
                "status": JobStatus.RENDERING_QC.value,
                "updated_at": datetime.utcnow()
            },
            "$push": {
                "review.history": {
                    "at": datetime.utcnow(),
                    "status": JobStatus.RENDERING_QC.value,
                    "by": str(current_user.id),
                    "notes": f"Re-rendering {language} with {len(regenerate_cues)} TTS regeneration(s), whisper_refine={request.whisper_refine}"
                }
            }
        }
    )
    if transition_result.modified_count == 0:
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail="A re-render is already in progress for this job. Please wait for it to complete."
        )

    # Trigger re-render task
    await _cr_dispatch(
        "rerender", job_id,
        language=language,
        regenerate_cues=regenerate_cues,
        whisper_refine=request.whisper_refine,
    )

    logger.info(
        f"Triggered accessible video re-render for job {job_id}/{language}: "
        f"regenerate_cues={regenerate_cues}, whisper_refine={request.whisper_refine}"
    )

    # Get updated job
    result = await db.jobs.find_one({"_id": job_id})

    return JobResponse(
        id=str(result["_id"]),
        title=result["title"],
        status=result["status"],
        source=result["source"],
        requested_outputs=RequestedOutputs(**result["requested_outputs"]),
        review=result.get("review", {"notes": "", "history": []}),
        outputs=result.get("outputs"),
        created_at=result["created_at"].isoformat(),
        updated_at=result["updated_at"].isoformat()
    )


@router.put("/{job_id}/tts-preferences", response_model=JobResponse)
async def update_tts_preferences(
    job_id: str,
    request: UpdateTTSPreferencesRequest,
    current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)),
    ctx: MembershipContext = Depends(get_membership_context),
    db: AsyncIOMotorDatabase = Depends(get_database),
):
    """
    Update TTS preferences and regenerate all TTS segments for all languages.

    This endpoint:
    1. Updates the TTS preferences in the job
    2. Queues ALL cues for TTS regeneration for all languages with AD outputs
    3. Triggers re-render tasks for each language
    """
    job_doc = await get_job_or_403(job_id, ctx, db)

    # Check job is in QC status
    if job_doc["status"] not in [JobStatus.PENDING_QC.value]:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=f"Job must be in pending_qc status to update TTS preferences (current: {job_doc['status']})"
        )

    # Check if audio description MP3 is requested
    if not job_doc.get("requested_outputs", {}).get("audio_description_mp3"):
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="TTS preferences can only be updated for jobs with audio description MP3 requested"
        )

    # Get languages with AD outputs
    outputs = job_doc.get("outputs", {})
    languages_to_regenerate = []

    for lang, lang_output in outputs.items():
        if lang_output.get("ad_vtt_gcs") and lang_output.get("ad_cues_gcs_prefix"):
            languages_to_regenerate.append(lang)

    if not languages_to_regenerate:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="No languages with audio description outputs found to regenerate"
        )

    # Update TTS preferences in the job
    await db.jobs.update_one(
        {"_id": job_id},
        {
            "$set": {
                "requested_outputs.tts_preferences": request.tts_preferences.model_dump(),
                "status": JobStatus.TTS_GENERATING.value,
                "updated_at": datetime.utcnow()
            },
            "$push": {
                "review.history": {
                    "at": datetime.utcnow(),
                    "status": JobStatus.TTS_GENERATING.value,
                    "by": str(current_user.id),
                    "notes": f"Updating TTS preferences and regenerating for {len(languages_to_regenerate)} language(s)"
                }
            }
        }
    )

    # Import tts_synthesis for parsing AD cues
    from ...tasks.tts_synthesis import parse_ad_cues

    # For each language, get cue count and queue all cues, then trigger re-render
    for lang in languages_to_regenerate:
        lang_output = outputs[lang]

        # Download AD VTT to count cues
        ad_vtt_gcs = lang_output.get("ad_vtt_gcs")
        ad_blob_path = ad_vtt_gcs.replace(f"gs://{settings.gcs_bucket}/", "")
        ad_blob = gcs_service.bucket.blob(ad_blob_path)
        ad_vtt_content = ad_blob.download_as_text()

        # Parse cues to get count
        cues = parse_ad_cues(ad_vtt_content)
        all_cue_indices = list(range(len(cues)))

        logger.info(
            f"Queuing {len(all_cue_indices)} cues for TTS regeneration in language {lang} for job {job_id}"
        )

        # Update TTS regeneration queue for this language
        regeneration_queue = [
            {"cue_index": idx, "status": "pending", "queued_at": datetime.utcnow().isoformat()}
            for idx in all_cue_indices
        ]

        await db.jobs.update_one(
            {"_id": job_id},
            {
                "$set": {
                    f"outputs.{lang}.accessible_video_edit_state.tts_regeneration_queue": regeneration_queue,
                    "updated_at": datetime.utcnow()
                }
            }
        )

        # Trigger re-render task for this language
        await _cr_dispatch(
            "rerender", job_id,
            language=lang,
            regenerate_cues=all_cue_indices,
        )

        logger.info(
            f"Triggered TTS regeneration re-render for job {job_id}/{lang} with {len(all_cue_indices)} cues"
        )

    # Broadcast status update
    job_title = job_doc.get("title", "Untitled Job")
    await connection_manager.broadcast_job_update(
        job_id,
        {
            "type": "status_update",
            "job_id": job_id,
            "status": JobStatus.TTS_GENERATING.value,
            "job_title": job_title,
            "message": f"Regenerating TTS for {len(languages_to_regenerate)} language(s) with new voice settings"
        }
    )

    # Get updated job
    result = await db.jobs.find_one({"_id": job_id})

    return JobResponse(
        id=str(result["_id"]),
        title=result["title"],
        status=result["status"],
        source=result["source"],
        requested_outputs=RequestedOutputs(**result["requested_outputs"]),
        review=result.get("review", {"notes": "", "history": []}),
        outputs=result.get("outputs"),
        created_at=result["created_at"].isoformat(),
        updated_at=result["updated_at"].isoformat()
    )
