<template>
  <!-- Quick actions -->
  <!-- Flow retiré : la relance de flow sera pilotée par l'automatisation (Réglages généraux), pas manuellement depuis le ticket. -->
  <div class="issue-actions q-mb-sm row items-center q-gutter-xs">
    <TicketStatusMenu :status="doc.status" @set-status="onSetStatus" />
    <TicketStatusControl doctype="Issue" :docname="docName" :status="doc.status"
      @changed="p => { if (p && p.status) doc.status = p.status; $emit('reply-sent', docName) }" />
    <!-- Fulfillment vente : complète l'installation liée → active l'abonnement + facture prorata BROUILLON (flux existant, gaté) -->
    <q-btn v-if="canActivate" dense unelevated no-caps color="deep-purple-6" icon="paid" :label="'Activer + facture brouillon' + (actPreview && actPreview.prorated_total != null ? ' (' + actPreview.prorated_total + '$)' : '')" :loading="activating" @click="activateSale">
      <q-tooltip>{{ (actPreview.lines || []).length }} abonnement(s) en attente → active + facture prorata BROUILLON (à approuver). Complète l'installation « {{ saleJob.subject || saleJob.name }} ».</q-tooltip>
    </q-btn>
  </div>

  <!-- Client lié (comme une conversation : cherchable par nom, association 1 clic).
       Masqué quand on ouvre le ticket DEPUIS la fiche client (redondant : on connaît déjà le client). -->
  <div v-if="!hideCustomerLink" class="cust-banner q-mb-sm">
    <q-icon name="person" size="18px" :color="doc.customer ? 'green-7' : 'grey-5'" />
    <template v-if="doc.customer">
      <router-link class="cust-name erp-link" :to="'/clients/' + encodeURIComponent(doc.customer)" style="text-decoration:underline;text-underline-offset:2px">{{ customerLabel || doc.customer }}<q-icon name="open_in_new" size="12px" class="q-ml-xs" /></router-link>
      <q-btn flat dense round size="xs" icon="link_off" color="grey-6" :loading="linkingCust" @click="unlinkCustomer">
        <q-tooltip>Délier le client</q-tooltip>
      </q-btn>
    </template>
    <q-select v-else dense borderless use-input hide-dropdown-icon input-debounce="300"
      :options="custOptions" option-value="value" option-label="label" emit-value map-options
      :loading="loadingCust" placeholder="Lier à un compte client (nom)…" style="flex:1;min-width:170px"
      @filter="searchCustomers" @update:model-value="linkCustomer">
      <template #option="scope">
        <q-item v-bind="scope.itemProps" dense>
          <q-item-section>
            <q-item-label>{{ scope.opt.label }}</q-item-label>
            <q-item-label caption>{{ scope.opt.sub }}</q-item-label>
          </q-item-section>
        </q-item>
      </template>
      <template #no-option>
        <q-item dense><q-item-section class="text-caption text-grey-6">Tapez un nom de client…</q-item-section></q-item>
      </template>
    </q-select>
  </div>

  <!-- Technicien(s) assigné(s) — surfacé EN TÊTE (avant : enfoui dans l'arbre des tâches / absent du panneau) -->
  <div v-if="assignedPeople.length || dispatchJobs.length" class="tech-banner q-mb-sm">
    <q-icon name="engineering" size="18px" :color="assignedPeople.length ? 'indigo-6' : 'grey-5'" />
    <span class="tech-banner-lbl">Technicien</span>
    <template v-if="assignedPeople.length">
      <q-chip v-for="p in assignedPeople" :key="p" dense color="indigo-1" text-color="indigo-9"><UserAvatar :name="p" :size="20" card class="q-mr-xs" />{{ p }}</q-chip>
    </template>
    <span v-else class="text-caption text-grey-6">Non assigné</span>
  </div>

  <!-- Assignation MULTIFONCTION (À = assigné · CC = assistant · # = follower) — champ PARTAGÉ, réutilisé depuis PlanificationPage.
       Pool par défaut = utilisateurs assignables du ticket (le ticket assigne des Users, pas des tech_id) ; « Assist » / « #follow » ouvrent tous les utilisateurs. -->
  <div class="assign-block q-mb-sm">
    <div class="info-block-title" style="margin-bottom:4px">Assignation</div>
    <AssignmentField doctype="Issue" :doc-name="docName"
      :assignee="issueAssignee" :assistants="issueAssistants" :tech-options="unifiedUserOptions" :can-onsite="false"
      @assign="assignUser" @set-assistant="p => assignUser(p.value)" @unassign="unassignFirst" @remove-assistant="removeAssignee" />
  </div>

  <div class="issue-field-grid">
    <div class="if-row">
      <span class="if-label">Statut</span>
      <InlineField :value="doc.status" field="status" doctype="Issue" :docname="docName"
        type="select" :options="['Open', 'Replied', 'On Hold', 'Resolved', 'Closed']"
        @saved="v => doc.status = v.value" />
    </div>
    <div class="if-row">
      <span class="if-label">Priorite</span>
      <InlineField :value="doc.priority" field="priority" doctype="Issue" :docname="docName"
        type="select" :options="['Low', 'Medium', 'High', 'Urgent']"
        @saved="v => doc.priority = v.value" />
    </div>
    <div class="if-row">
      <span class="if-label">Ouvert le</span>
      <span class="if-value">{{ formatDate(doc.opening_date) }}</span>
    </div>
    <div class="if-row" v-if="doc.resolution_time">
      <span class="if-label">Resolu le</span>
      <span class="if-value">{{ doc.resolution_time }}</span>
    </div>
    <div class="if-row">
      <span class="if-label">Type</span>
      <InlineField :value="doc.issue_type" field="issue_type" doctype="Issue" :docname="docName"
        type="select" :options="['Support', 'Installation', 'Demenagement', 'Facturation', 'Reseau', 'Autre']"
        placeholder="Type" @saved="v => doc.issue_type = v.value" />
    </div>
    <div class="if-row" v-if="doc.service_location">
      <span class="if-label">Adresse</span>
      <span class="if-value">{{ doc.service_location }}</span>
    </div>
    <div class="if-row" v-if="doc.raised_by">
      <span class="if-label">Soumis par</span>
      <span class="if-value" style="display:flex;align-items:center;gap:6px;min-width:0">
        <UserAvatar :email="doc.raised_by" :name="doc.raised_by" :size="20" card />
        <span class="ellipsis">{{ doc.raised_by }}</span>
      </span>
    </div>
  </div>

  <!-- Tags — SOURCE UNIQUE (Dispatch Tag ∪ compétences roster) via TagEditor partagé.
       Avant : q-select maison + liste Dispatch Tag seule (sans les compétences « sans-fil »…). -->
  <div class="q-mt-sm">
    <div class="info-block-title" style="display:flex;align-items:center">
      Tags
      <q-btn flat dense round size="xs" icon="settings" color="grey-5" class="q-ml-xs"
        @click="showTagManager = !showTagManager">
        <q-tooltip>Gérer les tags</q-tooltip>
      </q-btn>
    </div>
    <TagEditor :model-value="currentTags" :all-tags="allTags" :get-color="getTagColor"
      :can-edit="false" placeholder="Ajouter un tag (compétence, département…)…"
      @update:model-value="onTagsChanged" @create="onCreateTag" />

    <!-- Tag Manager (CRUD Dispatch Tag) -->
    <div v-if="showTagManager" class="tag-manager q-mt-sm">
      <div class="text-caption text-weight-bold text-grey-7 q-mb-xs">Gérer les tags ({{ dispatchTags.length }})</div>
      <div v-for="t in dispatchTags" :key="t.name" class="tag-mgr-row">
        <div class="tag-mgr-dot" :style="{ background: t.color || '#6b7280' }"
          @click="cycleTagColor(t)">
          <q-tooltip>Changer la couleur</q-tooltip>
        </div>
        <span v-if="editingTagName !== t.name" class="tag-mgr-label" @dblclick="startEditTag(t)">{{ t.label }}</span>
        <input v-else v-model="editingTagValue" class="tag-mgr-edit"
          @keyup.enter="saveTagRename(t)" @keyup.escape="editingTagName = null" @blur="saveTagRename(t)" />
        <span class="tag-mgr-cat text-caption text-grey-5">{{ t.category }}</span>
        <q-btn flat dense round size="xs" icon="delete" color="negative"
          @click="deleteDispatchTag(t)" class="tag-mgr-del">
          <q-tooltip>Supprimer ce tag</q-tooltip>
        </q-btn>
      </div>
      <div v-if="!dispatchTags.length" class="text-caption text-grey-5">Aucun tag</div>
    </div>
  </div>

  <!-- ═══ DISPATCH / PROJECT TASKS ═══ -->
  <div class="q-mt-md dispatch-section">
    <div class="row items-center q-mb-xs">
      <div class="info-block-title" style="margin-bottom:0">
        <q-icon name="account_tree" size="16px" class="q-mr-xs" />
        Tâches ({{ dispatchJobs.length }})
      </div>
      <q-space />
      <!-- Un SEUL point d'entrée « Ajouter » (plus deux icônes côte à côte) : tâche simple, ou projet multi-étapes d'où découlent les tâches. -->
      <q-btn-dropdown dense size="sm" color="primary" no-caps unelevated icon="add" label="Ajouter">
        <q-list dense style="min-width:230px">
          <q-item clickable v-close-popup @click="showCreateDialog = true">
            <q-item-section avatar><q-icon name="add_task" color="primary" /></q-item-section>
            <q-item-section><q-item-label>Tâche</q-item-label><q-item-label caption>Tâche simple liée à ce ticket</q-item-label></q-item-section>
          </q-item>
          <q-item clickable v-close-popup @click="showProjectWizard = true">
            <q-item-section avatar><q-icon name="playlist_add" color="green-7" /></q-item-section>
            <q-item-section><q-item-label>Projet</q-item-label><q-item-label caption>Modèle multi-étapes (installation…) — les tâches en découlent</q-item-label></q-item-section>
          </q-item>
          <q-separator />
          <q-item clickable v-close-popup @click="availReasonOpen = true">
            <q-item-section avatar><q-icon name="fact_check" color="indigo-7" /></q-item-section>
            <q-item-section><q-item-label>Dispo par motif</q-item-label><q-item-label caption>Compétence requise → disponibilité ajustée, puis créer la tâche</q-item-label></q-item-section>
          </q-item>
        </q-list>
      </q-btn-dropdown>
    </div>

    <!-- Nested task tree -->
    <div v-if="dispatchJobs.length" class="dispatch-jobs-list">
      <!-- Root jobs (no parent) -->
      <template v-for="job in rootJobs" :key="job.name">
        <TaskNode :job="job" :all-jobs="dispatchJobs" :depth="0"
          @fire-webhook="fireWebhook" @deleted="onJobDeleted" @updated="onJobUpdated" />
      </template>

      <!-- Orphan jobs (parent_job references something outside this ticket) -->
      <template v-for="job in orphanJobs" :key="job.name">
        <TaskNode :job="job" :all-jobs="dispatchJobs" :depth="0"
          @fire-webhook="fireWebhook" @deleted="onJobDeleted" @updated="onJobUpdated" />
      </template>
    </div>

    <div v-else class="text-caption text-grey-5 q-pa-sm">
      Aucune tâche liée à ce ticket.
    </div>
  </div>

  <!-- ═══ INTERVENTION SUR SITE / DISPATCH ═══
       Modules RÉUTILISABLES (identiques au volet Planification) montés pour la tâche dispatch principale.
       Auto-affiché si signaux terrain (coords / billet legacy) ; sinon bascule manuelle. « Ne régresse pas vers un DetailModal nu ». -->
  <div v-if="primaryJob" class="q-mt-md onsite-section">
    <div class="row items-center q-mb-xs">
      <div class="info-block-title" style="margin-bottom:0"><q-icon name="construction" size="16px" class="q-mr-xs" />Intervention sur site</div>
      <q-space />
      <q-toggle v-model="showOnsite" dense size="sm" color="teal-6" label="Sur place">
        <q-tooltip>Afficher les détails terrain (carte, suivi GPS, durée, compétences, fil du billet) pour cette intervention</q-tooltip>
      </q-toggle>
    </div>
    <div v-if="showOnsite" class="onsite-body">
      <JobMapModule :name="primaryJob.name" :lat="primaryJob.latitude" :lon="primaryJob.longitude"
        :tech-id="primaryJob.assigned_tech || ''" :iso="primaryJobIso" :can-track="jobCanTrack" :today-iso="todayIso" />
      <GeofenceTimeline :name="primaryJob.name" />
      <DurationField :name="primaryJob.name" :dur-h="Number(primaryJob.duration_h) || 1" />
      <RequiredSkillsField :name="primaryJob.name" :skills="primaryJobSkills" :all-tags="allTags" :get-color="getTagColor" @create="onCreateTag" />
      <!-- Équipe / assignation de la tâche (module partagé). Lead via roster.assignJob (chemin simple, pas la pipeline board). -->
      <div class="q-mt-sm">
        <JobTeamField :name="primaryJob.name" :tech-id="primaryJob.assigned_tech || ''" :tech-name="primaryJobTechName"
          :tech-options="jobTechOptions" :can-onsite="true"
          @assign-lead="onJobAssignLead" @unassign-lead="onJobUnassignLead" />
      </div>
      <template v-if="primaryJob.legacy_ticket_id">
        <JobThread ref="jobThreadRef" :lid="primaryJob.legacy_ticket_id" />
        <JobReplyBox :name="primaryJob.name" :lid="primaryJob.legacy_ticket_id" @sent="() => jobThreadRef && jobThreadRef.reload()" />
      </template>
    </div>
  </div>

  <div class="q-mt-sm">
    <div class="info-block-title">Sujet</div>
    <InlineField :value="doc.subject" field="subject" doctype="Issue" :docname="docName"
      placeholder="Sujet du ticket" @saved="v => doc.subject = v.value" />
  </div>
  <div v-if="doc.issue_split_from" class="q-mt-md">
    <div class="info-block-title">Ticket parent</div>
    <div class="text-body2 erp-link" style="cursor:pointer" @click="$emit('navigate', 'Issue', doc.issue_split_from)">
      {{ doc.issue_split_from }}
    </div>
  </div>
  <div class="q-mt-md">
    <div class="info-block-title">Description</div>
    <InlineField :value="doc.description || ''" field="description" doctype="Issue" :docname="docName"
      type="textarea" placeholder="Ajouter une description..."
      @saved="v => doc.description = v.value" />
  </div>
  <div v-if="doc.resolution_details" class="q-mt-md">
    <div class="info-block-title">Resolution</div>
    <div class="modal-desc" v-html="richHtml(doc.resolution_details)"></div>
  </div>
  <!-- Fil unifié : échanges (Communication) + notes (Comment) fusionnés, chronologique, en bulles -->
  <div v-if="thread.length" class="q-mt-md">
    <div class="info-block-title">Conversation ({{ thread.length }})</div>
    <div class="thread-wrap">
      <div v-for="m in thread" :key="m.id" class="msg-bubble" :class="'msg-' + m.kind">
        <div class="msg-head">
          <UserAvatar :email="m.email" :name="m.who" :size="22" card class="q-mr-xs" />
          <span class="msg-who">{{ m.who }}</span>
          <!-- Pastille de visibilité CLIQUABLE : bascule Interne ↔ Public (relabel seul, aucun courriel) -->
          <span class="msg-vis" :class="[m.public ? 'vis-pub' : 'vis-int', { 'vis-busy': togglingMsg === m.name }]"
            role="button" tabindex="0" @click="toggleMsgVisibility(m)" @keyup.enter="toggleMsgVisibility(m)">
            <q-spinner v-if="togglingMsg === m.name" size="11px" />
            <q-icon v-else :name="m.public ? 'visibility' : 'lock'" size="11px" />{{ m.public ? 'Client' : 'Interne' }}
            <q-tooltip class="bg-grey-9" style="font-size:11px">{{ m.public ? 'Visible par le client — cliquer pour repasser en interne' : 'Note interne (privée) — cliquer pour rendre visible au client (sans courriel)' }}</q-tooltip>
          </span>
          <span class="msg-when">{{ formatDateTime(m.when) }}</span>
        </div>
        <div class="msg-body" v-html="richHtml(m.content)"></div>
      </div>
    </div>
  </div>
  <div v-if="files.length" class="q-mt-md">
    <div class="info-block-title">Pieces jointes ({{ files.length }})</div>
    <div v-for="f in files" :key="f.name" class="q-py-xs">
      <a :href="erpFileUrl(f.file_url)" target="_blank" class="erp-link">
        <q-icon name="attach_file" size="14px" /> {{ f.file_name }}
      </a>
    </div>
  </div>
  <div v-if="!doc.description && !doc.resolution_details && !thread.length" class="text-center text-grey-5 q-pa-lg">
    Aucun contenu pour ce ticket
  </div>

  <!-- Delete -->
  <div class="q-mt-lg" style="border-top:1px solid #fee2e2;padding-top:12px">
    <q-btn flat dense no-caps size="sm" icon="delete" label="Supprimer ce ticket"
      color="negative" @click="confirmDelete">
      <q-tooltip>Suppression définitive du ticket</q-tooltip>
    </q-btn>
  </div>

  <div class="q-mt-md reply-box">
    <div class="info-block-title">Répondre</div>
    <q-input v-model="replyContent" dense outlined type="textarea" autogrow
      placeholder="Écrire une réponse ou une note…"
      :input-style="{ fontSize: '0.85rem', minHeight: '50px' }"
      @keydown.ctrl.enter="sendReply" @keydown.meta.enter="sendReply" />
    <!-- UNE seule action + une case « Envoyer au client ». Défaut = interne (privé). Cocher = public + courriel (lien ticket ; la réponse revient au fil). -->
    <div class="row items-center justify-between q-mt-xs no-wrap">
      <q-checkbox v-model="replyToClient" dense size="sm" :color="replyToClient ? 'deep-orange-9' : 'grey-7'">
        <span class="reply-cb-lbl" :class="{ 'reply-cb-pub': replyToClient }">
          <q-icon :name="replyToClient ? 'warning_amber' : 'lock'" size="14px" />
          {{ replyToClient ? 'Envoyer au client (visible + courriel)' : 'Note interne (privée)' }}
        </span>
        <q-tooltip class="bg-grey-9" style="font-size:11px">{{ replyToClient ? '⚠️ Visible par le CLIENT : marqué public + courriel avec lien vers le ticket (sa réponse revient au fil)' : 'Reste PRIVÉ — non visible par le client. Cochez pour envoyer au client.' }}</q-tooltip>
      </q-checkbox>
      <q-btn unelevated dense size="sm" :label="replyToClient ? 'Envoyer au client' : 'Ajouter la note'"
        :color="replyToClient ? 'deep-orange-9' : 'grey-8'" :icon="replyToClient ? 'send' : 'lock'"
        :disable="!replyContent?.trim()" :loading="sendingReply" @click="sendReply" />
    </div>
  </div>

  <!-- Create single Dispatch Job dialog -->
  <UnifiedCreateModal
    v-model="showCreateDialog"
    mode="work-order"
    :context="dispatchContext"
    :existing-jobs="dispatchJobs"
    @created="onJobCreated"
  />

  <!-- Project Wizard (multi-step template) -->
  <ProjectWizard
    v-model="showProjectWizard"
    :issue="doc"
    :existing-jobs="dispatchJobs"
    @created="onJobCreated"
  />

  <!-- « Dispo par motif » : raison (sujet/description pré-remplis) → compétence → disponibilité filtrée → créer la tâche -->
  <AvailabilityByReason
    v-model="availReasonOpen"
    :initial-text="[doc.subject, doc.description].filter(Boolean).join(' — ')"
    :customer-name="customerLabel || doc.customer || ''"
    :source-issue="doc.name || docName || ''"
    :customer="doc.customer || ''"
    :service-location="doc.service_location || ''"
    @book="onAvailBook"
  />
</template>

<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { Notify, Dialog } from 'quasar'
import { richHtml } from 'src/utils/sanitize'
import { formatDate, formatDateTime, erpFileUrl } from 'src/composables/useFormatters'
import { createDoc, updateDoc, deleteDoc, listDocs } from 'src/api/erp'
import { authFetch } from 'src/api/auth'
import { useAuthStore } from 'src/stores/auth'
import { usePermissions } from 'src/composables/usePermissions'
import { BASE_URL } from 'src/config/erpnext'
import { HUB_URL } from 'src/config/hub'
import { useTagCatalog } from 'src/composables/useTagCatalog'
import InlineField from 'src/components/shared/InlineField.vue'
import UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.vue'
import TicketStatusControl from 'src/components/shared/TicketStatusControl.vue'
import ProjectWizard from 'src/components/shared/ProjectWizard.vue'
import TaskNode from 'src/components/shared/TaskNode.vue'
import TagEditor from 'src/components/shared/TagEditor.vue'
import AssignmentField from 'src/components/shared/AssignmentField.vue'
import UserAvatar from 'src/components/shared/UserAvatar.vue'
import AvailabilityByReason from 'src/components/shared/AvailabilityByReason.vue' // raison → compétence → disponibilité (dénominateur filtré)
import { useIdentity } from 'src/composables/useIdentity' // identité unifiée : dé-dup assignation (alias/tech = même personne) + nom complet
import TicketStatusMenu from 'src/components/shared/TicketStatusMenu.vue' // bouton « Statut » (Complété/Annulé) — distinct du snooze « Reporter »
import { setJobStatusChain, activationPreview } from 'src/api/dispatch' // complète le job lié → active abonnement + facture brouillon (flux existant, gaté) + aperçu lecture seule
// Modules on-site / dispatch RÉUTILISABLES (mêmes composants que le volet Planification) — montés ici pour la tâche dispatch principale du ticket.
import JobMapModule from 'src/components/shared/detail-sections/modules/JobMapModule.vue'
import GeofenceTimeline from 'src/components/shared/detail-sections/modules/GeofenceTimeline.vue'
import DurationField from 'src/components/shared/detail-sections/modules/DurationField.vue'
import RequiredSkillsField from 'src/components/shared/detail-sections/modules/RequiredSkillsField.vue'
import JobThread from 'src/components/shared/detail-sections/modules/JobThread.vue'
import JobReplyBox from 'src/components/shared/detail-sections/modules/JobReplyBox.vue'
import JobTeamField from 'src/components/shared/detail-sections/modules/JobTeamField.vue'
import * as roster from 'src/api/roster'

const props = defineProps({
  doc: { type: Object, required: true },
  docName: String,
  title: String,
  comments: { type: Array, default: () => [] },
  comms: { type: Array, default: () => [] },
  files: { type: Array, default: () => [] },
  dispatchJobs: { type: Array, default: () => [] },
  hideCustomerLink: { type: Boolean, default: false }, // true = ouvert depuis la fiche client → masque la bannière client (redondante)
})
const emit = defineEmits(['navigate', 'reply-sent', 'dispatch-created', 'dispatch-deleted', 'dispatch-updated', 'deleted'])

const replyContent = ref('')
const authStore = useAuthStore()
const { userName } = usePermissions()
// Expéditeur = PRÉNOM de l'utilisateur connecté (note interne) / prénom+nom (sortie courriel au client) — jamais le courriel/login brut (« louis »).
const senderFirst = computed(() => { const n = (userName.value || '').trim(); return (n ? n.split(/\s+/)[0] : (authStore.user || '').split('@')[0]) || 'Agent' })
const senderFull = computed(() => ((userName.value || '').trim() || (authStore.user || '').split('@')[0] || 'Agent'))
const sendingReply = ref(false)
const replyToClient = ref(false) // case à cocher : OFF = note interne (défaut sûr) · ON = public + envoi au client
const EMAIL_RE = /.+@.+\..+/
// Ticket INITIÉ par le client ? (soumis par un courriel externe, OU le client a écrit dans le fil).
// → si oui, la case « Envoyer au client » est PRÉ-COCHÉE (mais tout envoi public exige une confirmation à la soumission).
//   Ticket interne (créé par un agent) → case décochée par défaut = note interne.
const clientInitiated = computed(() => {
  const me = (authStore.user || '').toLowerCase()
  const rb = String(props.doc?.raised_by || '').trim().toLowerCase()
  if (rb && EMAIL_RE.test(rb) && rb !== me) return true
  return (props.comms || []).some(c => String(c.sent_or_received || '') === 'Received')
})
const clientEmailHint = computed(() => { const rb = props.doc?.raised_by; return (rb && EMAIL_RE.test(rb)) ? rb : '' })
// Cale le défaut de la case à l'ouverture d'un ticket (client → coché · interne → décoché). Ne clobbe pas un basculement manuel en cours.
watch(() => props.docName, () => { replyToClient.value = clientInitiated.value }, { immediate: true })
const showCreateDialog = ref(false)
const showProjectWizard = ref(false)
const availReasonOpen = ref(false)
const bookedSkill = ref('') // compétence résolue par « Dispo par motif » → pré-remplit la tâche créée

const dispatchContext = computed(() => ({
  subject: props.doc?.subject || '',
  customer: props.doc?.customer || '',
  source_issue: props.doc?.name || '',
  service_location: props.doc?.service_location || '',
  issue_type: props.doc?.issue_type || '',
  priority: props.doc?.priority || 'medium',
  tags: bookedSkill.value ? [bookedSkill.value] : [], // compétence issue de « Dispo par motif » → compétence requise de la tâche
}))
// « Dispo par motif » a résolu une compétence → pré-remplit + ouvre la création de tâche liée à ce ticket.
function onAvailBook (b) { bookedSkill.value = b.primary || ''; showCreateDialog.value = true }

// ── Client lié + fil unifié ──

const custOptions = ref([])
const loadingCust = ref(false)
const linkingCust = ref(false)
const customerLabel = ref('')

function initials (name) {
  if (!name) return '?'
  const p = String(name).replace(/@.*/, '').split(/[\s._-]+/).filter(Boolean)
  return ((p[0]?.[0] || '') + (p[1]?.[0] || '')).toUpperCase() || '?'
}

// ── Visibilité PAR MESSAGE, surchargeable au clic (Interne privé ↔ Public client) ──
// Défaut par doctype : Communication = échange CLIENT (public) · Comment = note INTERNE (privé). Mais on peut
// BASCULER un message précis : la surcharge explicite est stockée au hub (overlay, clé = docname du message) car
// les Communication mêlent notes internes ET courriels → on ne peut PAS trancher au doctype seul. « Rendre public »
// = RELABEL uniquement, AUCUN courriel envoyé ; le client voit les messages publics via le portail/lien à jeton.
const msgPublic = ref({}) // surcharges explicites { [docname]: true|false }
const togglingMsg = ref('')
async function loadMsgVisibility () {
  try {
    const r = await fetch(`${HUB_URL}/conversations/msg-visibility`).then(x => x.json())
    msgPublic.value = (r && r.public) || {}
  } catch { /* hub indispo (aperçu) → on garde le défaut par doctype */ }
}
async function toggleMsgVisibility (m) {
  if (!m || !m.name || togglingMsg.value) return
  const next = !m.public
  togglingMsg.value = m.name
  try {
    const r = await fetch(`${HUB_URL}/conversations/msg-visibility`, {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name: m.name, public: next }),
    }).then(x => x.json())
    if (r && r.ok) {
      msgPublic.value = { ...msgPublic.value, [m.name]: next }
      Notify.create({ type: 'positive', icon: next ? 'visibility' : 'lock', message: next ? 'Rendu PUBLIC — visible par le client (aucun courriel envoyé)' : 'Repassé en INTERNE (privé)', timeout: 2200 })
    } else Notify.create({ type: 'negative', message: (r && r.error) || 'Échec', timeout: 2500 })
  } catch (e) { Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 2500 }) }
  finally { togglingMsg.value = '' }
}

