from __future__ import annotations

from datetime import datetime, timedelta, timezone
from uuid import UUID
from uuid import uuid4

from fastapi import APIRouter, Depends, HTTPException, Response, Request as FastapiRequest
from fastapi.responses import StreamingResponse
from sqlalchemy import func
from sqlalchemy.orm import Session
from sqlalchemy.exc import SQLAlchemyError

from app.core.config import settings
from app.core.deps import get_public_session
from app.core.security import create_jwt
from app.db.session import get_db
from app.models.admin_user import AdminUser
from app.models.attachment import Attachment
from app.models.client import Client
from app.models.invoice import Invoice
from app.models.message import Message
from app.models.audit_log import AuditLog
from app.models.notification import Notification
from app.models.request import Request
from app.models.request_service_request import RequestServiceRequest
from app.models.status import Status
from app.models.status_history import StatusHistory
from app.models.topic import Topic
from app.services.invoice_crypto import decrypt_requisites
from app.services.invoice_pdf import build_invoice_pdf_bytes
from app.services.origin_guard import enforce_public_origin_or_403
from app.services.notifications import (
    get_client_notification,
    list_client_notifications,
    mark_client_notifications_read,
    serialize_notification,
    unread_client_summary,
)
from app.services.request_read_markers import clear_unread_for_client
from app.services.request_deadline import initial_important_date_at
from app.services.request_templates import validate_required_topic_fields_or_400
from app.services.security_audit import extract_client_ip, record_pii_access_event
from app.api.admin.requests_modules.status_flow import get_request_status_route_service
from app.schemas.public import (
    PublicAttachmentRead,
    PublicRequestCreate,
    PublicRequestCreated,
    PublicServiceRequestCreate,
    PublicServiceRequestRead,
    PublicStatusHistoryRead,
    PublicTimelineEvent,
)

router = APIRouter()

OTP_CREATE_PURPOSE = "CREATE_REQUEST"
OTP_VIEW_PURPOSE = "VIEW_REQUEST"
INVOICE_STATUS_LABELS = {
    "WAITING_PAYMENT": "Ожидает оплату",
    "PAID": "Оплачен",
    "CANCELED": "Отменен",
}
SERVICE_REQUEST_TYPES = {"CURATOR_CONTACT", "LAWYER_CHANGE_REQUEST"}


def _normalize_phone(raw: str | None) -> str:
    value = str(raw or "").strip()
    if not value:
        return ""
    allowed = {"+", "(", ")", "-", " "}
    return "".join(ch for ch in value if ch.isdigit() or ch in allowed).strip()


def _normalize_email(raw: str | None) -> str:
    return str(raw or "").strip().lower()


def _normalize_track(raw: str | None) -> str:
    return str(raw or "").strip().upper()


def _is_honeypot_tripped(raw: str | None) -> bool:
    return bool(str(raw or "").strip())


def _now_utc() -> datetime:
    return datetime.now(timezone.utc)


def _set_view_cookie(response: Response, subject: str) -> None:
    token = create_jwt(
        {"sub": subject, "purpose": OTP_VIEW_PURPOSE},
        settings.PUBLIC_JWT_SECRET,
        timedelta(days=settings.PUBLIC_JWT_TTL_DAYS),
    )
    response.set_cookie(
        key=settings.PUBLIC_COOKIE_NAME,
        value=token,
        httponly=True,
        secure=settings.public_cookie_secure_effective,
        samesite=settings.public_cookie_samesite_effective,
        max_age=settings.PUBLIC_JWT_TTL_DAYS * 24 * 3600,
    )


def _public_actor_subject(session: dict) -> str:
    subject = _require_view_session_or_403(session)
    normalized_track = _normalize_track(subject)
    if normalized_track.startswith("TRK-"):
        return normalized_track
    normalized_phone = _normalize_phone(subject)
    if normalized_phone:
        return normalized_phone
    normalized_email = _normalize_email(subject)
    return normalized_email or subject


