import hashlib
import json
import uuid
from datetime import datetime, timezone

from fastapi import HTTPException
from sqlalchemy import and_, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.actors import (
    ActorType,
    GuestSessionStatus,
    MergeConflictLevel,
    MergeResolutionAction,
)
from app.core.config import settings
from app.models.discussion import Discussion
from app.models.guest_merge_audit import GuestMergeAudit
from app.models.guest_session import GuestSession
from app.models.message import Message
from app.models.note_interne import NoteInterne
from app.models.signalement import Signalement
from app.models.signalement_timeline import SignalementTimeline
from app.models.utilisateur import Utilisateur


def _normalize_phone(value: str) -> str:
    value = (value or "").strip()
    if not value:
        return value
    cleaned = []
    for ch in value:
        if ch.isdigit() or (ch == "+" and not cleaned):
            cleaned.append(ch)
    return "".join(cleaned)


def _hash_phone(value: str) -> str:
    payload = f"{settings.jwt_secret_key}:{_normalize_phone(value)}"
    return hashlib.sha256(payload.encode("utf-8")).hexdigest()


def _mask_phone(value: str | None) -> str | None:
    if not value:
        return None
    normalized = _normalize_phone(value)
    if len(normalized) <= 4:
        return "*" * len(normalized)
    return f"{'*' * (len(normalized) - 4)}{normalized[-4:]}"


def _safe_text(value: str | None) -> str:
    return " ".join((value or "").strip().lower().split())


def _signalement_fingerprint(signalement: Signalement) -> str:
    parts = [
        _safe_text(signalement.type),
        signalement.date_incident.date().isoformat() if signalement.date_incident else "",
        _safe_text(signalement.lieu_incident),
        _safe_text((signalement.description or "")[:180]),
    ]
    return "|".join(parts)


def _status_rank(statut: str | None) -> int:
    ranks = {
        "NOUVEAU": 1,
        "ASSIGNE": 2,
        "EN_COURS": 3,
        "EN_ATTENTE": 4,
        "TRAITE": 5,
        "CLOTURE": 6,
    }
    return ranks.get((statut or "").upper(), 0)


def _priority_rank(priorite: str | None) -> int:
    ranks = {"BASSE": 1, "MOYENNE": 2, "HAUTE": 3, "CRITIQUE": 4}
    return ranks.get((priorite or "").upper(), 0)


def _message_signature(message: Message) -> str:
    pieces = message.pieces_jointes or []
    signature = {
        "author": _safe_text(message.auteur_nom),
        "content": _safe_text(message.contenu),
        "type": _safe_text(message.type),
        "pieces": sorted(str(item) for item in pieces),
    }
    return hashlib.sha256(json.dumps(signature, sort_keys=True).encode("utf-8")).hexdigest()