// Fusionne échanges (Communication) + notes (Comment) en UN fil chronologique.
// Visibilité = surcharge explicite (msgPublic) SI présente, sinon défaut par doctype (Communication=public, Comment=interne).
const thread = computed(() => {
  const items = []
  const ov = msgPublic.value
  for (const c of props.comms || []) items.push({ id: 'e-' + c.name, name: c.name, email: c.sender || c.owner || '', who: c.sender_full_name || c.sender || c.owner || 'Système', when: c.creation, content: c.content, kind: 'comm', public: (c.name in ov) ? !!ov[c.name] : true })
  for (const c of props.comments || []) items.push({ id: 'c-' + c.name, name: c.name, email: c.comment_email || c.comment_by || '', who: c.comment_by || 'Système', when: c.creation, content: c.content, kind: 'note', public: (c.name in ov) ? !!ov[c.name] : false })
  return items.sort((a, b) => String(a.when || '').localeCompare(String(b.when || '')))
})

async function loadCustomerLabel () {
  customerLabel.value = ''
  if (!props.doc?.customer) return
  try {
    const rows = await listDocs('Customer', { filters: { name: props.doc.customer }, fields: ['customer_name'], limit: 1 })
    customerLabel.value = rows?.[0]?.customer_name || ''
  } catch { /* garde l'ID affiché */ }
}
watch(() => props.doc?.customer, loadCustomerLabel, { immediate: true })