def _record_public_read_audit(
    db: Session,
    *,
    session: dict,
    http_request: FastapiRequest,
    action: str,
    scope: str,
    request_id: str | UUID | None = None,
    details: dict | None = None,
) -> None:
    record_pii_access_event(
        db,
        actor_role="CLIENT",
        actor_subject=_public_actor_subject(session),
        actor_ip=extract_client_ip(http_request),
        action=action,
        scope=scope,
        request_id=request_id,
        details=details or {},
        responsible="Клиент",
        persist_now=True,
    )


def _require_create_session_or_403(session: dict, client_phone: str, client_email: str | None = None) -> None:
    purpose = str(session.get("purpose") or "").strip().upper()
    subject = str(session.get("sub") or "").strip()
    auth_channel = str(session.get("auth_channel") or "SMS").strip().upper()
    if purpose != OTP_CREATE_PURPOSE or not subject:
        raise HTTPException(status_code=403, detail="Требуется подтверждение контакта через OTP")
    if auth_channel == "EMAIL":
        if _normalize_email(subject) != _normalize_email(client_email):
            raise HTTPException(status_code=403, detail="Требуется подтверждение email через OTP")
        return
    if _normalize_phone(subject) != _normalize_phone(client_phone):
        raise HTTPException(status_code=403, detail="Требуется подтверждение телефона через OTP")


def _require_view_session_or_403(session: dict) -> str:
    purpose = str(session.get("purpose") or "").strip().upper()
    subject = str(session.get("sub") or "").strip()
    if purpose != OTP_VIEW_PURPOSE or not subject:
        raise HTTPException(status_code=403, detail="Нет доступа к заявке")
    return subject


def _ensure_view_access_or_403(session: dict, req: Request) -> None:
    subject = _require_view_session_or_403(session)
    if _normalize_track(subject) == _normalize_track(req.track_number):
        return
    if _normalize_phone(subject) and _normalize_phone(subject) == _normalize_phone(req.client_phone):
        return
    if _normalize_email(subject) and _normalize_email(subject) == _normalize_email(req.client_email):
        return
    # Do not disclose whether a foreign request exists.
    raise HTTPException(status_code=404, detail="Заявка не найдена")


def _request_for_track_or_404(db: Session, session: dict, track_number: str) -> Request:
    normalized_track = _normalize_track(track_number)
    subject = _require_view_session_or_403(session)
    subject_track = _normalize_track(subject)
    if subject_track.startswith("TRK-") and subject_track != normalized_track:
        raise HTTPException(status_code=404, detail="Заявка не найдена")
    req = db.query(Request).filter(Request.track_number == normalized_track).first()
    if req is None:
        raise HTTPException(status_code=404, detail="Заявка не найдена")
    _ensure_view_access_or_403(session, req)
    return req


def _upsert_client_by_phone(db: Session, *, full_name: str, phone: str, email: str | None = None) -> Client:
    normalized_phone = _normalize_phone(phone)
    normalized_email = _normalize_email(email)
    if not normalized_phone:
        raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно')
    normalized_name = str(full_name or "").strip() or "Клиент"

    client = db.query(Client).filter(Client.phone == normalized_phone).first()
    if client is None:
        client = Client(
            full_name=normalized_name,
            phone=normalized_phone,
            email=normalized_email or None,
            responsible="Клиент",
        )
        db.add(client)
        db.flush()
        return client
    if client.full_name != normalized_name:
        client.full_name = normalized_name
        client.responsible = "Клиент"
        db.add(client)
        db.flush()
    if normalized_email and client.email != normalized_email:
        client.email = normalized_email
        client.responsible = "Клиент"
        db.add(client)
        db.flush()
    return client


def _to_iso(value) -> str | None:
    return value.isoformat() if value is not None else None


def _serialize_public_service_request(row: RequestServiceRequest) -> PublicServiceRequestRead:
    return PublicServiceRequestRead(
        id=row.id,
        request_id=row.request_id,
        client_id=row.client_id,
        assigned_lawyer_id=row.assigned_lawyer_id,
        type=str(row.type or ""),
        status=str(row.status or "NEW"),
        body=str(row.body or ""),
        created_by_client=bool(row.created_by_client),
        created_at=_to_iso(row.created_at),
        updated_at=_to_iso(row.updated_at),
        resolved_at=_to_iso(row.resolved_at),
    )