class GuestSessionService:
    def __init__(self, db: AsyncSession) -> None:
        self.db = db

    def _serialize_session(self, session: GuestSession) -> dict:
        return {
            "guestId": str(session.id),
            "statut": session.statut,
            "telephoneMasked": _mask_phone(session.telephone_chiffre),
            "createdAt": session.date_creation,
            "updatedAt": session.date_modification,
            "lastSeenAt": session.last_seen_at,
        }

    async def create_session(self, telephone: str, device_fingerprint: str | None = None) -> dict:
        normalized = _normalize_phone(telephone)
        if not normalized or len(normalized) < 8:
            raise HTTPException(status_code=422, detail="GUEST_SESSION_INVALID")

        session = GuestSession(
            statut=GuestSessionStatus.ACTIVE.value,
            telephone_hash=_hash_phone(normalized),
            telephone_chiffre=normalized,
            device_fingerprint=device_fingerprint,
            last_seen_at=datetime.now(timezone.utc),
        )
        self.db.add(session)
        await self.db.commit()
        await self.db.refresh(session)
        return self._serialize_session(session)

    async def get_session(self, guest_id: str | uuid.UUID) -> dict:
        session = await self._get_guest_session_or_404(guest_id, allow_merged=True)
        session.last_seen_at = datetime.now(timezone.utc)
        await self.db.commit()
        await self.db.refresh(session)
        return self._serialize_session(session)

    async def update_telephone(self, guest_id: str | uuid.UUID, telephone: str) -> dict:
        session = await self._get_guest_session_or_404(guest_id)
        normalized = _normalize_phone(telephone)
        if not normalized or len(normalized) < 8:
            raise HTTPException(status_code=422, detail="GUEST_SESSION_INVALID")

        session.telephone_hash = _hash_phone(normalized)
        session.telephone_chiffre = normalized
        session.last_seen_at = datetime.now(timezone.utc)
        await self.db.commit()
        await self.db.refresh(session)
        return self._serialize_session(session)

    async def merge_preview(self, guest_id: str | uuid.UUID, user_id: str | uuid.UUID) -> dict:
        guest_uuid = self._to_uuid(guest_id)
        user_uuid = self._to_uuid(user_id)

        await self._get_guest_session_or_404(guest_uuid)
        await self._get_user_or_404(user_uuid)

        signalements_count = (
            await self.db.execute(
                select(func.count(Signalement.id)).where(Signalement.actor_guest_id == guest_uuid)
            )
        ).scalar_one()

        discussions_count = (
            await self.db.execute(
                select(func.count(Discussion.id)).where(Discussion.actor_guest_id == guest_uuid)
            )
        ).scalar_one()

        messages_count = (
            await self.db.execute(
                select(func.count(Message.id)).where(Message.actor_guest_id == guest_uuid)
            )
        ).scalar_one()

        suivis_count = (
            await self.db.execute(
                select(func.count(SignalementTimeline.id)).where(
                    SignalementTimeline.actor_guest_id == guest_uuid
                )
            )
        ).scalar_one()

        conflicts = await self._collect_conflicts(guest_uuid, user_uuid)

        return {
            "guestId": str(guest_uuid),
            "userId": str(user_uuid),
            "canMerge": True,
            "counts": {
                "signalements": int(signalements_count or 0),
                "discussions": int(discussions_count or 0),
                "messages": int(messages_count or 0),
                "suivis": int(suivis_count or 0),
            },
            "conflicts": conflicts,
        }

    async def merge_guest_into_user(
        self,
        guest_id: str | uuid.UUID,
        user_id: str | uuid.UUID,
        idempotency_key: str,
        accept_policy: str,
        resolutions: list[dict] | None = None,
    ) -> dict:
        if not idempotency_key:
            raise HTTPException(status_code=400, detail="IDEMPOTENCY_KEY_REQUIRED")

        guest_uuid = self._to_uuid(guest_id)
        user_uuid = self._to_uuid(user_id)
        await self._get_user_or_404(user_uuid)

        existing_audit = (
            await self.db.execute(
                select(GuestMergeAudit).where(GuestMergeAudit.merge_request_id == idempotency_key)
            )
        ).scalar_one_or_none()
        if existing_audit:
            return existing_audit.summary_json

        preview = await self.merge_preview(guest_uuid, user_uuid)
        conflict_map = self._build_resolution_map(
            preview["conflicts"],
            accept_policy=accept_policy,
            resolutions=resolutions or [],
        )

        # Close any implicit read transaction opened during preview before starting the merge tx.
        if self.db.in_transaction():
            await self.db.rollback()

        migrated_counts = {"signalements": 0, "discussions": 0, "messages": 0, "suivis": 0}
        conflicts_handled = 0
        signalement_target_map: dict[str, str] = {}

        async with self.db.begin():
            guest_session = (
                await self.db.execute(
                    select(GuestSession)
                    .where(GuestSession.id == guest_uuid)
                    .with_for_update()
                )
            ).scalar_one_or_none()

            if guest_session is None:
                raise HTTPException(status_code=404, detail="GUEST_SESSION_NOT_FOUND")
            if guest_session.statut == GuestSessionStatus.MERGED.value:
                raise HTTPException(status_code=409, detail="GUEST_SESSION_MERGED")
            if guest_session.statut != GuestSessionStatus.ACTIVE.value:
                raise HTTPException(status_code=409, detail="GUEST_SESSION_INVALID")

            user_signalements = (
                await self.db.execute(
                    select(Signalement).where(
                        or_(
                            Signalement.actor_user_id == user_uuid,
                            Signalement.utilisateur_id == user_uuid,
                        )
                    )
                )
            ).scalars().all()
            user_signalements_by_id = {str(item.id): item for item in user_signalements}

            guest_signalements = (
                await self.db.execute(
                    select(Signalement).where(Signalement.actor_guest_id == guest_uuid)
                )
            ).scalars().all()

            signalement_conflicts = {
                item["guestEntityId"]: item
                for item in preview["conflicts"]
                if item["level"] == MergeConflictLevel.SIGNALEMENT.value
            }

            for guest_signalement in guest_signalements:
                conflict = signalement_conflicts.get(str(guest_signalement.id))
                key = self._resolution_key(
                    MergeConflictLevel.SIGNALEMENT.value,
                    str(guest_signalement.id),
                    conflict["targetEntityId"] if conflict else None,
                )
                action = conflict_map.get(
                    key,
                    MergeResolutionAction.MERGE_FIELDS.value,
                )

                if action == MergeResolutionAction.SKIP.value:
                    continue

                target_signalement = None
                if conflict and conflict.get("targetEntityId"):
                    target_signalement = user_signalements_by_id.get(conflict["targetEntityId"])

                if target_signalement and action == MergeResolutionAction.MERGE_FIELDS.value:
                    self._merge_signalement_fields(target_signalement, guest_signalement)
                    signalement_target_map[str(guest_signalement.id)] = str(target_signalement.id)
                    conflicts_handled += 1
                elif target_signalement and action == MergeResolutionAction.KEEP_TARGET.value:
                    signalement_target_map[str(guest_signalement.id)] = str(target_signalement.id)
                    conflicts_handled += 1
                else:
                    signalement_target_map[str(guest_signalement.id)] = str(guest_signalement.id)

                guest_signalement.actor_type = ActorType.AUTH_USER.value
                guest_signalement.actor_user_id = user_uuid
                guest_signalement.actor_guest_id = None
                guest_signalement.utilisateur_id = user_uuid
                migrated_counts["signalements"] += 1

                target_signalement_id = signalement_target_map[str(guest_signalement.id)]
                target_uuid = uuid.UUID(target_signalement_id)

                await self.db.execute(
                    update(SignalementTimeline)
                    .where(SignalementTimeline.signalement_id == guest_signalement.id)
                    .values(
                        signalement_id=target_uuid,
                        actor_type=ActorType.AUTH_USER.value,
                        actor_user_id=user_uuid,
                        actor_guest_id=None,
                    )
                )
                await self.db.execute(
                    update(NoteInterne)
                    .where(NoteInterne.signalement_id == guest_signalement.id)
                    .values(
                        signalement_id=target_uuid,
                        actor_type=ActorType.AUTH_USER.value,
                        actor_user_id=user_uuid,
                        actor_guest_id=None,
                        auteur_id=user_uuid,
                    )
                )

            await self.db.execute(
                update(SignalementTimeline)
                .where(SignalementTimeline.actor_guest_id == guest_uuid)
                .values(
                    actor_type=ActorType.AUTH_USER.value,
                    actor_user_id=user_uuid,
                    actor_guest_id=None,
                    effectue_par_id=user_uuid,
                )
            )
            await self.db.execute(
                update(NoteInterne)
                .where(NoteInterne.actor_guest_id == guest_uuid)
                .values(
                    actor_type=ActorType.AUTH_USER.value,
                    actor_user_id=user_uuid,
                    actor_guest_id=None,
                    auteur_id=user_uuid,
                )
            )

            user_discussions = (
                await self.db.execute(
                    select(Discussion).where(
                        or_(
                            Discussion.actor_user_id == user_uuid,
                            Discussion.utilisateur_id == user_uuid,
                        )
                    )
                )
            ).scalars().all()
            user_discussions_by_id = {str(item.id): item for item in user_discussions}

            guest_discussions = (
                await self.db.execute(
                    select(Discussion).where(Discussion.actor_guest_id == guest_uuid)
                )
            ).scalars().all()

            discussion_conflicts = {
                item["guestEntityId"]: item
                for item in preview["conflicts"]
                if item["level"] == MergeConflictLevel.DISCUSSION.value
            }

            for guest_discussion in guest_discussions:
                conflict = discussion_conflicts.get(str(guest_discussion.id))
                key = self._resolution_key(
                    MergeConflictLevel.DISCUSSION.value,
                    str(guest_discussion.id),
                    conflict["targetEntityId"] if conflict else None,
                )
                action = conflict_map.get(
                    key,
                    MergeResolutionAction.MERGE_FIELDS.value,
                )
                if action == MergeResolutionAction.SKIP.value:
                    continue

                mapped_signalement = None
                if guest_discussion.signalement_id:
                    mapped_signalement = signalement_target_map.get(str(guest_discussion.signalement_id))
                if mapped_signalement:
                    guest_discussion.signalement_id = uuid.UUID(mapped_signalement)

                target_discussion = None
                if conflict and conflict.get("targetEntityId"):
                    target_discussion = user_discussions_by_id.get(conflict["targetEntityId"])

                if target_discussion and action in (
                    MergeResolutionAction.KEEP_TARGET.value,
                    MergeResolutionAction.MERGE_FIELDS.value,
                ):
                    moved_count, duplicate_count = await self._merge_discussion_messages(
                        guest_discussion,
                        target_discussion,
                        user_uuid,
                    )
                    migrated_counts["messages"] += moved_count
                    target_discussion.non_lus_utilisateur += guest_discussion.non_lus_utilisateur
                    target_discussion.non_lus_gestionnaire += guest_discussion.non_lus_gestionnaire
                    await self._refresh_discussion_summary(target_discussion)

                    guest_discussion.est_archivee = True
                    guest_discussion.actor_type = ActorType.AUTH_USER.value
                    guest_discussion.actor_user_id = user_uuid
                    guest_discussion.actor_guest_id = None
                    guest_discussion.utilisateur_id = user_uuid
                    guest_discussion.dernier_message = (
                        f"Fusion vers {target_discussion.id} (dedupe={duplicate_count})"
                    )
                    conflicts_handled += 1
                    migrated_counts["discussions"] += 1
                else:
                    guest_discussion.actor_type = ActorType.AUTH_USER.value
                    guest_discussion.actor_user_id = user_uuid
                    guest_discussion.actor_guest_id = None
                    guest_discussion.utilisateur_id = user_uuid
                    migrated_counts["discussions"] += 1

                    guest_message_rows = (
                        await self.db.execute(
                            select(Message).where(Message.discussion_id == guest_discussion.id)
                        )
                    ).scalars().all()
                    for message in guest_message_rows:
                        if message.actor_guest_id == guest_uuid:
                            message.actor_type = ActorType.AUTH_USER.value
                            message.actor_user_id = user_uuid
                            message.actor_guest_id = None
                            message.auteur_id = user_uuid
                            migrated_counts["messages"] += 1

            merged_at = datetime.now(timezone.utc)
            guest_session.statut = GuestSessionStatus.MERGED.value
            guest_session.merged_into_user_id = user_uuid
            guest_session.merged_at = merged_at
            guest_session.last_seen_at = merged_at

            summary = {
                "merged": True,
                "guestId": str(guest_uuid),
                "userId": str(user_uuid),
                "migratedCounts": migrated_counts,
                "mergedAt": merged_at.isoformat(),
                "conflictsHandled": conflicts_handled,
                "acceptPolicy": accept_policy,
                "conflicts": preview["conflicts"],
            }

            self.db.add(
                GuestMergeAudit(
                    guest_id=guest_uuid,
                    user_id=user_uuid,
                    merge_request_id=idempotency_key,
                    summary_json=summary,
                )
            )

        return {
            "merged": True,
            "guestId": str(guest_uuid),
            "userId": str(user_uuid),
            "migratedCounts": migrated_counts,
            "mergedAt": datetime.now(timezone.utc),
            "conflictsHandled": conflicts_handled,
        }

    async def get_guest_suivi(self, guest_id: str | uuid.UUID) -> dict:
        guest_uuid = self._to_uuid(guest_id)
        await self._get_guest_session_or_404(guest_uuid, allow_merged=True)

        signalements = (
            await self.db.execute(
                select(Signalement)
                .where(Signalement.actor_guest_id == guest_uuid)
                .order_by(Signalement.date_creation.desc())
            )
        ).scalars().all()

        items: list[dict] = []
        for signalement in signalements:
            unread_count = (
                await self.db.execute(
                    select(func.sum(Discussion.non_lus_utilisateur)).where(
                        Discussion.signalement_id == signalement.id,
                    )
                )
            ).scalar_one()
            items.append(
                {
                    "reference": signalement.reference,
                    "signalementId": str(signalement.id),
                    "statut": signalement.statut,
                    "messagesNonLus": int(unread_count or 0),
                    "derniereMiseAJour": signalement.date_modification or signalement.date_creation,
                }
            )

        return {"items": items, "total": len(items)}

    async def get_guest_suivi_by_reference(self, guest_id: str | uuid.UUID, reference: str) -> dict:
        guest_uuid = self._to_uuid(guest_id)
        await self._get_guest_session_or_404(guest_uuid, allow_merged=True)

        signalement = (
            await self.db.execute(
                select(Signalement).where(
                    Signalement.actor_guest_id == guest_uuid,
                    Signalement.reference == reference,
                )
            )
        ).scalar_one_or_none()

        if not signalement:
            raise HTTPException(status_code=404, detail="GUEST_ACCESS_DENIED")

        unread_count = (
            await self.db.execute(
                select(func.sum(Discussion.non_lus_utilisateur)).where(
                    Discussion.signalement_id == signalement.id
                )
            )
        ).scalar_one()

        return {
            "reference": signalement.reference,
            "signalementId": str(signalement.id),
            "statut": signalement.statut,
            "messagesNonLus": int(unread_count or 0),
            "derniereMiseAJour": signalement.date_modification or signalement.date_creation,
        }

    async def _collect_conflicts(self, guest_id: uuid.UUID, user_id: uuid.UUID) -> list[dict]:
        conflicts: list[dict] = []

        guest_signalements = (
            await self.db.execute(select(Signalement).where(Signalement.actor_guest_id == guest_id))
        ).scalars().all()
        user_signalements = (
            await self.db.execute(
                select(Signalement).where(
                    or_(Signalement.actor_user_id == user_id, Signalement.utilisateur_id == user_id)
                )
            )
        ).scalars().all()

        user_by_reference = {
            (item.reference or "").strip().upper(): item
            for item in user_signalements
            if item.reference
        }
        user_by_fingerprint = {_signalement_fingerprint(item): item for item in user_signalements}

        for guest_signalement in guest_signalements:
            key = (guest_signalement.reference or "").strip().upper()
            target = user_by_reference.get(key)
            reason = "SAME_REFERENCE"
            if target is None:
                target = user_by_fingerprint.get(_signalement_fingerprint(guest_signalement))
                reason = "SAME_FINGERPRINT" if target else ""

            if target:
                conflicts.append(
                    {
                        "level": MergeConflictLevel.SIGNALEMENT.value,
                        "guestEntityId": str(guest_signalement.id),
                        "targetEntityId": str(target.id),
                        "reason": reason,
                        "recommendedAction": MergeResolutionAction.MERGE_FIELDS.value,
                        "duplicateMessages": 0,
                    }
                )

        guest_discussions = (
            await self.db.execute(select(Discussion).where(Discussion.actor_guest_id == guest_id))
        ).scalars().all()
        user_discussions = (
            await self.db.execute(
                select(Discussion).where(
                    or_(Discussion.actor_user_id == user_id, Discussion.utilisateur_id == user_id)
                )
            )
        ).scalars().all()

        for guest_discussion in guest_discussions:
            target = self._find_discussion_conflict(guest_discussion, user_discussions)
            if not target:
                continue
            duplicate_messages = await self._count_duplicate_messages(
                guest_discussion_id=guest_discussion.id,
                target_discussion_id=target.id,
            )
            conflicts.append(
                {
                    "level": MergeConflictLevel.DISCUSSION.value,
                    "guestEntityId": str(guest_discussion.id),
                    "targetEntityId": str(target.id),
                    "reason": "DISCUSSION_COLLISION",
                    "recommendedAction": MergeResolutionAction.MERGE_FIELDS.value,
                    "duplicateMessages": int(duplicate_messages),
                }
            )

        return conflicts

    def _find_discussion_conflict(
        self,
        guest_discussion: Discussion,
        user_discussions: list[Discussion],
    ) -> Discussion | None:
        guest_title = _safe_text(guest_discussion.titre)
        guest_reference = _safe_text(guest_discussion.signalement_reference)
        for discussion in user_discussions:
            if (
                guest_discussion.signalement_id
                and discussion.signalement_id
                and str(guest_discussion.signalement_id) == str(discussion.signalement_id)
            ):
                return discussion
            if guest_reference and _safe_text(discussion.signalement_reference) == guest_reference:
                return discussion
            if guest_title and _safe_text(discussion.titre) == guest_title:
                return discussion
        return None

    async def _count_duplicate_messages(
        self,
        guest_discussion_id: uuid.UUID,
        target_discussion_id: uuid.UUID,
    ) -> int:
        guest_messages = (
            await self.db.execute(
                select(Message).where(Message.discussion_id == guest_discussion_id)
            )
        ).scalars().all()
        target_messages = (
            await self.db.execute(
                select(Message).where(Message.discussion_id == target_discussion_id)
            )
        ).scalars().all()

        target_signatures = {_message_signature(item) for item in target_messages}
        duplicates = 0
        for guest_message in guest_messages:
            if _message_signature(guest_message) in target_signatures:
                duplicates += 1
        return duplicates

    async def _merge_discussion_messages(
        self,
        source: Discussion,
        target: Discussion,
        user_id: uuid.UUID,
    ) -> tuple[int, int]:
        source_messages = (
            await self.db.execute(
                select(Message)
                .where(Message.discussion_id == source.id)
                .order_by(Message.date_creation.asc(), Message.id.asc())
            )
        ).scalars().all()
        target_messages = (
            await self.db.execute(
                select(Message)
                .where(Message.discussion_id == target.id)
                .order_by(Message.date_creation.asc(), Message.id.asc())
            )
        ).scalars().all()

        signatures = {_message_signature(item) for item in target_messages}
        moved = 0
        duplicates = 0

        for message in source_messages:
            signature = _message_signature(message)
            if signature in signatures:
                duplicates += 1
                continue

            cloned = Message(
                discussion_id=target.id,
                actor_type=(
                    ActorType.AUTH_USER.value
                    if message.actor_type == ActorType.GUEST.value
                    else message.actor_type
                ),
                actor_user_id=(
                    user_id if message.actor_type == ActorType.GUEST.value else message.actor_user_id
                ),
                actor_guest_id=(
                    None if message.actor_type == ActorType.GUEST.value else message.actor_guest_id
                ),
                auteur_id=(
                    user_id if message.actor_type == ActorType.GUEST.value else message.auteur_id
                ),
                auteur_nom=message.auteur_nom,
                contenu=message.contenu,
                type=message.type,
                est_lu=message.est_lu,
                numero_sequence=None,
                pieces_jointes=message.pieces_jointes,
                date_creation=message.date_creation,
            )
            self.db.add(cloned)
            signatures.add(signature)
            moved += 1

        await self.db.flush()
        await self._resequence_messages(target.id)
        return moved, duplicates

    async def _resequence_messages(self, discussion_id: uuid.UUID) -> None:
        messages = (
            await self.db.execute(
                select(Message)
                .where(Message.discussion_id == discussion_id)
                .order_by(Message.date_creation.asc(), Message.id.asc())
            )
        ).scalars().all()
        for index, message in enumerate(messages, start=1):
            message.numero_sequence = index

    async def _refresh_discussion_summary(self, discussion: Discussion) -> None:
        row = (
            await self.db.execute(
                select(Message)
                .where(Message.discussion_id == discussion.id)
                .order_by(Message.date_creation.desc(), Message.id.desc())
                .limit(1)
            )
        ).scalar_one_or_none()
        if row:
            discussion.dernier_message = (row.contenu or "")[:100]
            discussion.dernier_message_at = row.date_creation

    def _merge_signalement_fields(
        self,
        target: Signalement,
        guest: Signalement,
    ) -> None:
        if _status_rank(guest.statut) > _status_rank(target.statut):
            target.statut = guest.statut
            target.est_traite = guest.est_traite
            target.date_traitement = guest.date_traitement

        if _priority_rank(guest.priorite) > _priority_rank(target.priorite):
            target.priorite = guest.priorite

        if not target.date_incident and guest.date_incident:
            target.date_incident = guest.date_incident
        if not target.lieu_incident and guest.lieu_incident:
            target.lieu_incident = guest.lieu_incident
        if not target.auteur_presume and guest.auteur_presume:
            target.auteur_presume = guest.auteur_presume
        if not target.description_temoins and guest.description_temoins:
            target.description_temoins = guest.description_temoins

        target_text = _safe_text(target.description)
        guest_text = _safe_text(guest.description)
        if guest_text and guest_text not in target_text:
            target.description = f"{target.description}\n\n---\n{guest.description}"

    def _build_resolution_map(
        self,
        conflicts: list[dict],
        accept_policy: str,
        resolutions: list[dict],
    ) -> dict[tuple[str, str, str], str]:
        resolved: dict[tuple[str, str, str], str] = {}
        for item in resolutions:
            key = self._resolution_key(
                level=item.get("level", ""),
                guest_entity_id=item.get("guestEntityId", ""),
                target_entity_id=item.get("targetEntityId"),
            )
            resolved[key] = item.get("action", MergeResolutionAction.MERGE_FIELDS.value)

        default_action = (
            MergeResolutionAction.KEEP_TARGET.value
            if accept_policy == "MERGE_SAFE"
            else MergeResolutionAction.MERGE_FIELDS.value
        )

        for conflict in conflicts:
            key = self._resolution_key(
                conflict.get("level", ""),
                conflict.get("guestEntityId", ""),
                conflict.get("targetEntityId"),
            )
            if key not in resolved:
                resolved[key] = default_action

        return resolved

    def _resolution_key(
        self,
        level: str,
        guest_entity_id: str,
        target_entity_id: str | None,
    ) -> tuple[str, str, str]:
        return (level, guest_entity_id, target_entity_id or "")

    async def _get_guest_session_or_404(
        self,
        guest_id: str | uuid.UUID,
        allow_merged: bool = False,
    ) -> GuestSession:
        guest_uuid = self._to_uuid(guest_id)
        session = (
            await self.db.execute(select(GuestSession).where(GuestSession.id == guest_uuid))
        ).scalar_one_or_none()
        if session is None:
            raise HTTPException(status_code=404, detail="GUEST_SESSION_NOT_FOUND")
        if not allow_merged and session.statut == GuestSessionStatus.MERGED.value:
            raise HTTPException(status_code=409, detail="GUEST_SESSION_MERGED")
        if session.statut not in (
            GuestSessionStatus.ACTIVE.value,
            GuestSessionStatus.MERGED.value,
        ):
            raise HTTPException(status_code=409, detail="GUEST_SESSION_INVALID")
        return session

    async def _get_user_or_404(self, user_id: str | uuid.UUID) -> Utilisateur:
        user_uuid = self._to_uuid(user_id)
        user = (
            await self.db.execute(
                select(Utilisateur).where(
                    Utilisateur.id == user_uuid,
                    Utilisateur.est_actif == True,
                )
            )
        ).scalar_one_or_none()
        if user is None:
            raise HTTPException(status_code=404, detail="Utilisateur introuvable")
        return user

    def _to_uuid(self, value: str | uuid.UUID) -> uuid.UUID:
        if isinstance(value, uuid.UUID):
            return value
        try:
            return uuid.UUID(str(value))
        except ValueError as exc:
            raise HTTPException(status_code=422, detail="UUID_INVALID") from exc