function searchCustomers (val, update) {
  if (!val || val.length < 2) { update(() => { custOptions.value = [] }); return }
  update(async () => {
    loadingCust.value = true
    try {
      const rows = await listDocs('Customer', {
        filters: { customer_name: ['like', '%' + val + '%'] },
        fields: ['name', 'customer_name'], limit: 12, orderBy: 'customer_name asc',
      })
      custOptions.value = rows.map(r => ({ label: r.customer_name || r.name, value: r.name, sub: r.name }))
    } catch { custOptions.value = [] }
    loadingCust.value = false
  })
}

async function linkCustomer (name) {
  if (!name) return
  linkingCust.value = true
  try {
    await updateDoc('Issue', props.docName, { customer: name })
    props.doc.customer = name
    await loadCustomerLabel()
    Notify.create({ type: 'positive', message: 'Client lié', timeout: 1500 })
  } catch (e) { Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 }) }
  finally { linkingCust.value = false }
}

async function unlinkCustomer () {
  linkingCust.value = true
  try {
    await updateDoc('Issue', props.docName, { customer: '' })
    props.doc.customer = ''
    customerLabel.value = ''
    Notify.create({ type: 'positive', message: 'Client délié', timeout: 1500 })
  } catch (e) { Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 }) }
  finally { linkingCust.value = false }
}