def _public_invoice_payload(row: Invoice, track_number: str) -> dict:
    status_code = str(row.status or "").upper()
    return {
        "id": str(row.id),
        "invoice_number": row.invoice_number,
        "status": row.status,
        "status_label": INVOICE_STATUS_LABELS.get(status_code, row.status),
        "amount": float(row.amount) if row.amount is not None else 0.0,
        "currency": row.currency,
        "payer_display_name": row.payer_display_name,
        "issued_at": _to_iso(row.issued_at),
        "paid_at": _to_iso(row.paid_at),
        "download_url": f"/api/public/requests/{track_number}/invoices/{row.id}/pdf",
    }


@router.post("", response_model=PublicRequestCreated, status_code=201)
def create_request(
    payload: PublicRequestCreate,
    response: Response,
    request: FastapiRequest,
    db: Session = Depends(get_db),
    session: dict = Depends(get_public_session),
):
    enforce_public_origin_or_403(request, endpoint="/api/public/requests")
    if _is_honeypot_tripped(getattr(payload, "hp_field", None)):
        raise HTTPException(status_code=400, detail="Некорректный запрос")
    if not bool(payload.pdn_consent):
        raise HTTPException(status_code=400, detail="Необходимо согласие на обработку персональных данных")
    _require_create_session_or_403(session, payload.client_phone, payload.client_email)
    validate_required_topic_fields_or_400(db, payload.topic_code, payload.extra_fields)
    client = _upsert_client_by_phone(
        db,
        full_name=payload.client_name,
        phone=payload.client_phone,
        email=payload.client_email,
    )

    track = f"TRK-{uuid4().hex[:10].upper()}"
    row = Request(
        track_number=track,
        client_id=client.id,
        client_name=client.full_name,
        client_phone=client.phone,
        client_email=client.email,
        topic_code=payload.topic_code,
        important_date_at=initial_important_date_at(),
        description=payload.description,
        extra_fields=payload.extra_fields,
        pdn_consent=True,
        pdn_consent_at=_now_utc(),
        pdn_consent_ip=extract_client_ip(request),
        responsible="Клиент",
    )
    db.add(row)
    db.flush()
    db.add(
        AuditLog(
            actor_admin_id=None,
            entity="requests",
            entity_id=str(row.id),
            action="PDN_CONSENT_CAPTURED",
            diff={
                "track_number": row.track_number,
                "consent": True,
                "consent_at": _to_iso(row.pdn_consent_at),
                "consent_ip": row.pdn_consent_ip,
            },
            responsible="Клиент",
        )
    )
    db.commit()
    db.refresh(row)

    _set_view_cookie(response, client.phone)
    return PublicRequestCreated(request_id=row.id, track_number=row.track_number, otp_required=False)


@router.get("/topics")
def list_public_topics(db: Session = Depends(get_db)):
    rows = (
        db.query(Topic)
        .filter(Topic.enabled.is_(True))
        .order_by(Topic.sort_order.asc(), Topic.name.asc(), Topic.code.asc())
        .all()
    )
    return [{"code": row.code, "name": row.name} for row in rows]


@router.get("/my")
def list_my_requests(
    request: FastapiRequest,
    db: Session = Depends(get_db),
    session: dict = Depends(get_public_session),
):
    subject = _require_view_session_or_403(session)
    normalized_track = _normalize_track(subject)
    normalized_phone = _normalize_phone(subject)
    normalized_email = _normalize_email(subject)

    query = db.query(Request)
    if normalized_track.startswith("TRK-"):
        query = query.filter(Request.track_number == normalized_track)
    elif normalized_email and "@" in normalized_email:
        query = query.filter(Request.client_email == normalized_email)
    else:
        query = query.filter(Request.client_phone == normalized_phone)

    rows = query.order_by(Request.updated_at.desc(), Request.created_at.desc(), Request.id.desc()).all()
    row_ids = [row.id for row in rows if row and row.id]
    status_names: dict[str, str] = {}
    status_codes = {str(row.status_code or "").strip() for row in rows if str(row.status_code or "").strip()}
    if status_codes:
        try:
            status_rows = db.query(Status.code, Status.name).filter(Status.code.in_(list(status_codes))).all()
        except SQLAlchemyError:
            status_rows = []
        for code, name in status_rows:
            normalized_code = str(code or "").strip()
            if not normalized_code:
                continue
            status_names[normalized_code] = str(name or normalized_code)
    unread_by_request: dict[str, dict[str, object]] = {}
    if row_ids:
        try:
            notif_rows = (
                db.query(Notification.request_id, Notification.event_type, func.count(Notification.id))
                .filter(
                    Notification.recipient_type == "CLIENT",
                    Notification.is_read.is_(False),
                    Notification.request_id.in_(row_ids),
                )
                .group_by(Notification.request_id, Notification.event_type)
                .all()
            )
        except SQLAlchemyError:
            notif_rows = []
        for request_id, event_type, count in notif_rows:
            request_key = str(request_id or "")
            if not request_key:
                continue
            bucket = unread_by_request.setdefault(request_key, {"total": 0, "by_event": {}})
            event_key = str(event_type or "").strip().upper()
            event_count = int(count or 0)
            if event_key:
                by_event = bucket["by_event"] if isinstance(bucket.get("by_event"), dict) else {}
                by_event[event_key] = int(by_event.get(event_key, 0)) + event_count
                bucket["by_event"] = by_event
            bucket["total"] = int(bucket.get("total", 0)) + event_count
    payload = {
        "rows": [
            {
                "id": str(row.id),
                "track_number": row.track_number,
                "topic_code": row.topic_code,
                "status_code": row.status_code,
                "status_name": status_names.get(str(row.status_code or "").strip()) or str(row.status_code or ""),
                "client_has_unread_updates": bool(row.client_has_unread_updates),
                "client_unread_event_type": row.client_unread_event_type,
                "viewer_unread_total": int((unread_by_request.get(str(row.id)) or {}).get("total", 0)),
                "viewer_unread_by_event": dict((unread_by_request.get(str(row.id)) or {}).get("by_event", {})),
                "created_at": _to_iso(row.created_at),
                "updated_at": _to_iso(row.updated_at),
            }
            for row in rows
        ],
        "total": len(rows),
    }
    _record_public_read_audit(
        db,
        session=session,
        http_request=request,
        action="READ_REQUEST_LIST",
        scope="REQUEST_LIST",
        details={"total": len(rows)},
    )
    return payload


@router.get("/{track_number}")
def get_request_by_track(
    track_number: str,
    request: FastapiRequest,
    db: Session = Depends(get_db),
    session: dict = Depends(get_public_session),
):
    req = _request_for_track_or_404(db, session, track_number)
    status_name = str(req.status_code or "")
    if str(req.status_code or "").strip():
        try:
            status_row = db.query(Status).filter(Status.code == req.status_code).first()
        except SQLAlchemyError:
            status_row = None
        if status_row is not None:
            status_name = str(status_row.name or req.status_code or "")
    topic_name = None
    if str(req.topic_code or "").strip():
        try:
            topic = db.query(Topic).filter(Topic.code == req.topic_code).first()
            topic_name = topic.name if topic and topic.name else None
        except SQLAlchemyError:
            topic_name = None
    lawyer_name = None
    lawyer_phone = None
    lawyer_id = str(req.assigned_lawyer_id or "").strip()
    if lawyer_id:
        try:
            lawyer_uuid = UUID(lawyer_id)
        except ValueError:
            lawyer_uuid = None
        if lawyer_uuid is not None:
            try:
                lawyer = db.get(AdminUser, lawyer_uuid)
            except SQLAlchemyError:
                lawyer = None
            if lawyer is not None:
                lawyer_name = lawyer.name
                lawyer_phone = lawyer.phone

    markers_cleared = clear_unread_for_client(req)
    notifications_cleared = mark_client_notifications_read(
        db,
        track_number=req.track_number,
        request_id=req.id,
        responsible="Клиент",
    )
    if markers_cleared or notifications_cleared:
        db.add(req)
        db.commit()
        db.refresh(req)

    unread_after_open = unread_client_summary(
        db,
        track_number=req.track_number,
        request_id=req.id,
    )

    payload = {
        "id": str(req.id),
        "client_id": str(req.client_id) if req.client_id else None,
        "track_number": req.track_number,
        "client_name": req.client_name,
        "client_phone": req.client_phone,
        "topic_code": req.topic_code,
        "topic_name": topic_name,
        "status_code": req.status_code,
        "status_name": status_name,
        "important_date_at": _to_iso(req.important_date_at),
        "description": req.description,
        "extra_fields": req.extra_fields,
        "assigned_lawyer_id": req.assigned_lawyer_id,
        "assigned_lawyer_name": lawyer_name or req.assigned_lawyer_id,
        "assigned_lawyer_phone": lawyer_phone,
        "client_has_unread_updates": req.client_has_unread_updates,
        "client_unread_event_type": req.client_unread_event_type,
        "lawyer_has_unread_updates": req.lawyer_has_unread_updates,
        "lawyer_unread_event_type": req.lawyer_unread_event_type,
        "viewer_unread_total": int(unread_after_open.get("total") or 0),
        "viewer_unread_by_event": dict(unread_after_open.get("by_event") or {}),
        "created_at": _to_iso(req.created_at),
        "updated_at": _to_iso(req.updated_at),
    }
    _record_public_read_audit(
        db,
        session=session,
        http_request=request,
        action="READ_REQUEST_CARD",
        scope="REQUEST_CARD",
        request_id=req.id,
        details={"track_number": req.track_number},
    )
    return payload