// ── Assignees ──

const userOptions = ref([])
const loadingUsers = ref(false)
const assignees = ref([])
// Technicien(s) assigné(s) surfacé(s) EN TÊTE : tech des Dispatch Jobs liés (« assigné au job ») + staff legacy (ce que montrent les lignes de liste, absent du panneau jusqu'ici). Dédupliqué.
const assignedPeople = computed(() => {
  const seen = new Set(); const out = []
  const add = v => { const s = (v == null ? '' : String(v)).trim(); if (s && !seen.has(s.toLowerCase())) { seen.add(s.toLowerCase()); out.push(s) } }
  for (const j of (props.dispatchJobs || [])) add(j.assigned_tech)
  add(props.doc && props.doc.assigned_staff)
  return out
})

function parseAssign (raw) {
  if (!raw) return []
  try { return JSON.parse(raw).filter(Boolean) } catch { return [] }
}

watch(() => props.doc?._assign, (val) => {
  assignees.value = parseAssign(val)
}, { immediate: true })

async function loadUsers () {
  if (userOptions.value.length) return
  loadingUsers.value = true
  try {
    const users = await listDocs('User', {
      filters: { enabled: 1, user_type: 'System User' },
      fields: ['name', 'full_name'],
      limit: 100,
      orderBy: 'full_name asc',
    })
    userOptions.value = users.map(u => ({ label: u.full_name || u.name, value: u.name }))
  } catch { userOptions.value = [] }
  loadingUsers.value = false
}
// Identité unifiée : nom complet + fusion des comptes-alias d'une même personne.
const ident = useIdentity()
// Options SANS doublon d'identité : un seul choix par personne (les alias courriel sont fusionnés),
// libellé = nom canonique (identité) sinon full_name. Recalcule quand la carte d'identité est chargée.
const unifiedUserOptions = computed(() => {
  const seen = new Set(); const out = []
  for (const o of userOptions.value) {
    if (ident.activeOf(o.value) === false) continue // personne partie → non assignable (identité gardée pour l'historique)
    const k = ident.keyOf(o.value)
    if (seen.has(k)) continue
    seen.add(k)
    out.push({ label: ident.labelOf(o.value) || o.label, value: o.value })
  }
  return out
})
const displayName = (email) => ident.labelOf(email) || (userOptions.value.find(u => u.value === email) || {}).label || email