@router.get("/{track_number}/status-route")
def get_status_route_by_track(
    track_number: str,
    request: FastapiRequest,
    db: Session = Depends(get_db),
    session: dict = Depends(get_public_session),
):
    req = _request_for_track_or_404(db, session, track_number)
    try:
        payload = get_request_status_route_service(
            str(req.id),
            db,
            {"role": "ADMIN", "sub": "", "email": "Клиент"},
        )
        payload["available_statuses"] = []
        _record_public_read_audit(
            db,
            session=session,
            http_request=request,
            action="READ_REQUEST_STATUS_ROUTE",
            scope="REQUEST_STATUS_ROUTE",
            request_id=req.id,
            details={"track_number": req.track_number},
        )
        return payload
    except Exception:
        current = str(req.status_code or "").strip()
        current_name = current
        if current:
            try:
                status_row = db.query(Status).filter(Status.code == current).first()
            except SQLAlchemyError:
                status_row = None
            if status_row is not None:
                current_name = str(status_row.name or current)
        changed_at = _to_iso(req.updated_at or req.created_at)
        payload = {
            "request_id": str(req.id),
            "track_number": req.track_number,
            "topic_code": req.topic_code,
            "current_status": current or None,
            "current_important_date_at": _to_iso(req.important_date_at),
            "available_statuses": [],
            "history": [
                {
                    "id": "current",
                    "from_status": None,
                    "to_status": current or None,
                    "to_status_name": current_name or None,
                    "changed_at": changed_at,
                    "important_date_at": _to_iso(req.important_date_at),
                    "comment": None,
                    "duration_seconds": None,
                }
            ],
            "nodes": [
                {
                    "code": current or "",
                    "name": current_name or "-",
                    "kind": "DEFAULT",
                    "state": "current",
                    "changed_at": changed_at,
                    "sla_hours": None,
                    "note": "Текущий этап обработки заявки",
                }
            ]
            if current
            else [],
        }
        _record_public_read_audit(
            db,
            session=session,
            http_request=request,
            action="READ_REQUEST_STATUS_ROUTE",
            scope="REQUEST_STATUS_ROUTE",
            request_id=req.id,
            details={"track_number": req.track_number, "fallback": True},
        )
        return payload


@router.get("/{track_number}/attachments", response_model=list[PublicAttachmentRead])
def list_attachments_by_track(
    track_number: str,
    request: FastapiRequest,
    db: Session = Depends(get_db),
    session: dict = Depends(get_public_session),
):
    req = _request_for_track_or_404(db, session, track_number)
    rows = (
        db.query(Attachment)
        .filter(Attachment.request_id == req.id)
        .order_by(Attachment.created_at.desc(), Attachment.id.desc())
        .all()
    )
    payload = [
        PublicAttachmentRead(
            id=row.id,
            request_id=row.request_id,
            message_id=row.message_id,
            file_name=row.file_name,
            mime_type=row.mime_type,
            size_bytes=row.size_bytes,
            created_at=_to_iso(row.created_at),
            download_url=f"/api/public/uploads/object/{row.id}",
        )
        for row in rows
    ]
    _record_public_read_audit(
        db,
        session=session,
        http_request=request,
        action="READ_ATTACHMENTS",
        scope="REQUEST_ATTACHMENTS",
        request_id=req.id,
        details={"rows": len(rows)},
    )
    return payload


@router.get("/{track_number}/invoices")
def list_invoices_by_track(
    track_number: str,
    request: FastapiRequest,
    db: Session = Depends(get_db),
    session: dict = Depends(get_public_session),
):
    req = _request_for_track_or_404(db, session, track_number)
    rows = (
        db.query(Invoice)
        .filter(Invoice.request_id == req.id)
        .order_by(Invoice.issued_at.desc(), Invoice.created_at.desc(), Invoice.id.desc())
        .all()
    )
    payload = [_public_invoice_payload(row, req.track_number) for row in rows]
    _record_public_read_audit(
        db,
        session=session,
        http_request=request,
        action="READ_INVOICE_LIST",
        scope="INVOICE",
        request_id=req.id,
        details={"rows": len(rows)},
    )
    return payload


@router.get("/{track_number}/invoices/{invoice_id}/pdf")
def download_invoice_pdf_by_track(
    track_number: str,
    invoice_id: str,
    request: FastapiRequest,
    db: Session = Depends(get_db),
    session: dict = Depends(get_public_session),
):
    req = _request_for_track_or_404(db, session, track_number)
    try:
        invoice_uuid = UUID(str(invoice_id))
    except ValueError:
        raise HTTPException(status_code=400, detail="Некорректный invoice_id")

    invoice = db.get(Invoice, invoice_uuid)
    if invoice is None or str(invoice.request_id) != str(req.id):
        raise HTTPException(status_code=404, detail="Счет не найден")

    issuer = db.get(AdminUser, invoice.issued_by_admin_user_id) if invoice.issued_by_admin_user_id else None
    requisites = decrypt_requisites(invoice.payer_details_encrypted)
    pdf_bytes = build_invoice_pdf_bytes(
        invoice_number=invoice.invoice_number,
        amount=float(invoice.amount) if invoice.amount is not None else 0.0,
        currency=invoice.currency,
        status=INVOICE_STATUS_LABELS.get(str(invoice.status or "").upper(), invoice.status or "-"),
        issued_at=invoice.issued_at,
        paid_at=invoice.paid_at,
        payer_display_name=invoice.payer_display_name,
        request_track_number=req.track_number,
        issued_by_name=(issuer.name if issuer else invoice.issued_by_role),
        requisites=requisites,
    )
    _record_public_read_audit(
        db,
        session=session,
        http_request=request,
        action="READ_INVOICE_PDF",
        scope="INVOICE",
        request_id=req.id,
        details={"invoice_id": str(invoice.id), "invoice_number": invoice.invoice_number},
    )
    file_name = f"{invoice.invoice_number}.pdf"
    headers = {"Content-Disposition": f'attachment; filename="{file_name}"'}
    return StreamingResponse(iter([pdf_bytes]), media_type="application/pdf", headers=headers)


@router.get("/{track_number}/history", response_model=list[PublicStatusHistoryRead])
def list_status_history_by_track(
    track_number: str,
    request: FastapiRequest,
    db: Session = Depends(get_db),
    session: dict = Depends(get_public_session),
):
    req = _request_for_track_or_404(db, session, track_number)
    rows = (
        db.query(StatusHistory)
        .filter(StatusHistory.request_id == req.id)
        .order_by(StatusHistory.created_at.asc(), StatusHistory.id.asc())
        .all()
    )
    payload = [
        PublicStatusHistoryRead(
            id=row.id,
            request_id=row.request_id,
            from_status=row.from_status,
            to_status=row.to_status,
            comment=row.comment,
            created_at=_to_iso(row.created_at),
        )
        for row in rows
    ]
    _record_public_read_audit(
        db,
        session=session,
        http_request=request,
        action="READ_REQUEST_HISTORY",
        scope="REQUEST_HISTORY",
        request_id=req.id,
        details={"rows": len(rows)},
    )
    return payload