// ── Adaptateurs pour AssignmentField (champ multifonction partagé) ──
// Le ticket assigne des Users via frappe _assign : 1er = assigné (À), suivants = assistants (CC). Followers gérés par le composant (doctype Issue).
const issueAssignee = computed(() => {
  const e = assignees.value[0]; if (!e) return null
  return { id: e, name: displayName(e) }
})
const issueAssistants = computed(() => assignees.value.slice(1).map(e => ({ id: e, name: displayName(e) })))
async function assignUser (email) {
  const e = String(email || '').trim(); if (!e) return
  // Dé-dup par IDENTITÉ, pas par courriel : « m'ajouter » (louis@…) + recherche « Louis-Paul » (louispaul@…)
  // = la même personne → on n'ajoute pas de doublon (ni le même compte deux fois).
  const k = ident.keyOf(e)
  if (assignees.value.some(x => ident.keyOf(x) === k)) { Notify.create({ type: 'info', message: displayName(e) + ' est déjà sur ce ticket', timeout: 1800 }); return }
  const next = [...assignees.value, e]
  assignees.value = next
  await saveAssignees(next)
}
async function removeAssignee (email) {
  const e = String(email || '').trim(); if (!e) return
  const next = assignees.value.filter(x => x !== e)
  assignees.value = next
  await saveAssignees(next)
}
function unassignFirst () { const e = assignees.value[0]; if (e) return removeAssignee(e) }

async function saveAssignees (newList) {
  const old = parseAssign(props.doc._assign)
  const added = newList.filter(u => !old.includes(u))
  const removed = old.filter(u => !newList.includes(u))
  try {
    for (const user of added) {
      await authFetch(BASE_URL + '/api/method/frappe.desk.form.assign_to.add', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ doctype: 'Issue', name: props.docName, assign_to: [user] }),
      })
    }
    for (const user of removed) {
      await authFetch(BASE_URL + '/api/method/frappe.desk.form.assign_to.remove', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ doctype: 'Issue', name: props.docName, assign_to: user }),
      })
    }
    props.doc._assign = JSON.stringify(newList)
    Notify.create({ type: 'positive', message: 'Assignation mise à jour', timeout: 1500 })
  } catch (e) {
    Notify.create({ type: 'negative', message: 'Erreur assignation: ' + e.message, timeout: 3000 })
    assignees.value = old
  }
}

onMounted(() => { loadUsers(); loadTagCatalog(); loadMsgVisibility() })