@router.get("/{track_number}/timeline", response_model=list[PublicTimelineEvent])
def list_timeline_by_track(
    track_number: str,
    request: FastapiRequest,
    db: Session = Depends(get_db),
    session: dict = Depends(get_public_session),
):
    req = _request_for_track_or_404(db, session, track_number)
    messages = db.query(Message).filter(Message.request_id == req.id).all()
    attachments = db.query(Attachment).filter(Attachment.request_id == req.id).all()
    statuses = db.query(StatusHistory).filter(StatusHistory.request_id == req.id).all()

    events: list[PublicTimelineEvent] = []
    for row in statuses:
        events.append(
            PublicTimelineEvent(
                type="status_change",
                created_at=_to_iso(row.created_at),
                payload={
                    "id": str(row.id),
                    "from_status": row.from_status,
                    "to_status": row.to_status,
                    "comment": row.comment,
                },
            )
        )
    for row in messages:
        events.append(
            PublicTimelineEvent(
                type="message",
                created_at=_to_iso(row.created_at),
                payload={
                    "id": str(row.id),
                    "author_type": row.author_type,
                    "author_name": row.author_name,
                    "body": row.body,
                },
            )
        )
    for row in attachments:
        events.append(
            PublicTimelineEvent(
                type="attachment",
                created_at=_to_iso(row.created_at),
                payload={
                    "id": str(row.id),
                    "file_name": row.file_name,
                    "mime_type": row.mime_type,
                    "size_bytes": row.size_bytes,
                    "download_url": f"/api/public/uploads/object/{row.id}",
                },
            )
        )

    def _sort_key(event: PublicTimelineEvent):
        return event.created_at or ""

    events.sort(key=_sort_key)
    _record_public_read_audit(
        db,
        session=session,
        http_request=request,
        action="READ_REQUEST_TIMELINE",
        scope="REQUEST_TIMELINE",
        request_id=req.id,
        details={"rows": len(events)},
    )
    return events


@router.post("/{track_number}/service-requests", response_model=PublicServiceRequestRead, status_code=201)
def create_service_request_by_track(
    track_number: str,
    payload: PublicServiceRequestCreate,
    request: FastapiRequest,
    db: Session = Depends(get_db),
    session: dict = Depends(get_public_session),
):
    enforce_public_origin_or_403(request, endpoint="/api/public/requests/{track_number}/service-requests")
    req = _request_for_track_or_404(db, session, track_number)
    request_type = str(payload.type or "").strip().upper()
    if request_type not in SERVICE_REQUEST_TYPES:
        raise HTTPException(status_code=400, detail="Некорректный тип запроса")

    body = str(payload.body or "").strip()
    if len(body) < 3:
        raise HTTPException(status_code=400, detail='Поле "body" должно содержать минимум 3 символа')

    assigned_lawyer_value = None
    assigned_lawyer_raw = str(req.assigned_lawyer_id or "").strip()
    if assigned_lawyer_raw:
        assigned_lawyer_value = assigned_lawyer_raw

    lawyer_unread = request_type == "CURATOR_CONTACT" and assigned_lawyer_value is not None
    row = RequestServiceRequest(
        request_id=str(req.id),
        client_id=str(req.client_id) if req.client_id else None,
        assigned_lawyer_id=assigned_lawyer_value,
        type=request_type,
        status="NEW",
        body=body,
        created_by_client=True,
        admin_unread=True,
        lawyer_unread=lawyer_unread,
        responsible="Клиент",
    )
    db.add(row)
    db.flush()
    db.add(
        AuditLog(
            actor_admin_id=None,
            entity="request_service_requests",
            entity_id=str(row.id),
            action="CREATE_CLIENT_REQUEST",
            diff={
                "request_id": str(req.id),
                "track_number": req.track_number,
                "type": request_type,
                "status": "NEW",
            },
            responsible="Клиент",
        )
    )
    db.commit()
    db.refresh(row)
    return _serialize_public_service_request(row)