// ── Intervention sur site / dispatch : modules montés pour la tâche dispatch PRINCIPALE du ticket ──
// Principale = une tâche avec coordonnées OU billet legacy (aspect terrain) ; sinon la 1re racine ; sinon la 1re.
const jobThreadRef = ref(null)
const primaryJob = computed(() => {
  const jobs = props.dispatchJobs || []
  if (!jobs.length) return null
  return jobs.find(j => (j.latitude && j.longitude) || j.legacy_ticket_id) || jobs.find(j => !j.parent_job) || jobs[0]
})
// ── Statut du ticket (bouton « Statut » : Complété/Annulé) + fulfillment de vente ──
const activating = ref(false)
// Tâche liée encore ouverte (installation à compléter) → cible du bouton « Activer + facture brouillon ».
const saleJob = computed(() => (props.dispatchJobs || []).find(j => !['Completed', 'Cancelled'].includes(j.status)) || null)
// Aperçu d'activation (lecture seule) : abonnements EN ATTENTE du client+lieu + total prorata consolidé (tous services).
// C'est LUI qui définit « quel ticket déclenche une activation » : on n'affiche le bouton que s'il y a du EN ATTENTE.
const actPreview = ref(null)
async function loadActivationPreview () {
  const cust = props.doc?.customer; const loc = props.doc?.service_location
  if (!cust) { actPreview.value = null; return }
  try { const p = await activationPreview({ customer: cust, service_location: loc }); actPreview.value = (p && (p.lines || []).length) ? p : null }
  catch (e) { actPreview.value = null }
}
onMounted(loadActivationPreview)
// Le bouton d'activation n'apparaît QUE si (installation liée ouverte) ET (abonnements en attente à activer).
const canActivate = computed(() => !!(saleJob.value && actPreview.value))
async function onSetStatus (status) {
  try {
    await updateDoc('Issue', props.docName, { status })
    props.doc.status = status
    Notify.create({ type: 'positive', message: 'Statut : ' + status, timeout: 1400 })
    emit('reply-sent', props.docName)
    // « Complété » sur une vente avec une installation à compléter → propose l'activation (pas d'auto : l'agent confirme).
    if (status === 'Resolved' && canActivate.value) setTimeout(activateSale, 300)
  } catch (e) { Notify.create({ type: 'negative', message: 'Statut : ' + (e.message || e) }) }
}
// Réutilise le flux hub EXISTANT : compléter le job lié → déblocage chaîne + activation abonnement + facture prorata BROUILLON
// (gaté PRORATION_WRITE / BILLING_APPROVAL_GATE). N'AUTO-FACTURE jamais : draft à approuver par la facturation.
function activateSale () {
  const j = saleJob.value; if (!j || activating.value) return
  const p = actPreview.value || {}
  const lines = (p.lines || []).map(l => `• ${l.label}${l.amount != null ? ' — ' + l.amount + '$' : ''}`).join('<br>')
  Dialog.create({
    title: 'Activer la vente',
    message: `Compléter l'installation « ${j.subject || j.name} » et activer <b>${(p.lines || []).length} service(s)</b> en attente ?<br><br>${lines}<br><br><b>Facture prorata consolidée</b>${p.prorated_total != null ? ' ≈ ' + p.prorated_total + '$' : ''}${p.billed_days ? ' (' + p.billed_days + '/' + p.period_days + ' j)' : ''} → créée en <b>BROUILLON</b> (à éditer/approuver par la facturation) si la facturation est armée ; sinon activation sans facture.`,
    html: true, cancel: 'Annuler', ok: { label: 'Activer', color: 'deep-purple-6' },
  }).onOk(async () => {
    activating.value = true
    try {
      const r = await setJobStatusChain(j.name, 'Completed')
      const acts = (r.activated || []).length; const inv = (r.invoices || [])[0]
      let msg
      if (r.pending_approval) msg = 'Activation placée EN ATTENTE D\'APPROBATION (facturation)'
      else if (inv) msg = acts + ' abonnement(s) activé(s) · facture brouillon ' + inv.name
      else if (acts) msg = acts + ' abonnement(s) activé(s) · facture non émise (écriture désarmée)'
      else msg = 'Installation complétée — aucun abonnement en attente à activer'
      Notify.create({ type: 'positive', message: msg, timeout: 6000 })
      j.status = 'Completed'; actPreview.value = null; emit('dispatch-updated', j.name)
    } catch (e) { Notify.create({ type: 'negative', message: 'Activation : ' + (e.message || e), timeout: 5000 }) } finally { activating.value = false }
  })
}
// Signaux « terrain » → affichage AUTO de la section ; sinon bascule manuelle (« nécessite une visite sur place »).
const onsiteAuto = computed(() => {
  const j = primaryJob.value; if (!j) return false
  const la = +j.latitude, lo = +j.longitude
  return (isFinite(la) && isFinite(lo) && (la || lo)) || !!j.legacy_ticket_id
})
const primaryJobIso = computed(() => String((primaryJob.value && primaryJob.value.scheduled_date) || '').slice(0, 10))
const todayIso = computed(() => new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }))
const jobCanTrack = computed(() => {
  const iso = primaryJobIso.value; if (!iso) return false
  const n = todayIso.value
  const yest = new Date(Date.parse(n + 'T12:00:00Z') - 86400000).toISOString().slice(0, 10)
  return iso === n || iso === yest
})
const primaryJobSkills = computed(() => { const s = primaryJob.value && primaryJob.value.required_skill; return s ? [s] : [] })

// Équipe / assignation de la tâche dispatch depuis le ticket. Techs chargés à la demande (quand la section s'ouvre).
const jobTechs = ref([])
const jobTechOptions = computed(() => (jobTechs.value || []).map(t => ({ label: t.name + ((t.skills || []).length ? ' · ' + t.skills.slice(0, 4).join(', ') : ''), value: t.id })))
const primaryJobTechName = computed(() => { const id = primaryJob.value && primaryJob.value.assigned_tech; if (!id) return ''; const t = (jobTechs.value || []).find(x => x.id === id); return (t && t.name) || String(id) })
async function loadJobTechs () {
  if (jobTechs.value.length) return
  try { const r = await roster.listTechnicians(); jobTechs.value = Array.isArray(r) ? r : ((r && r.technicians) || []) } catch { jobTechs.value = [] }
}
// Lead : chemin roster SIMPLE (assignJob / unassign) — pas la pipeline board (assignNames/sibling-grouping) qui vit dans Planification.
async function onJobAssignLead (techId) {
  const j = primaryJob.value; if (!techId || !j || !j.name) return
  try {
    await roster.assignJob(j.name, techId, primaryJobIso.value || undefined)
    j.assigned_tech = techId // objet réactif (issu de useDetailModal) → met à jour la bannière + le module
    const t = (jobTechs.value || []).find(x => x.id === techId)
    Notify.create({ type: 'positive', message: 'Assigné à ' + ((t && t.name) || techId), timeout: 1800 })
    emit('dispatch-updated', j.name, { assigned_tech: techId })
  } catch (e) { Notify.create({ type: 'negative', message: 'Erreur assignation: ' + e.message, timeout: 3000 }) }
}
async function onJobUnassignLead () {
  const j = primaryJob.value; if (!j || !j.name) return
  try {
    await roster.unassignJobRoster(j.name); j.assigned_tech = ''
    Notify.create({ type: 'info', message: 'Renvoyé au pool (non assigné)', timeout: 1600 })
    emit('dispatch-updated', j.name, { assigned_tech: '' })
  } catch (e) { Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 }) }
}
// Bascule persistée par ticket (localStorage — pas de champ ERPNext requis). Défaut = auto-détection.
// ⚠️ showOnsite DOIT être déclaré AVANT tout watch qui le référence (sinon ReferenceError TDZ au setup → composant blanc).
const showOnsite = ref(false)
watch(showOnsite, v => { if (v) loadJobTechs() })
const onsiteKey = () => 'ops-ticket-onsite-' + (props.docName || '')
watch([primaryJob, () => props.docName], () => {
  let stored = null
  try { stored = localStorage.getItem(onsiteKey()) } catch { /* localStorage indisponible */ }
  showOnsite.value = stored != null ? stored === '1' : onsiteAuto.value
}, { immediate: true })
watch(showOnsite, v => { try { localStorage.setItem(onsiteKey(), v ? '1' : '0') } catch { /* noop */ } })

// ── Tags — catalogue UNIFIÉ (Dispatch Tag ∪ compétences roster) via TagEditor ──
// Le catalogue et son CRUD vivent dans useTagCatalog (source unique, réutilisable).
const { allTags, dispatchTags, getColor: getTagColor, load: loadTagCatalog, createTag: createCatTag, recolorTag, renameTag: renameCatTag, removeTag: removeCatTag } = useTagCatalog()

function parseTags (raw) {
  if (!raw) return []
  return raw.split(',').map(t => t.trim()).filter(Boolean)
}

const currentTags = computed(() => parseTags(props.doc?._user_tags))

// Applique/retire une étiquette sur CE ticket (frappe add_tag/remove_tag → Issue._user_tags).
async function tagIssue (label) {
  await authFetch(BASE_URL + '/api/method/frappe.desk.doctype.tag.tag.add_tag', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ tag: label, dt: 'Issue', dn: props.docName }),
  })
  const cur = parseTags(props.doc._user_tags)
  if (!cur.includes(label)) cur.push(label)
  props.doc._user_tags = cur.join(',')
}
async function untagIssue (label) {
  await authFetch(BASE_URL + '/api/method/frappe.desk.doctype.tag.tag.remove_tag', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ tag: label, dt: 'Issue', dn: props.docName }),
  })
  props.doc._user_tags = parseTags(props.doc._user_tags).filter(t => t !== label).join(',')
}

// TagEditor émet la NOUVELLE liste complète de libellés → on diff avec l'actuelle.
function onTagsChanged (newLabels) {
  const cur = currentTags.value
  const added = newLabels.filter(l => !cur.includes(l))
  const removed = cur.filter(l => !newLabels.includes(l))
  for (const l of added) tagIssue(l).catch(e => Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 }))
  for (const l of removed) untagIssue(l).catch(e => Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 }))
}

// Création à la volée : matérialise le tag dans Dispatch Tag (l'application au ticket
// arrive séparément via l'émission update:modelValue de TagEditor → onTagsChanged).
async function onCreateTag ({ label, color }) {
  try { await createCatTag(label, color) } catch (e) { Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 }) }
}

// ── Tag Manager (CRUD Dispatch Tag, délégué à useTagCatalog) ──

const showTagManager = ref(false)
const editingTagName = ref(null)
const editingTagValue = ref('')

const TAG_COLORS = [
  '#6366f1', '#3b82f6', '#06b6d4', '#10b981', '#14b8a6',
  '#f59e0b', '#f97316', '#f43f5e', '#d946ef', '#8b5cf6',
  '#78716c', '#64748b',
]

function startEditTag (t) {
  editingTagName.value = t.name
  editingTagValue.value = t.label
}

async function saveTagRename (t) {
  const newLabel = editingTagValue.value.trim()
  editingTagName.value = null
  if (!newLabel || newLabel === t.label) return
  try {
    await renameCatTag(t, newLabel)
    Notify.create({ type: 'positive', message: `Tag renommé → "${newLabel}"`, timeout: 1500 })
  } catch (e) {
    Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
  }
}

async function cycleTagColor (t) {
  const idx = TAG_COLORS.indexOf(t.color)
  const next = TAG_COLORS[(idx + 1) % TAG_COLORS.length]
  try {
    await recolorTag(t, next)
  } catch (e) {
    Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
  }
}

async function deleteDispatchTag (t) {
  Dialog.create({
    title: 'Supprimer le tag ?',
    message: `Le tag "${t.label}" sera retiré de tous les éléments.`,
    cancel: { flat: true, label: 'Annuler' },
    ok: { color: 'red', label: 'Supprimer', unelevated: true },
  }).onOk(async () => {
    try {
      await removeCatTag(t)
      Notify.create({ type: 'positive', message: `Tag "${t.label}" supprimé`, timeout: 1500 })
    } catch (e) {
      Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
    }
  })
}

// ── Delete ──

function confirmDelete () {
  Dialog.create({
    title: 'Supprimer ce ticket ?',
    message: `Le ticket ${props.docName} sera supprimé définitivement.`,
    cancel: { flat: true, label: 'Annuler' },
    ok: { color: 'red', label: 'Supprimer', unelevated: true },
    persistent: true,
  }).onOk(async () => {
    try {
      await deleteDoc('Issue', props.docName)
      Notify.create({ type: 'positive', message: 'Ticket supprimé', timeout: 2000 })
      emit('deleted', props.docName)
    } catch (e) {
      Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
    }
  })
}

// ── Nested task tree ──

const rootJobs = computed(() => {
  // Jobs with no parent_job, or whose parent_job is not in this ticket's jobs
  const jobNames = new Set(props.dispatchJobs.map(j => j.name))
  return props.dispatchJobs.filter(j => !j.parent_job || !jobNames.has(j.parent_job))
    .sort((a, b) => (a.step_order || 0) - (b.step_order || 0))
})

const orphanJobs = computed(() => {
  // Jobs whose parent is in the list but weren't caught as roots or children
  // This handles edge cases
  const rootAndChildNames = new Set()
  function collectNames (jobs) {
    for (const j of jobs) {
      rootAndChildNames.add(j.name)
    }
  }
  collectNames(rootJobs.value)
  // Recursively collect all children
  function collectChildren (parentName) {
    const children = props.dispatchJobs.filter(j => j.parent_job === parentName)
    for (const c of children) {
      rootAndChildNames.add(c.name)
      collectChildren(c.name)
    }
  }
  for (const r of rootJobs.value) collectChildren(r.name)
  return props.dispatchJobs.filter(j => !rootAndChildNames.has(j.name))
})

// ── n8n webhook firing ──

async function fireWebhook (url, job) {
  if (!url) return
  try {
    await authFetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        event: url.includes('close') ? 'job_closed' : 'job_opened',
        job_name: job.name,
        job_subject: job.subject,
        job_status: job.status,
        job_type: job.job_type,
        ticket: props.doc?.name,
        customer: props.doc?.customer,
        timestamp: new Date().toISOString(),
      }),
    })
  } catch (err) {
    console.warn('[n8n webhook] fire failed:', err)
  }
}

function onJobCreated (job) {
  emit('dispatch-created', job)
}

function onJobDeleted (jobName) {
  emit('dispatch-deleted', jobName)
}

function onJobUpdated (jobName, data) {
  emit('dispatch-updated', jobName, data)
}

// Fermer/Rouvrir/Reporter : gérés par le composant partagé TicketStatusControl.

// ── Reply ──

// Courriel du client (destinataire de « Envoyer au client ») : raised_by si c'est un courriel, sinon email_id du compte.
async function resolveClientEmail () {
  const rb = props.doc?.raised_by
  if (rb && EMAIL_RE.test(rb)) return rb
  if (props.doc?.customer) {
    try {
      const rows = await listDocs('Customer', { filters: { name: props.doc.customer }, fields: ['email_id'], limit: 1 })
      const e = rows?.[0]?.email_id
      if (e && EMAIL_RE.test(e)) return e
    } catch { /* pas d'email → visible au portail seulement */ }
  }
  return ''
}

// Soumission : une note INTERNE part directement ; un envoi PUBLIC (au client) demande une CONFIRMATION explicite
// (garde-fou, surtout quand la case est pré-cochée pour un ticket initié par le client).
function sendReply () {
  if (!replyContent.value?.trim() || sendingReply.value) return
  if (!replyToClient.value) { doSendReply(false); return }
  Dialog.create({
    title: 'Répondre au client ?',
    message: `Ce message sera <b>visible par le client</b> et lui sera envoyé par courriel${clientEmailHint.value ? ' (' + clientEmailHint.value + ')' : ''}. Pour une note interne, décochez « Envoyer au client ».`,
    html: true,
    cancel: { flat: true, label: 'Annuler' },
    ok: { color: 'deep-orange-9', unelevated: true, label: 'Envoyer au client', icon: 'send' },
  }).onOk(() => doSendReply(true))
}