@router.get("/{track_number}/service-requests", response_model=list[PublicServiceRequestRead])
def list_service_requests_by_track(
    track_number: str,
    request: FastapiRequest,
    db: Session = Depends(get_db),
    session: dict = Depends(get_public_session),
):
    req = _request_for_track_or_404(db, session, track_number)
    rows = (
        db.query(RequestServiceRequest)
        .filter(
            RequestServiceRequest.request_id == str(req.id),
            RequestServiceRequest.created_by_client.is_(True),
        )
        .order_by(RequestServiceRequest.created_at.desc(), RequestServiceRequest.id.desc())
        .all()
    )
    payload = [_serialize_public_service_request(row) for row in rows]
    _record_public_read_audit(
        db,
        session=session,
        http_request=request,
        action="READ_SERVICE_REQUESTS",
        scope="SERVICE_REQUEST",
        request_id=req.id,
        details={"rows": len(rows)},
    )
    return payload


@router.get("/{track_number}/notifications")
def list_notifications_by_track(
    track_number: str,
    request: FastapiRequest,
    unread_only: bool = False,
    limit: int = 50,
    offset: int = 0,
    db: Session = Depends(get_db),
    session: dict = Depends(get_public_session),
):
    req = _request_for_track_or_404(db, session, track_number)
    rows, total = list_client_notifications(
        db,
        track_number=req.track_number,
        unread_only=bool(unread_only),
        request_id=req.id,
        limit=limit,
        offset=offset,
    )
    _, unread_total = list_client_notifications(
        db,
        track_number=req.track_number,
        unread_only=True,
        request_id=req.id,
        limit=1,
        offset=0,
    )
    payload = {
        "rows": [serialize_notification(row) for row in rows],
        "total": int(total),
        "unread_total": int(unread_total),
    }
    _record_public_read_audit(
        db,
        session=session,
        http_request=request,
        action="READ_NOTIFICATIONS",
        scope="NOTIFICATION",
        request_id=req.id,
        details={"rows": int(total), "unread_only": bool(unread_only)},
    )
    return payload


@router.post("/{track_number}/notifications/{notification_id}/read")
def read_notification_by_track(
    track_number: str,
    notification_id: str,
    request: FastapiRequest,
    db: Session = Depends(get_db),
    session: dict = Depends(get_public_session),
):
    enforce_public_origin_or_403(request, endpoint="/api/public/requests/{track_number}/notifications/{notification_id}/read")
    req = _request_for_track_or_404(db, session, track_number)
    try:
        notification_uuid = UUID(str(notification_id))
    except ValueError:
        raise HTTPException(status_code=400, detail="Некорректный notification_id")
    row = get_client_notification(db, track_number=req.track_number, notification_id=notification_uuid)
    if row is None or str(row.request_id) != str(req.id):
        raise HTTPException(status_code=404, detail="Уведомление не найдено")
    changed = mark_client_notifications_read(
        db,
        track_number=req.track_number,
        request_id=req.id,
        notification_id=notification_uuid,
        responsible="Клиент",
    )
    db.commit()
    refreshed = get_client_notification(db, track_number=req.track_number, notification_id=notification_uuid)
    return {"status": "ok", "changed": int(changed), "notification": serialize_notification(refreshed) if refreshed else None}


@router.post("/{track_number}/notifications/read-all")
def read_all_notifications_by_track(
    track_number: str,
    request: FastapiRequest,
    db: Session = Depends(get_db),
    session: dict = Depends(get_public_session),
):
    enforce_public_origin_or_403(request, endpoint="/api/public/requests/{track_number}/notifications/read-all")
    req = _request_for_track_or_404(db, session, track_number)
    changed = mark_client_notifications_read(
        db,
        track_number=req.track_number,
        request_id=req.id,
        responsible="Клиент",
    )
    db.commit()
    return {"status": "ok", "changed": int(changed)}