// UNE seule action. Défaut (case décochée) = NOTE INTERNE (privée). Cochée = PUBLIC + courriel au client.
// La visibilité est écrite EXPLICITEMENT dans l'overlay (interne=false / public=true) → corrige le bug « note interne
// affichée Client » (le défaut par doctype classait toute Communication en public).
async function doSendReply (isPublic) {
  if (!replyContent.value?.trim() || sendingReply.value) return
  const text = replyContent.value.trim()
  sendingReply.value = true
  try {
    const created = await createDoc('Communication', {
      communication_type: 'Communication',
      communication_medium: 'Other',
      sent_or_received: 'Sent',
      subject: props.title || props.docName,
      content: text,
      sender: authStore.user || '',
      sender_full_name: senderFull.value, // nom réel de l'agent (jamais le courriel brut)
      reference_doctype: 'Issue',
      reference_name: props.docName,
    })
    const name = created && (created.name || (created.data && created.data.name))
    // Visibilité explicite (source unique = overlay hub) → rend correctement Interne/Client dès le rechargement.
    if (name) {
      try {
        await fetch(`${HUB_URL}/conversations/msg-visibility`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, public: isPublic }) })
        msgPublic.value = { ...msgPublic.value, [name]: isPublic }
      } catch { /* best-effort */ }
    }
    // Public → envoi au client (lien vers le ticket ; sa réponse revient au fil si l'ingestion courriel est active).
    let emailed = false
    if (isPublic) {
      const to = await resolveClientEmail()
      if (to) {
        try {
          const res = await fetch(`${HUB_URL}/conversations/ticket-email`, {
            method: 'POST', headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ issue: props.docName, to, message: text, link: window.location.href, agentName: senderFull.value }),
          })
          const d = await res.json(); emailed = !!(d && d.ok)
        } catch { /* le message reste public/visible même si le courriel échoue */ }
      }
    }
    replyContent.value = ''
    replyToClient.value = clientInitiated.value // retour au défaut selon l'origine (client → coché · interne → décoché)
    emit('reply-sent', props.docName)
    Notify.create({
      type: 'positive',
      icon: isPublic ? (emailed ? 'send' : 'visibility') : 'lock',
      message: isPublic
        ? (emailed ? 'Envoyé au client (courriel + fil du ticket)' : 'Publié — visible par le client au portail (aucune adresse courriel au dossier)')
        : 'Note interne ajoutée (privée)',
      timeout: 2800,
    })
  } catch {
    Notify.create({ type: 'negative', message: 'Erreur : réponse non enregistrée', timeout: 3000 })
  } finally {
    sendingReply.value = false
  }
}
</script>

<style scoped>
.issue-actions {
  display: flex;
  gap: 8px;
}
.issue-field-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 0 16px;
}
.if-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 5px 0;
  font-size: 0.875rem;
  border-bottom: 1px solid #f1f5f9;
  min-height: 32px;
}
.if-label {
  font-size: 0.75rem;
  font-weight: 600;
  color: #6b7280;
  width: 80px;
  flex-shrink: 0;
}
.if-value {
  font-size: 0.875rem;
  color: #1f2937;
}
.dispatch-section {
  background: #f8fafc;
  border: 1px solid #e2e8f0;
  border-radius: 8px;
  padding: 10px 12px;
}
.dispatch-jobs-list {
  display: flex;
  flex-direction: column;
  gap: 4px;
}
.tag-manager {
  background: #f8fafc;
  border: 1px solid #e2e8f0;
  border-radius: 8px;
  padding: 8px 10px;
  max-height: 240px;
  overflow-y: auto;
}
.tag-mgr-row {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 3px 0;
  border-bottom: 1px solid #f1f5f9;
}
.tag-mgr-row:last-child { border-bottom: none; }
.tag-mgr-dot {
  width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; cursor: pointer;
  transition: transform 0.1s;
}
.tag-mgr-dot:hover { transform: scale(1.3); }
.tag-mgr-label {
  flex: 1; font-size: 0.8rem; cursor: pointer; min-width: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.tag-mgr-label:hover { text-decoration: underline dotted; }
.tag-mgr-edit {
  flex: 1; font-size: 0.8rem; border: 1px solid #cbd5e1; border-radius: 4px;
  padding: 2px 6px; outline: none; min-width: 0;
}
.tag-mgr-edit:focus { border-color: #6366f1; }
.tag-mgr-cat { flex-shrink: 0; font-size: 0.65rem; }
.tag-mgr-del { opacity: 0.3; transition: opacity 0.15s; }
.tag-mgr-row:hover .tag-mgr-del { opacity: 1; }

/* Client lié */
.cust-banner {
  display: flex; align-items: center; gap: 8px;
  background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 8px;
  padding: 5px 12px; min-height: 40px;
}
.cust-name { font-weight: 600; font-size: 0.9rem; color: #166534; cursor: pointer; }
.cust-name:hover { text-decoration: underline; }
.tech-banner { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; }
.tech-banner-lbl { font-size: 12px; font-weight: 600; color: #607d8b; }

/* Fil unifié en bulles */
.thread-wrap { display: flex; flex-direction: column; gap: 10px; }
.msg-bubble { border: 1px solid #e5e7eb; border-radius: 10px; padding: 8px 12px; background: #fff; }
.msg-bubble.msg-note { background: #fffbeb; border-color: #fde68a; } /* notes internes = ambre */
.msg-head { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; }
.msg-avatar {
  width: 22px; height: 22px; border-radius: 50%; background: #e0e7ff; color: #4338ca;
  font-size: 0.62rem; font-weight: 700; display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.msg-who { font-size: 0.8rem; font-weight: 600; color: #374151; }
/* Badge visibilité par message (public/client vs interne/privé) */
.msg-vis { display: inline-flex; align-items: center; gap: 2px; font-size: 0.62rem; font-weight: 700; padding: 1px 6px; border-radius: 9px; letter-spacing: .02em; cursor: pointer; user-select: none; transition: filter .12s, box-shadow .12s; }
.msg-vis:hover { filter: brightness(0.96); box-shadow: 0 0 0 1px rgba(0,0,0,.08) inset; }
.msg-vis.vis-busy { opacity: .6; pointer-events: none; }
.msg-vis.vis-int { background: #fef3c7; color: #92400e; }      /* interne = ambre (privé) */
.msg-vis.vis-pub { background: #dbeafe; color: #1e40af; }      /* client = bleu (public) */
.msg-when { font-size: 0.7rem; color: #9ca3af; margin-left: auto; white-space: nowrap; }
/* Avertissement de visibilité au bas du ticket (avant envoi au client) */
.reply-warn { display: flex; align-items: flex-start; gap: 6px; margin-top: 8px; padding: 7px 10px; font-size: 0.75rem; line-height: 1.35; color: #7c2d12; background: #fff7ed; border: 1px solid #fed7aa; border-radius: 8px; }
.reply-warn .q-icon { color: #ea580c; flex-shrink: 0; margin-top: 1px; }
.reply-cb-lbl { display: inline-flex; align-items: center; gap: 4px; font-size: 0.78rem; font-weight: 500; color: #475569; }
.reply-cb-lbl.reply-cb-pub { color: #9a3412; font-weight: 600; }
.msg-body { font-size: 0.85rem; color: #1f2937; line-height: 1.5; word-break: break-word; }
.msg-body :deep(img) { max-width: 100%; height: auto; }
</style>
