// CI touch: enable server-side assignee filtering and trigger redeploy
import { mutation, query } from "./_generated/server";
import { paginationOptsValidator } from "convex/server";
import { api } from "./_generated/api";
import type { MutationCtx, QueryCtx } from "./_generated/server";
import { ConvexError, v } from "convex/values";
import { Id, type Doc, type TableNames } from "./_generated/dataModel";

import { requireAdmin, requireStaff, requireUser } from "./rbac";
import { runTicketAutomationsForEvent } from "./automations";
import {
  OPTIONAL_ADMISSION_FIELD_KEYS,
  TICKET_FORM_CONFIG,
  TICKET_FORM_DEFAULT_FIELDS,
  type TicketFormFieldSeed,
} from "./ticketForms.config";
import {
  ensureTicketFormTemplatesForTenant,
  getTemplateByKey,
  normalizeFormTemplateKey,
} from "./ticketFormTemplates";
import {
  applyChecklistTemplateToItems,
  checklistBlocksResolution,
  normalizeChecklistText,
  type TicketChecklistItem,
} from "./ticketChecklist";

const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT"]);
const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT"]);
const LUNCH_BREAK_REASON = "LUNCH_BREAK";
const LUNCH_BREAK_NOTE = "Pausa automática do intervalo de almoço";
const LUNCH_BREAK_PAUSE_LABEL = "Intervalo de almoço";
const LUNCH_BREAK_TIMEZONE = "America/Sao_Paulo";
const LUNCH_BREAK_HOUR = 12;

const PAUSE_REASON_LABELS: Record<string, string> = {
  NO_CONTACT: "Falta de contato",
  WAITING_THIRD_PARTY: "Aguardando terceiro",
  IN_PROCEDURE: "Em procedimento",
  [LUNCH_BREAK_REASON]: LUNCH_BREAK_PAUSE_LABEL,
};
const DATE_ONLY_REGEX = /^\d{4}-\d{2}-\d{2}$/;

type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED";

const STATUS_LABELS: Record<TicketStatusNormalized, string> = {
  PENDING: "Pendente",
  AWAITING_ATTENDANCE: "Em andamento",
  PAUSED: "Pausado",
  RESOLVED: "Resolvido",
};

const LEGACY_STATUS_MAP: Record<string, TicketStatusNormalized> = {
  NEW: "PENDING",
  PENDING: "PENDING",
  OPEN: "AWAITING_ATTENDANCE",
  AWAITING_ATTENDANCE: "AWAITING_ATTENDANCE",
  ON_HOLD: "PAUSED",
  PAUSED: "PAUSED",
  RESOLVED: "RESOLVED",
  CLOSED: "RESOLVED",
};

function normalizePriorityFilter(input: string | string[] | null | undefined): string[] {
  if (!input) return [];
  const list = Array.isArray(input) ? input : [input];
  const set = new Set<string>();
  for (const entry of list) {
    if (typeof entry !== "string") continue;
    const normalized = entry.trim().toUpperCase();
    if (!normalized) continue;
    set.add(normalized);
  }
  return Array.from(set);
}

const missingRequesterLogCache = new Set<string>();
const missingCommentAuthorLogCache = new Set<string>();

// Character limits (generous but bounded)
const MAX_SUMMARY_CHARS = 600;
const MAX_COMMENT_CHARS = 20000;
const DEFAULT_REOPEN_DAYS = 7;
const MAX_REOPEN_DAYS = 14;
const VISIT_QUEUE_KEYWORDS = ["visita", "visitas", "in loco", "laboratório", "laboratorio", "lab"];
const VISIT_STATUSES = new Set(["scheduled", "en_route", "in_service", "done", "no_show", "canceled"]);
const VISIT_COMPLETED_STATUSES = new Set(["done", "no_show", "canceled"]);

type AnyCtx = QueryCtx | MutationCtx;

// Tipos para eventos de chat
type ChatEventPayload = {
  sessionId?: string;
  agentId?: string;
  agentName?: string;
  machineHostname?: string;
  durationMs?: number;
  startedAt?: number;
  endedAt?: number;
  autoEnded?: boolean;
  reason?: string;
};

type TimelineEvent = {
  _id: Id<"ticketEvents">;
  type: string;
  payload?: unknown;
  createdAt: number;
};

/**
 * Consolida eventos de chat ao vivo na timeline.
 * Em vez de mostrar "Chat iniciado" e "Chat finalizado" separadamente,
 * mostra um unico evento "LIVE_CHAT_SUMMARY" com a duracao total.
 */
function consolidateChatEventsInTimeline(events: TimelineEvent[]): TimelineEvent[] {
  const chatStartEvents: TimelineEvent[] = [];
  const chatEndEvents: TimelineEvent[] = [];
  const otherEvents: TimelineEvent[] = [];

  // Separar eventos de chat dos demais
  for (const event of events) {
    if (event.type === "LIVE_CHAT_STARTED") {
      chatStartEvents.push(event);
    } else if (event.type === "LIVE_CHAT_ENDED") {
      chatEndEvents.push(event);
    } else {
      otherEvents.push(event);
    }
  }

  // Se nao houver eventos de chat, retornar como esta
  if (chatStartEvents.length === 0 && chatEndEvents.length === 0) {
    return events;
  }

  // Calcular duracao total de todas as sessoes encerradas
  let totalDurationMs = 0;
  const sessionIds = new Set<string>();
  let lastAgentName = "";
  let mostRecentActivity = 0;
  let hasActiveSession = false;

  for (const endEvent of chatEndEvents) {
    const payload = endEvent.payload as ChatEventPayload | undefined;
    if (payload?.durationMs) {
      totalDurationMs += payload.durationMs;
    }
    if (payload?.sessionId) {
      sessionIds.add(payload.sessionId);
    }
    if (payload?.agentName) {
      lastAgentName = payload.agentName;
    }
    if (endEvent.createdAt > mostRecentActivity) {
      mostRecentActivity = endEvent.createdAt;
    }
  }

  // Verificar sessoes iniciadas mas nao encerradas (ativas)
  const endedSessionIds = new Set(
    chatEndEvents
      .map((e) => (e.payload as ChatEventPayload | undefined)?.sessionId)
      .filter(Boolean)
  );

  for (const startEvent of chatStartEvents) {
    const payload = startEvent.payload as ChatEventPayload | undefined;
    const sessionId = payload?.sessionId;
    if (sessionId && !endedSessionIds.has(sessionId)) {
      hasActiveSession = true;
      sessionIds.add(sessionId);
    }
    if (payload?.agentName && !lastAgentName) {
      lastAgentName = payload.agentName;
    }
    if (startEvent.createdAt > mostRecentActivity) {
      mostRecentActivity = startEvent.createdAt;
    }
  }

  // Se so tem sessao iniciada sem encerrar, mostrar o evento de inicio
  if (chatEndEvents.length === 0 && chatStartEvents.length > 0) {
    // Retornar o evento de inicio mais recente
    const latestStart = chatStartEvents.reduce((a, b) =>
      a.createdAt > b.createdAt ? a : b
    );
    return [...otherEvents, latestStart];
  }

  // Criar evento consolidado
  const sessionCount = sessionIds.size || chatEndEvents.length;
  const consolidatedEvent: TimelineEvent = {
    _id: chatEndEvents[0]._id, // Usar ID do primeiro evento para manter referencia
    type: "LIVE_CHAT_SUMMARY",
    payload: {
      sessionCount,
      totalDurationMs,
      agentName: lastAgentName || "Agente",
      hasActiveSession,
    },
    createdAt: mostRecentActivity,
  };

  return [...otherEvents, consolidatedEvent];
}

type TemplateSummary = {
  key: string;
  label: string;
  description: string;
  defaultEnabled: boolean;
};

type TicketFieldScopeMap = Map<string, Doc<"ticketFields">[]>;

function plainTextLength(html: string): number {
  try {
    const text = String(html)
      .replace(/<[^>]*>/g, "") // strip tags
      .replace(/&nbsp;/g, " ")
      .trim();
    return text.length;
  } catch {
    return String(html ?? "").length;
  }
}

const SLA_DEFAULT_ALERT_THRESHOLD = 0.8;
const BUSINESS_DAY_START_HOUR = 8;
const BUSINESS_DAY_END_HOUR = 18;

type SlaTimeMode = "business" | "calendar";

type TicketSlaSnapshot = {
  categoryId?: Id<"ticketCategories">;
  categoryName?: string;
  priority: string;
  responseTargetMinutes?: number;
  responseMode: SlaTimeMode;
  solutionTargetMinutes?: number;
  solutionMode: SlaTimeMode;
  alertThreshold: number;
  pauseStatuses: TicketStatusNormalized[];
};

type SlaStatusValue = "pending" | "met" | "breached" | "n/a";

function normalizeSlaMode(input?: string | null): SlaTimeMode {
  if (!input) return "calendar";
  return input.toLowerCase() === "business" ? "business" : "calendar";
}

function normalizeSnapshotPauseStatuses(statuses?: string[] | null): TicketStatusNormalized[] {
  if (!Array.isArray(statuses)) {
    return ["PAUSED"];
  }
  const set = new Set<TicketStatusNormalized>();
  for (const value of statuses) {
    if (typeof value !== "string") continue;
    const normalized = normalizeStatus(value);
    set.add(normalized);
  }
  if (set.size === 0) {
    set.add("PAUSED");
  }
  return Array.from(set);
}

async function resolveTicketSlaSnapshot(
  ctx: AnyCtx,
  tenantId: string,
  category: Doc<"ticketCategories"> | null,
  priority: string
): Promise<TicketSlaSnapshot | null> {
  if (!category) {
    return null;
  }
  const normalizedPriority = priority.trim().toUpperCase();
  const rule =
    (await ctx.db
      .query("categorySlaSettings")
      .withIndex("by_tenant_category_priority", (q) =>
        q.eq("tenantId", tenantId).eq("categoryId", category._id).eq("priority", normalizedPriority)
      )
      .first()) ??
    (await ctx.db
      .query("categorySlaSettings")
      .withIndex("by_tenant_category_priority", (q) =>
        q.eq("tenantId", tenantId).eq("categoryId", category._id).eq("priority", "DEFAULT")
      )
      .first());
  if (!rule) {
    return null;
  }

  return {
    categoryId: category._id,
    categoryName: category.name,
    priority: normalizedPriority,
    responseTargetMinutes: rule.responseTargetMinutes ?? undefined,
    responseMode: normalizeSlaMode(rule.responseMode),
    solutionTargetMinutes: rule.solutionTargetMinutes ?? undefined,
    solutionMode: normalizeSlaMode(rule.solutionMode),
    alertThreshold:
      typeof rule.alertThreshold === "number" && Number.isFinite(rule.alertThreshold)
        ? rule.alertThreshold
        : SLA_DEFAULT_ALERT_THRESHOLD,
    pauseStatuses: normalizeSnapshotPauseStatuses(rule.pauseStatuses),
  };
}

function computeSlaDueDates(snapshot: TicketSlaSnapshot, startAt: number) {
  return {
    responseDueAt: addMinutesWithMode(startAt, snapshot.responseTargetMinutes, snapshot.responseMode),
    solutionDueAt: addMinutesWithMode(startAt, snapshot.solutionTargetMinutes, snapshot.solutionMode),
  };
}

function addMinutesWithMode(startAt: number, minutes: number | null | undefined, mode: SlaTimeMode): number | null {
  if (minutes === null || minutes === undefined || minutes <= 0) {
    return null;
  }
  if (mode === "calendar") {
    return startAt + minutes * 60000;
  }

  let remaining = minutes;
  let cursor = alignToBusinessStart(new Date(startAt));

  while (remaining > 0) {
    if (!isBusinessDay(cursor)) {
      cursor = advanceToNextBusinessStart(cursor);
      continue;
    }
    const endOfDay = new Date(cursor);
    endOfDay.setHours(BUSINESS_DAY_END_HOUR, 0, 0, 0);
    const minutesAvailable = (endOfDay.getTime() - cursor.getTime()) / 60000;
    if (minutesAvailable >= remaining) {
      cursor = new Date(cursor.getTime() + remaining * 60000);
      remaining = 0;
    } else {
      remaining -= minutesAvailable;
      cursor = advanceToNextBusinessStart(endOfDay);
    }
  }

  return cursor.getTime();
}

function alignToBusinessStart(date: Date): Date {
  let result = new Date(date);
  if (!isBusinessDay(result)) {
    return advanceToNextBusinessStart(result);
  }
  if (result.getHours() >= BUSINESS_DAY_END_HOUR) {
    return advanceToNextBusinessStart(result);
  }
  if (result.getHours() < BUSINESS_DAY_START_HOUR) {
    result.setHours(BUSINESS_DAY_START_HOUR, 0, 0, 0);
  }
  return result;
}

function advanceToNextBusinessStart(date: Date): Date {
  const next = new Date(date);
  next.setHours(BUSINESS_DAY_START_HOUR, 0, 0, 0);
  next.setDate(next.getDate() + 1);
  while (!isBusinessDay(next)) {
    next.setDate(next.getDate() + 1);
  }
  return next;
}

function isBusinessDay(date: Date) {
  const day = date.getDay();
  return day !== 0 && day !== 6;
}

function applySlaSnapshot(snapshot: TicketSlaSnapshot | null, now: number) {
  if (!snapshot) return {};
  const { responseDueAt, solutionDueAt } = computeSlaDueDates(snapshot, now);
  return {
    slaSnapshot: snapshot,
    slaResponseDueAt: responseDueAt ?? undefined,
    slaSolutionDueAt: solutionDueAt ?? undefined,
    slaResponseStatus: responseDueAt ? ("pending" as SlaStatusValue) : ("n/a" as SlaStatusValue),
    slaSolutionStatus: solutionDueAt ? ("pending" as SlaStatusValue) : ("n/a" as SlaStatusValue),
    dueAt: solutionDueAt ?? undefined,
  };
}

function buildSlaStatusPatch(ticketDoc: Doc<"tickets">, nextStatus: TicketStatusNormalized, now: number) {
  const snapshot = ticketDoc.slaSnapshot as TicketSlaSnapshot | undefined;
  if (!snapshot) return {};
  const pauseSet = new Set(snapshot.pauseStatuses);
  const currentlyPaused = typeof ticketDoc.slaPausedAt === "number";

  if (pauseSet.has(nextStatus)) {
    if (currentlyPaused) {
      return {};
    }
    return {
      slaPausedAt: now,
      slaPausedBy: nextStatus,
    };
  }

  if (currentlyPaused) {
    const pauseStart = ticketDoc.slaPausedAt ?? now;
    const delta = Math.max(0, now - pauseStart);
    const patch: Record<string, unknown> = {
      slaPausedAt: undefined,
      slaPausedBy: undefined,
      slaPausedMs: (ticketDoc.slaPausedMs ?? 0) + delta,
    };
    if (ticketDoc.slaResponseDueAt && ticketDoc.slaResponseStatus !== "met" && ticketDoc.slaResponseStatus !== "breached") {
      patch.slaResponseDueAt = ticketDoc.slaResponseDueAt + delta;
    }
    if (ticketDoc.slaSolutionDueAt && ticketDoc.slaSolutionStatus !== "met" && ticketDoc.slaSolutionStatus !== "breached") {
      patch.slaSolutionDueAt = ticketDoc.slaSolutionDueAt + delta;
      patch.dueAt = ticketDoc.slaSolutionDueAt + delta;
    }
    return patch;
  }

  return {};
}

function getHourMinuteInTimezone(date: number, timeZone: string): { hour: number; minute: number } {
  const formatter = new Intl.DateTimeFormat("en-CA", {
    timeZone,
    hour: "2-digit",
    minute: "2-digit",
    hour12: false,
  })
  const parts = formatter.formatToParts(new Date(date))
  const hour = Number(parts.find((p) => p.type === "hour")?.value ?? "0")
  const minute = Number(parts.find((p) => p.type === "minute")?.value ?? "0")
  return { hour, minute }
}

function mergeTicketState(ticketDoc: Doc<"tickets">, patch: Record<string, unknown>): Doc<"tickets"> {
  const merged = { ...ticketDoc } as Record<string, unknown>;
  for (const [key, value] of Object.entries(patch)) {
    if (value === undefined) {
      delete merged[key];
    } else {
      merged[key] = value;
    }
  }
  return merged as Doc<"tickets">;
}

async function pauseSessionForLunch(ctx: MutationCtx, ticket: Doc<"tickets">, session: Doc<"ticketWorkSessions">) {
  const now = Date.now()
  const durationMs = Math.max(0, now - session.startedAt)
  const slaPatch = buildSlaStatusPatch(ticket, "PAUSED", now)

  await ctx.db.patch(session._id, {
    stoppedAt: now,
    durationMs,
    pauseReason: LUNCH_BREAK_REASON,
    pauseNote: LUNCH_BREAK_NOTE,
  })

  await ctx.db.patch(ticket._id, {
    working: false,
    activeSessionId: undefined,
    status: "PAUSED",
    totalWorkedMs: (ticket.totalWorkedMs ?? 0) + durationMs,
    internalWorkedMs: (ticket.internalWorkedMs ?? 0) + durationMs,
    updatedAt: now,
    ...slaPatch,
  })

  await ctx.db.insert("ticketEvents", {
    ticketId: ticket._id,
    type: "WORK_PAUSED",
    payload: {
      actorId: null,
      actorName: "Sistema",
      actorAvatar: null,
      sessionId: session._id,
      sessionDurationMs: durationMs,
      workType: (session.workType ?? "INTERNAL").toUpperCase(),
      pauseReason: LUNCH_BREAK_REASON,
      pauseReasonLabel: PAUSE_REASON_LABELS[LUNCH_BREAK_REASON],
      pauseNote: LUNCH_BREAK_NOTE,
      source: "lunch-break-cron",
    },
    createdAt: now,
  })
}

function buildResponseCompletionPatch(ticketDoc: Doc<"tickets">, now: number) {
  if (ticketDoc.firstResponseAt) {
    return {};
  }
  if (!ticketDoc.slaResponseDueAt) {
    return {
      firstResponseAt: now,
      slaResponseStatus: "n/a",
    };
  }
  const status = now <= ticketDoc.slaResponseDueAt ? "met" : "breached";
  return {
    firstResponseAt: now,
    slaResponseStatus: status,
  };
}

function buildSolutionCompletionPatch(ticketDoc: Doc<"tickets">, now: number) {
  if (ticketDoc.slaSolutionStatus === "met" || ticketDoc.slaSolutionStatus === "breached") {
    return {};
  }
  if (!ticketDoc.slaSolutionDueAt) {
    return { slaSolutionStatus: "n/a" };
  }
  const status = now <= ticketDoc.slaSolutionDueAt ? "met" : "breached";
  return {
    slaSolutionStatus: status,
  };
}

function resolveFormTemplateLabel(
  templateKey: string | null | undefined,
  storedLabel: string | null | undefined
): string | null {
  if (storedLabel && storedLabel.trim().length > 0) {
    return storedLabel.trim();
  }
  const normalizedKey = templateKey?.trim();
  if (!normalizedKey) {
    return null;
  }
  const fallback = TICKET_FORM_CONFIG.find((entry) => entry.key === normalizedKey);
  return fallback ? fallback.label : null;
}

function escapeHtml(input: string): string {
  return input
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;");
}

function resolveReopenWindowDays(input?: number | null): number {
  if (typeof input !== "number" || !Number.isFinite(input)) {
    return DEFAULT_REOPEN_DAYS;
  }
  const rounded = Math.round(input);
  if (rounded < 1) return 1;
  if (rounded > MAX_REOPEN_DAYS) return MAX_REOPEN_DAYS;
  return rounded;
}

function computeReopenDeadline(now: number, windowDays: number): number {
  return now + windowDays * 24 * 60 * 60 * 1000;
}

function inferExistingReopenDeadline(ticket: Doc<"tickets">): number | null {
  if (typeof ticket.reopenDeadline === "number") {
    return ticket.reopenDeadline;
  }
  if (typeof ticket.closedAt === "number") {
    return ticket.closedAt + DEFAULT_REOPEN_DAYS * 24 * 60 * 60 * 1000;
  }
  if (typeof ticket.resolvedAt === "number") {
    return ticket.resolvedAt + DEFAULT_REOPEN_DAYS * 24 * 60 * 60 * 1000;
  }
  return null;
}

function isWithinReopenWindow(ticket: Doc<"tickets">, now: number): boolean {
  const deadline = inferExistingReopenDeadline(ticket);
  if (!deadline) {
    return true;
  }
  return now <= deadline;
}

function findLatestSetting<T extends { updatedAt: number }>(entries: T[], predicate: (entry: T) => boolean): T | null {
  let latest: T | null = null;
  for (const entry of entries) {
    if (!predicate(entry)) continue;
    if (!latest || entry.updatedAt > latest.updatedAt) {
      latest = entry;
    }
  }
  return latest;
}

function resolveFormEnabled(
  template: string,
  baseEnabled: boolean,
  settings: Doc<"ticketFormSettings">[],
  context: { companyId?: Id<"companies"> | null; userId: Id<"users"> }
): boolean {
  const scoped = settings.filter((setting) => setting.template === template)
  if (scoped.length === 0) {
    return baseEnabled
  }
  const userSetting = findLatestSetting(scoped, (setting) => {
    if (setting.scope !== "user") {
      return false
    }
    if (!setting.userId) {
      return false
    }
    return String(setting.userId) === String(context.userId)
  })
  if (userSetting) {
    return userSetting.enabled ?? baseEnabled
  }
  const companyId = context.companyId ? String(context.companyId) : null
  if (companyId) {
    const companySetting = findLatestSetting(scoped, (setting) => {
      if (setting.scope !== "company") {
        return false
      }
      if (!setting.companyId) {
        return false
      }
      return String(setting.companyId) === companyId
    })
    if (companySetting) {
      return companySetting.enabled ?? baseEnabled
    }
  }
  const tenantSetting = findLatestSetting(scoped, (setting) => setting.scope === "tenant")
  if (tenantSetting) {
    return tenantSetting.enabled ?? baseEnabled
  }
  return baseEnabled
}

async function fetchTemplateSummaries(ctx: AnyCtx, tenantId: string): Promise<TemplateSummary[]> {
  const templates = await ctx.db
    .query("ticketFormTemplates")
    .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
    .take(100);
  if (!templates.length) {
    return TICKET_FORM_CONFIG.map((template) => ({
      key: template.key,
      label: template.label,
      description: template.description,
      defaultEnabled: template.defaultEnabled,
    }));
  }
  return templates
    .filter((tpl) => tpl.isArchived !== true)
    .sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.label.localeCompare(b.label, "pt-BR"))
    .map((tpl) => ({
      key: tpl.key,
      label: tpl.label,
      description: tpl.description ?? "",
      defaultEnabled: tpl.defaultEnabled ?? true,
    }));
}

async function fetchTicketFieldsByScopes(
  ctx: QueryCtx,
  tenantId: string,
  scopes: string[],
  companyId: Id<"companies"> | null
): Promise<TicketFieldScopeMap> {
  const scopeLookup = new Map<string, string>();
  scopes.forEach((scope) => {
    const trimmed = scope?.trim();
    if (!trimmed) {
      return;
    }
    const normalized = trimmed.toLowerCase();
    if (!scopeLookup.has(normalized)) {
      scopeLookup.set(normalized, trimmed);
    }
  });

  if (!scopeLookup.size) {
    return new Map();
  }

  const companyIdStr = companyId ? String(companyId) : null;
  const result: TicketFieldScopeMap = new Map();
  const allFields = await ctx.db
    .query("ticketFields")
    .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
    .take(100);

  const addFieldToScope = (scopeKey: string, field: Doc<"ticketFields">) => {
    const originalKey = scopeLookup.get(scopeKey);
    if (!originalKey) {
      return;
    }
    const current = result.get(originalKey);
    if (current) {
      current.push(field);
    } else {
      result.set(originalKey, [field]);
    }
  };

  const normalizedScopeSet = new Set(scopeLookup.keys());

  for (const field of allFields) {
    const rawScope = field.scope?.trim();
    const normalizedFieldScope = rawScope && rawScope.length > 0 ? rawScope.toLowerCase() : "all";
    const fieldCompanyId = field.companyId ? String(field.companyId) : null;
    if (fieldCompanyId && (!companyIdStr || companyIdStr !== fieldCompanyId)) {
      continue;
    }

    if (normalizedFieldScope === "all") {
      normalizedScopeSet.forEach((scopeKey) => addFieldToScope(scopeKey, field));
      continue;
    }

    if (normalizedFieldScope === "default") {
      if (normalizedScopeSet.has("default")) {
        addFieldToScope("default", field);
      }
      continue;
    }

    if (!normalizedScopeSet.has(normalizedFieldScope)) {
      continue;
    }
    addFieldToScope(normalizedFieldScope, field);
  }
  return result;
}

async function fetchViewerScopedFormSettings(
  ctx: QueryCtx,
  tenantId: string,
  templateKeys: string[],
  viewerId: Id<"users">,
  viewerCompanyId: Id<"companies"> | null
): Promise<Map<string, Doc<"ticketFormSettings">[]>> {
  const uniqueTemplates = Array.from(new Set(templateKeys));
  if (uniqueTemplates.length === 0) {
    return new Map();
  }
  const keySet = new Set(uniqueTemplates);
  const viewerIdStr = String(viewerId);
  const viewerCompanyIdStr = viewerCompanyId ? String(viewerCompanyId) : null;
  const scopedMap = new Map<string, Doc<"ticketFormSettings">[]>();

  const allSettings = await ctx.db
    .query("ticketFormSettings")
    .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
    .take(100);

  for (const setting of allSettings) {
    if (!keySet.has(setting.template)) {
      continue;
    }
    if (setting.scope === "company") {
      if (!viewerCompanyIdStr || !setting.companyId || String(setting.companyId) !== viewerCompanyIdStr) {
        continue;
      }
    } else if (setting.scope === "user") {
      if (!setting.userId || String(setting.userId) !== viewerIdStr) {
        continue;
      }
    } else if (setting.scope !== "tenant") {
      continue;
    }

    if (scopedMap.has(setting.template)) {
      scopedMap.get(setting.template)!.push(setting);
    } else {
      scopedMap.set(setting.template, [setting]);
    }
  }

  return scopedMap;
}

function normalizeDateOnlyValue(value: unknown): string | null {
  if (value === null || value === undefined) {
    return null;
  }
  if (typeof value === "string") {
    const trimmed = value.trim();
    if (!trimmed) return null;
    if (DATE_ONLY_REGEX.test(trimmed)) {
      return trimmed;
    }
    const parsed = new Date(trimmed);
    if (Number.isNaN(parsed.getTime())) {
      return null;
    }
    return parsed.toISOString().slice(0, 10);
  }
  const date =
    value instanceof Date
      ? value
      : typeof value === "number"
        ? new Date(value)
        : null;
  if (!date || Number.isNaN(date.getTime())) {
    return null;
  }
  return date.toISOString().slice(0, 10);
}

async function ensureTicketFormDefaultsForTenant(ctx: MutationCtx, tenantId: string) {
  await ensureTicketFormTemplatesForTenant(ctx, tenantId);
  const now = Date.now();
  for (const template of TICKET_FORM_CONFIG) {
    const defaults = TICKET_FORM_DEFAULT_FIELDS[template.key] ?? [];
    if (!defaults.length) {
      continue;
    }
    const existing = await ctx.db
      .query("ticketFields")
      .withIndex("by_tenant_scope", (q) => q.eq("tenantId", tenantId).eq("scope", template.key))
      .take(100);
    if (template.key === "admissao") {
      for (const key of OPTIONAL_ADMISSION_FIELD_KEYS) {
        const field = existing.find((f) => f.key === key);
        if (!field) continue;
        const updates: Partial<Doc<"ticketFields">> = {};
        if (field.required) {
          updates.required = false;
        }
        if (key === "colaborador_patrimonio") {
          const desiredLabel = "Patrimônio do computador (se houver)";
          if ((field.label ?? "").trim() !== desiredLabel) {
            updates.label = desiredLabel;
          }
        }
        if (Object.keys(updates).length) {
          await ctx.db.patch(field._id, {
            ...updates,
            updatedAt: now,
          });
        }
      }
    }
    const existingKeys = new Set(existing.map((field) => field.key));
    let order = existing.reduce((max, field) => Math.max(max, field.order ?? 0), 0);
    for (const field of defaults) {
      if (existingKeys.has(field.key)) {
        // Campo já existe: não sobrescrevemos personalizações do cliente, exceto hotfix acima
        continue;
      }
      order += 1;
      await ctx.db.insert("ticketFields", {
        tenantId,
        key: field.key,
        label: field.label,
        description: field.description ?? "",
        type: field.type,
        required: Boolean(field.required),
        options: field.options?.map((option) => ({
          value: option.value,
          label: option.label,
        })),
        scope: template.key,
        companyId: field.companyId ?? undefined,
        order,
        createdAt: now,
        updatedAt: now,
      });
    }
  }
}

function truncateSubject(subject: string) {
  if (subject.length <= 60) return subject
  return `${subject.slice(0, 57)}…`
}

const TICKET_MENTION_ANCHOR_CLASSES =
  "ticket-mention inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-100 px-2.5 py-1 text-xs font-semibold text-neutral-800 no-underline transition hover:bg-slate-200"
const TICKET_MENTION_REF_CLASSES = "ticket-mention-ref text-neutral-900"
const TICKET_MENTION_SEP_CLASSES = "ticket-mention-sep text-neutral-400"
const TICKET_MENTION_SUBJECT_CLASSES = "ticket-mention-subject max-w-[220px] truncate text-neutral-700"
const TICKET_MENTION_DOT_BASE_CLASSES = "ticket-mention-dot inline-flex size-2 rounded-full"
const TICKET_MENTION_STATUS_TONE: Record<TicketStatusNormalized, string> = {
  PENDING: "bg-amber-400",
  AWAITING_ATTENDANCE: "bg-sky-500",
  PAUSED: "bg-violet-500",
  RESOLVED: "bg-emerald-500",
}

function buildTicketMentionAnchor(ticket: Doc<"tickets">): string {
  const reference = ticket.reference
  const subject = escapeHtml(ticket.subject ?? "")
  const truncated = truncateSubject(subject)
  const status = (ticket.status ?? "PENDING").toString().toUpperCase()
  const priority = (ticket.priority ?? "MEDIUM").toString().toUpperCase()
  const normalizedStatus = normalizeStatus(status)
  const dotTone = TICKET_MENTION_STATUS_TONE[normalizedStatus] ?? "bg-slate-400"
  const dotClass = `${TICKET_MENTION_DOT_BASE_CLASSES} ${dotTone}`
  return `<a data-ticket-mention="true" data-ticket-id="${String(ticket._id)}" data-ticket-reference="${reference}" data-ticket-status="${status}" data-ticket-priority="${priority}" data-ticket-subject="${subject}" status="${normalizedStatus}" href="/tickets/${String(ticket._id)}" class="${TICKET_MENTION_ANCHOR_CLASSES}" rel="noopener noreferrer" target="_self" title="Chamado #${reference}${subject ? ` • ${subject}` : ""}"><span class="${dotClass}"></span><span class="${TICKET_MENTION_REF_CLASSES}">#${reference}</span><span class="${TICKET_MENTION_SEP_CLASSES}">•</span><span class="${TICKET_MENTION_SUBJECT_CLASSES}">${truncated}</span></a>`
}

function canMentionTicket(viewerRole: string, viewerId: Id<"users">, ticket: Doc<"tickets">) {
  if (viewerRole === "ADMIN" || viewerRole === "AGENT") return true
  if (viewerRole === "COLLABORATOR") {
    return String(ticket.requesterId) === String(viewerId)
  }
  if (viewerRole === "MANAGER") {
    // Gestores compartilham contexto interno; permitem apenas tickets da mesma empresa do solicitante
    return String(ticket.requesterId) === String(viewerId)
  }
  return false
}

async function normalizeTicketMentions(
  ctx: MutationCtx,
  html: string,
  viewer: { user: Doc<"users">; role: string },
  tenantId: string,
): Promise<string> {
  if (!html || (html.indexOf("data-ticket-mention") === -1 && html.indexOf("ticket-mention") === -1)) {
    return html
  }

  const mentionPattern = /<a\b[^>]*(?:data-ticket-mention="true"|class="[^"]*ticket-mention[^"]*")[^>]*>[\s\S]*?<\/a>/gi
  const matches = Array.from(html.matchAll(mentionPattern))
  if (!matches.length) {
    return html
  }

  let output = html

  const attributePattern = /(data-[\w-]+|class|href)="([^"]*)"/gi

  for (const match of matches) {
    const full = match[0]
    attributePattern.lastIndex = 0
    const attributes: Record<string, string> = {}
    let attrMatch: RegExpExecArray | null
    while ((attrMatch = attributePattern.exec(full)) !== null) {
      attributes[attrMatch[1]] = attrMatch[2]
    }

    let ticketIdRaw: string | null = attributes["data-ticket-id"] ?? null
    if (!ticketIdRaw && attributes.href) {
      const hrefPath = attributes.href.split("?")[0]
      const segments = hrefPath.split("/").filter(Boolean)
      ticketIdRaw = segments.pop() ?? null
    }
    let replacement = ""

    if (ticketIdRaw) {
      const ticket = await ctx.db.get(ticketIdRaw as Id<"tickets">)
      if (ticket && ticket.tenantId === tenantId && canMentionTicket(viewer.role, viewer.user._id, ticket)) {
        replacement = buildTicketMentionAnchor(ticket)
      } else {
        const inner = match[0].replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim()
        replacement = escapeHtml(inner || `#${ticketIdRaw}`)
      }
    } else {
      replacement = escapeHtml(full.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim())
    }

    output = output.replace(full, replacement)
  }

  return output
}

export function normalizeStatus(status: string | null | undefined): TicketStatusNormalized {
  if (!status) return "PENDING";
  const normalized = LEGACY_STATUS_MAP[status.toUpperCase()];
  return normalized ?? "PENDING";
}

function formatWorkDuration(ms: number): string {
  if (!Number.isFinite(ms) || ms <= 0) {
    return "0m";
  }
  const totalMinutes = Math.round(ms / 60000);
  const hours = Math.floor(totalMinutes / 60);
  const minutes = Math.abs(totalMinutes % 60);
  const parts: string[] = [];
  if (hours > 0) parts.push(`${hours}h`);
  if (minutes > 0) parts.push(`${minutes}m`);
  if (parts.length === 0) {
    return "0m";
  }
  return parts.join(" ");
}

function formatWorkDelta(deltaMs: number): string {
  if (deltaMs === 0) return "0m";
  const sign = deltaMs > 0 ? "+" : "-";
  const absolute = formatWorkDuration(Math.abs(deltaMs));
  return `${sign}${absolute}`;
}

type AgentWorkTotals = {
  agentId: Id<"users">;
  agentName: string | null;
  agentEmail: string | null;
  avatarUrl: string | null;
  totalWorkedMs: number;
  internalWorkedMs: number;
  externalWorkedMs: number;
};

async function computeAgentWorkTotals(
  ctx: MutationCtx | QueryCtx,
  ticketId: Id<"tickets">,
  referenceNow: number,
): Promise<AgentWorkTotals[]> {
  const sessions = await ctx.db
    .query("ticketWorkSessions")
    .withIndex("by_ticket", (q) => q.eq("ticketId", ticketId))
    .take(50);

  if (!sessions.length) {
    return [];
  }

  const totals = new Map<
    string,
    { totalWorkedMs: number; internalWorkedMs: number; externalWorkedMs: number }
  >();

  for (const session of sessions) {
    const baseDuration = typeof session.durationMs === "number"
      ? session.durationMs
      : typeof session.stoppedAt === "number"
        ? session.stoppedAt - session.startedAt
        : referenceNow - session.startedAt;
    const durationMs = Math.max(0, baseDuration);
    if (durationMs <= 0) continue;
    const key = session.agentId as string;
    const bucket = totals.get(key) ?? {
      totalWorkedMs: 0,
      internalWorkedMs: 0,
      externalWorkedMs: 0,
    };
    bucket.totalWorkedMs += durationMs;
    const workType = (session.workType ?? "INTERNAL").toUpperCase();
    if (workType === "EXTERNAL") {
      bucket.externalWorkedMs += durationMs;
    } else {
      bucket.internalWorkedMs += durationMs;
    }
    totals.set(key, bucket);
  }

  if (totals.size === 0) {
    return [];
  }

  const agentIds = Array.from(totals.keys());
  const agents = await Promise.all(agentIds.map((agentId) => ctx.db.get(agentId as Id<"users">)));

  return agentIds
    .map((agentId, index) => {
      const bucket = totals.get(agentId)!;
      const agentDoc = agents[index] as Doc<"users"> | null;
      return {
        agentId: agentId as Id<"users">,
        agentName: agentDoc?.name ?? null,
        agentEmail: agentDoc?.email ?? null,
        avatarUrl: agentDoc?.avatarUrl ?? null,
        totalWorkedMs: bucket.totalWorkedMs,
        internalWorkedMs: bucket.internalWorkedMs,
        externalWorkedMs: bucket.externalWorkedMs,
      };
    })
    .sort((a, b) => b.totalWorkedMs - a.totalWorkedMs);
}

async function ensureManagerTicketAccess(
  ctx: MutationCtx | QueryCtx,
  manager: Doc<"users">,
  ticket: Doc<"tickets">,
): Promise<Doc<"users"> | null> {
  if (!manager.companyId) {
    throw new ConvexError("Gestor não possui empresa vinculada")
  }
  if (ticket.companyId && ticket.companyId === manager.companyId) {
    return null
  }
  const requester = await ctx.db.get(ticket.requesterId)
  if (!requester || requester.companyId !== manager.companyId) {
    throw new ConvexError("Acesso restrito à empresa")
  }
  return requester as Doc<"users">
}

async function requireTicketStaff(
  ctx: MutationCtx | QueryCtx,
  actorId: Id<"users">,
  ticket: Doc<"tickets">
) {
  const viewer = await requireStaff(ctx, actorId, ticket.tenantId)
  if (viewer.role === "MANAGER") {
    await ensureManagerTicketAccess(ctx, viewer.user, ticket)
  }
  return viewer
}

type TicketChatParticipant = {
  user: Doc<"users">;
  role: string | null;
  kind: "staff" | "manager" | "requester";
};

async function requireTicketChatParticipant(
  ctx: MutationCtx | QueryCtx,
  actorId: Id<"users">,
  ticket: Doc<"tickets">
): Promise<TicketChatParticipant> {
  const viewer = await requireUser(ctx, actorId, ticket.tenantId);
  const normalizedRole = viewer.role ?? "";
  if (normalizedRole === "ADMIN" || normalizedRole === "AGENT") {
    return { user: viewer.user, role: normalizedRole, kind: "staff" };
  }
  if (normalizedRole === "MANAGER") {
    await ensureManagerTicketAccess(ctx, viewer.user, ticket);
    return { user: viewer.user, role: normalizedRole, kind: "manager" };
  }
  if (normalizedRole === "COLLABORATOR") {
    // Verificar se e o solicitante
    if (String(ticket.requesterId) === String(viewer.user._id)) {
      return { user: viewer.user, role: normalizedRole, kind: "requester" };
    }
    // Verificar se esta vinculado a maquina do ticket
    if (ticket.machineId) {
      const machine = await ctx.db.get(ticket.machineId);
      if (machine) {
        const isLinkedToMachine =
          machine.assignedUserId?.toString() === viewer.user._id.toString() ||
          machine.linkedUserIds?.some((id) => id.toString() === viewer.user._id.toString());
        if (isLinkedToMachine) {
          return { user: viewer.user, role: normalizedRole, kind: "requester" };
        }
      }
    }
    throw new ConvexError("Apenas o solicitante pode conversar neste chamado");
  }
  throw new ConvexError("Usuário não possui acesso ao chat deste chamado");
}

const QUEUE_RENAME_LOOKUP: Record<string, string> = {
  "Suporte N1": "Chamados",
  "suporte-n1": "Chamados",
  chamados: "Chamados",
  "Suporte N2": "Laboratório",
  "suporte-n2": "Laboratório",
  laboratorio: "Laboratório",
  Laboratorio: "Laboratório",
  visitas: "Visitas",
};

function renameQueueString(value?: string | null): string | null {
  if (!value) return value ?? null;
  const direct = QUEUE_RENAME_LOOKUP[value];
  if (direct) return direct;
  const normalizedKey = value.replace(/\s+/g, "-").toLowerCase();
  return QUEUE_RENAME_LOOKUP[normalizedKey] ?? value;
}

function normalizeQueueName(queue?: Doc<"queues"> | null): string | null {
  if (!queue) return null;
  const normalized = renameQueueString(queue.name);
  if (normalized) {
    return normalized;
  }
  if (queue.slug) {
    const fromSlug = renameQueueString(queue.slug);
    if (fromSlug) return fromSlug;
  }
  return queue.name;
}

function normalizeTeams(teams?: string[] | null): string[] {
  if (!teams) return [];
  return teams.map((team) => renameQueueString(team) ?? team);
}

type RequesterFallbackContext = {
  ticketId?: Id<"tickets">;
  fallbackName?: string | null;
  fallbackEmail?: string | null;
};

function buildRequesterSummary(
  requester: Doc<"users"> | null,
  requesterId: Id<"users">,
  context?: RequesterFallbackContext,
) {
  if (requester) {
    return {
      id: requester._id,
      name: requester.name,
      email: requester.email,
      avatarUrl: requester.avatarUrl,
      teams: normalizeTeams(requester.teams),
    };
  }

  const idString = String(requesterId);
  const fallbackName =
    typeof context?.fallbackName === "string" && context.fallbackName.trim().length > 0
      ? context.fallbackName.trim()
      : "Solicitante não encontrado";
  const fallbackEmailCandidate =
    typeof context?.fallbackEmail === "string" && context.fallbackEmail.includes("@")
      ? context.fallbackEmail
      : null;
  const fallbackEmail = fallbackEmailCandidate ?? `requester-${idString}@example.invalid`;

  if (process.env.NODE_ENV !== "test") {
    const ticketInfo = context?.ticketId ? ` (ticket ${String(context.ticketId)})` : "";
    const cacheKey = `${idString}:${context?.ticketId ? String(context.ticketId) : "unknown"}`;
    if (!missingRequesterLogCache.has(cacheKey)) {
      missingRequesterLogCache.add(cacheKey);
      console.warn(
        `[tickets] requester ${idString} ausente ao hidratar resposta${ticketInfo}; usando placeholders.`,
      );
    }
  }

  return {
    id: requesterId,
    name: fallbackName,
    email: fallbackEmail,
    teams: [],
  };
}

type UserSnapshot = { name: string; email?: string; avatarUrl?: string; teams?: string[] };
type CompanySnapshot = { name: string; slug?: string; isAvulso?: boolean };

function buildRequesterFromSnapshot(
  requesterId: Id<"users">,
  snapshot: UserSnapshot | null | undefined,
  fallback?: RequesterFallbackContext
) {
  if (snapshot) {
    const name = typeof snapshot.name === "string" && snapshot.name.trim().length > 0 ? snapshot.name.trim() : (fallback?.fallbackName ?? "Solicitante não encontrado")
    const emailCandidate = typeof snapshot.email === "string" && snapshot.email.includes("@") ? snapshot.email : null
    const email = emailCandidate ?? (fallback?.fallbackEmail ?? `requester-${String(requesterId)}@example.invalid`)
    return {
      id: requesterId,
      name,
      email,
      avatarUrl: snapshot.avatarUrl ?? undefined,
      teams: normalizeTeams(snapshot.teams ?? []),
    }
  }
  return buildRequesterSummary(null, requesterId, fallback)
}

function buildAssigneeFromSnapshot(
  assigneeId: Id<"users">,
  snapshot: UserSnapshot | null | undefined
) {
  const name = snapshot?.name?.trim?.() || "Usuário removido"
  const emailCandidate = typeof snapshot?.email === "string" && snapshot.email.includes("@") ? snapshot.email : null
  const email = emailCandidate ?? `user-${String(assigneeId)}@example.invalid`
  return {
    id: assigneeId,
    name,
    email,
    avatarUrl: snapshot?.avatarUrl ?? undefined,
    teams: normalizeTeams(snapshot?.teams ?? []),
  }
}

function buildCompanyFromSnapshot(
  companyId: Id<"companies"> | undefined,
  snapshot: CompanySnapshot | null | undefined
) {
  if (!snapshot) return null
  return {
    id: (companyId ? companyId : ("snapshot" as unknown as Id<"companies">)) as Id<"companies">,
    name: snapshot.name,
    isAvulso: Boolean(snapshot.isAvulso ?? false),
  }
}

type CommentAuthorFallbackContext = {
  ticketId?: Id<"tickets">;
  commentId?: Id<"ticketComments">;
};

type CommentAuthorSnapshot = {
  name: string;
  email?: string;
  avatarUrl?: string;
  teams?: string[];
};

export function buildCommentAuthorSummary(
  comment: Doc<"ticketComments">,
  author: Doc<"users"> | null,
  context?: CommentAuthorFallbackContext,
) {
  if (author) {
    return {
      id: author._id,
      name: author.name,
      email: author.email,
      avatarUrl: author.avatarUrl,
      teams: normalizeTeams(author.teams),
    };
  }

  if (process.env.NODE_ENV !== "test") {
    const ticketInfo = context?.ticketId ? ` (ticket ${String(context.ticketId)})` : "";
    const commentInfo = context?.commentId ? ` (comentário ${String(context.commentId)})` : "";
    const cacheKeyParts = [String(comment.authorId), context?.ticketId ? String(context.ticketId) : "unknown"];
    if (context?.commentId) cacheKeyParts.push(String(context.commentId));
    const cacheKey = cacheKeyParts.join(":");
    if (!missingCommentAuthorLogCache.has(cacheKey)) {
      missingCommentAuthorLogCache.add(cacheKey);
      console.warn(
        `[tickets] autor ${String(comment.authorId)} ausente ao hidratar comentário${ticketInfo}${commentInfo}; usando placeholders.`,
      );
    }
  }

  const idString = String(comment.authorId);
  const fallbackName = "Usuário removido";
  const fallbackEmail = `author-${idString}@example.invalid`;
  const snapshot = comment.authorSnapshot as CommentAuthorSnapshot | undefined;
  if (snapshot) {
    const name =
      typeof snapshot.name === "string" && snapshot.name.trim().length > 0
        ? snapshot.name.trim()
        : fallbackName;
    const emailCandidate =
      typeof snapshot.email === "string" && snapshot.email.includes("@") ? snapshot.email : null;
    const email = emailCandidate ?? fallbackEmail;
    return {
      id: comment.authorId,
      name,
      email,
      avatarUrl: snapshot.avatarUrl ?? undefined,
      teams: normalizeTeams(snapshot.teams ?? []),
    };
  }

  return {
    id: comment.authorId,
    name: fallbackName,
    email: fallbackEmail,
    teams: [],
  };
}

type CustomFieldInput = {
  fieldId: Id<"ticketFields">;
  value: unknown;
};

type NormalizedCustomField = {
  fieldId: Id<"ticketFields">;
  fieldKey: string;
  label: string;
  type: string;
  value: unknown;
  displayValue?: string;
};

function coerceCustomFieldValue(field: Doc<"ticketFields">, raw: unknown): { value: unknown; displayValue?: string } {
  switch (field.type) {
    case "text":
      return { value: String(raw).trim() };
    case "number": {
      const value = typeof raw === "number" ? raw : Number(String(raw).replace(",", "."));
      if (!Number.isFinite(value)) {
        throw new ConvexError(`Valor numérico inválido para o campo ${field.label}`);
      }
      return { value };
    }
    case "date": {
      const normalized = normalizeDateOnlyValue(raw);
      if (!normalized) {
        throw new ConvexError(`Data inválida para o campo ${field.label}`);
      }
      return { value: normalized };
    }
    case "boolean": {
      if (typeof raw === "boolean") {
        return { value: raw };
      }
      if (typeof raw === "string") {
        const normalized = raw.toLowerCase();
        if (normalized === "true" || normalized === "1") return { value: true };
        if (normalized === "false" || normalized === "0") return { value: false };
      }
      throw new ConvexError(`Valor inválido para o campo ${field.label}`);
    }
    case "select": {
      if (!field.options || field.options.length === 0) {
        throw new ConvexError(`Campo ${field.label} sem opções configuradas`);
      }
      const value = String(raw);
      const option = field.options.find((opt) => opt.value === value);
      if (!option) {
        throw new ConvexError(`Seleção inválida para o campo ${field.label}`);
      }
      return { value: option.value, displayValue: option.label ?? option.value };
    }
    default:
      return { value: raw };
  }
}

async function normalizeCustomFieldValues(
  ctx: Pick<MutationCtx, "db">,
  tenantId: string,
  inputs: CustomFieldInput[] | undefined,
  scope?: string | null
): Promise<NormalizedCustomField[]> {
  const normalizedScope = scope?.trim() ? scope.trim().toLowerCase() : null;
  const definitions = await ctx.db
    .query("ticketFields")
    .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
    .take(100);

  const scopedDefinitions = definitions.filter((definition) => {
    const fieldScope = (definition.scope ?? "all").toLowerCase();
    if (fieldScope === "all" || fieldScope.length === 0) {
      return true;
    }
    if (!normalizedScope) {
      return false;
    }
    return fieldScope === normalizedScope;
  });

  if (!scopedDefinitions.length) {
    if (inputs && inputs.length > 0) {
      throw new ConvexError("Campos personalizados não configurados para este formulário");
    }
    return [];
  }

  const provided = new Map<Id<"ticketFields">, unknown>();
  for (const entry of inputs ?? []) {
    provided.set(entry.fieldId, entry.value);
  }

  const normalized: NormalizedCustomField[] = [];

  for (const definition of scopedDefinitions.sort((a, b) => a.order - b.order)) {
    const raw = provided.has(definition._id) ? provided.get(definition._id) : undefined;
    const isMissing =
      raw === undefined ||
      raw === null ||
      (typeof raw === "string" && raw.trim().length === 0);

    if (isMissing) {
      if (definition.required) {
        throw new ConvexError(`Preencha o campo obrigatório: ${definition.label}`);
      }
      continue;
    }

    const { value, displayValue } = coerceCustomFieldValue(definition, raw);
    normalized.push({
      fieldId: definition._id,
      fieldKey: definition.key,
      label: definition.label,
      type: definition.type,
      value,
      displayValue,
    });
  }

  return normalized;
}

function mapCustomFieldsToRecord(entries: NormalizedCustomField[] | undefined) {
  if (!entries || entries.length === 0) return {};
  return entries.reduce<Record<string, { label: string; type: string; value: unknown; displayValue?: string }>>((acc, entry) => {
    let value: unknown = entry.value;
    if (entry.type === "date") {
      value = normalizeDateOnlyValue(entry.value) ?? entry.value;
    }
    acc[entry.fieldKey] = {
      label: entry.label,
      type: entry.type,
      value,
      displayValue: entry.displayValue,
    };
    return acc;
  }, {});
}

type CustomFieldRecordEntry = { label: string; type: string; value: unknown; displayValue?: string } | undefined;

function areCustomFieldEntriesEqual(a: CustomFieldRecordEntry, b: CustomFieldRecordEntry): boolean {
  return serializeCustomFieldEntry(a) === serializeCustomFieldEntry(b);
}

function serializeCustomFieldEntry(entry: CustomFieldRecordEntry): string {
  if (!entry) return "__undefined__";
  return JSON.stringify({
    value: normalizeEntryValue(entry.value),
    displayValue: entry.displayValue ?? null,
  });
}

function normalizeEntryValue(value: unknown): unknown {
  if (value === undefined || value === null) return null;
  if (value instanceof Date) return value.toISOString();
  if (typeof value === "number" && Number.isNaN(value)) return "__nan__";
  if (Array.isArray(value)) {
    return value.map((item) => normalizeEntryValue(item));
  }
  if (typeof value === "object") {
    const record = value as Record<string, unknown>;
    const normalized: Record<string, unknown> = {};
    Object.keys(record)
      .sort()
      .forEach((key) => {
        normalized[key] = normalizeEntryValue(record[key]);
      });
    return normalized;
  }
  return value;
}

function getCustomFieldRecordEntry(
  record: Record<string, { label: string; type: string; value: unknown; displayValue?: string }>,
  key: string
): CustomFieldRecordEntry {
  return Object.prototype.hasOwnProperty.call(record, key) ? record[key] : undefined;
}

const DEFAULT_TICKETS_LIST_LIMIT = 120;
const MIN_TICKETS_LIST_LIMIT = 25;
const MAX_TICKETS_LIST_LIMIT = 400;
const MAX_FETCH_LIMIT = 400;
const BASE_FETCH_PADDING = 50;
const SEARCH_FETCH_PADDING = 200;

function clampTicketLimit(limit: number) {
  if (!Number.isFinite(limit)) return DEFAULT_TICKETS_LIST_LIMIT;
  return Math.max(MIN_TICKETS_LIST_LIMIT, Math.min(MAX_TICKETS_LIST_LIMIT, Math.floor(limit)));
}

function computeFetchLimit(limit: number, hasSearch: boolean) {
  const padding = hasSearch ? SEARCH_FETCH_PADDING : BASE_FETCH_PADDING;
  const target = limit + padding;
  return Math.max(limit, Math.min(MAX_FETCH_LIMIT, target));
}

async function loadDocs<TableName extends TableNames>(
  ctx: QueryCtx,
  ids: (Id<TableName> | null | undefined)[],
): Promise<Map<string, Doc<TableName>>> {
  const uniqueIds = Array.from(
    new Set(ids.filter((value): value is Id<TableName> => Boolean(value))),
  );
  if (uniqueIds.length === 0) {
    return new Map();
  }
  const docs = await Promise.all(uniqueIds.map((id) => ctx.db.get(id)));
  const map = new Map<string, Doc<TableName>>();
  docs.forEach((doc, index) => {
    if (doc) {
      map.set(String(uniqueIds[index]), doc);
    }
  });
  return map;
}

function dedupeTicketsById(tickets: Doc<"tickets">[]) {
  const seen = new Set<string>();
  const result: Doc<"tickets">[] = [];
  for (const ticket of tickets) {
    const key = String(ticket._id);
    if (seen.has(key)) continue;
    seen.add(key);
    result.push(ticket);
  }
  return result;
}

export const list = query({
  args: {
    viewerId: v.optional(v.id("users")),
    tenantId: v.string(),
    status: v.optional(v.string()),
    priority: v.optional(v.union(v.string(), v.array(v.string()))),
    channel: v.optional(v.string()),
    queueId: v.optional(v.id("queues")),
    assigneeId: v.optional(v.id("users")),
    requesterId: v.optional(v.id("users")),
    search: v.optional(v.string()),
    limit: v.optional(v.number()),
  },
  handler: async (ctx, args) => {
    if (!args.viewerId) {
      return [];
    }
    const viewerId = args.viewerId as Id<"users">;
    const { user, role } = await requireUser(ctx, viewerId, args.tenantId);
    if (role === "MANAGER" && !user.companyId) {
      throw new ConvexError("Gestor não possui empresa vinculada");
    }

    const normalizedStatusFilter = args.status ? normalizeStatus(args.status) : null;
    const normalizedPriorityFilter = normalizePriorityFilter(args.priority);
    const prioritySet = normalizedPriorityFilter.length > 0 ? new Set(normalizedPriorityFilter) : null;
    const normalizedChannelFilter = args.channel ? args.channel.toUpperCase() : null;
    const searchTerm = args.search?.trim().toLowerCase() ?? null;

    const requestedLimitRaw = typeof args.limit === "number" ? args.limit : DEFAULT_TICKETS_LIST_LIMIT;
    const requestedLimit = clampTicketLimit(requestedLimitRaw);
    const fetchLimit = computeFetchLimit(requestedLimit, Boolean(searchTerm));

    let base: Doc<"tickets">[] = [];

    if (role === "MANAGER") {
      const baseQuery = ctx.db
        .query("tickets")
        .withIndex("by_tenant_company", (q) => q.eq("tenantId", args.tenantId).eq("companyId", user.companyId!));
      base = await baseQuery.order("desc").take(fetchLimit);
    } else if (args.assigneeId) {
      const baseQuery = ctx.db
        .query("tickets")
        .withIndex("by_tenant_assignee", (q) => q.eq("tenantId", args.tenantId).eq("assigneeId", args.assigneeId!));
      base = await baseQuery.order("desc").take(fetchLimit);
    } else if (args.requesterId) {
      const baseQuery = ctx.db
        .query("tickets")
        .withIndex("by_tenant_requester", (q) => q.eq("tenantId", args.tenantId).eq("requesterId", args.requesterId!));
      base = await baseQuery.order("desc").take(fetchLimit);
    } else if (args.queueId) {
      const baseQuery = ctx.db
        .query("tickets")
        .withIndex("by_tenant_queue", (q) => q.eq("tenantId", args.tenantId).eq("queueId", args.queueId!));
      base = await baseQuery.order("desc").take(fetchLimit);
    } else if (normalizedStatusFilter) {
      const baseQuery = ctx.db
        .query("tickets")
        .withIndex("by_tenant_status", (q) => q.eq("tenantId", args.tenantId).eq("status", normalizedStatusFilter));
      base = await baseQuery.order("desc").take(fetchLimit);
    } else if (role === "COLLABORATOR") {
      const viewerEmail = user.email.trim().toLowerCase();
      const directQuery = ctx.db
        .query("tickets")
        .withIndex("by_tenant_requester", (q) => q.eq("tenantId", args.tenantId).eq("requesterId", viewerId));
      const directTickets = await directQuery.order("desc").take(fetchLimit);

      let combined = directTickets;
      if (directTickets.length < fetchLimit) {
        const fallbackQuery = ctx.db.query("tickets").withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId));
        const fallbackRaw = await fallbackQuery.order("desc").take(fetchLimit);
        const fallbackMatches = fallbackRaw.filter((ticket) => {
          const snapshotEmail = (ticket.requesterSnapshot as { email?: string } | undefined)?.email;
          if (typeof snapshotEmail !== "string") return false;
          return snapshotEmail.trim().toLowerCase() === viewerEmail;
        });
        combined = dedupeTicketsById([...directTickets, ...fallbackMatches]);
      }
      base = combined.slice(0, fetchLimit);
    } else {
      const baseQuery = ctx.db.query("tickets").withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId));
      base = await baseQuery.order("desc").take(fetchLimit);
    }

    let filtered = base;

    if (role === "MANAGER") {
      filtered = filtered.filter((t) => t.companyId === user.companyId);
    }
    if (prioritySet) filtered = filtered.filter((t) => prioritySet.has(t.priority));
    if (normalizedChannelFilter) filtered = filtered.filter((t) => t.channel === normalizedChannelFilter);
    if (args.assigneeId) filtered = filtered.filter((t) => String(t.assigneeId ?? "") === String(args.assigneeId));
    if (args.requesterId) filtered = filtered.filter((t) => String(t.requesterId) === String(args.requesterId));
    if (normalizedStatusFilter) {
      filtered = filtered.filter((t) => normalizeStatus(t.status) === normalizedStatusFilter);
    }
    if (searchTerm) {
      filtered = filtered.filter(
        (t) =>
          t.subject.toLowerCase().includes(searchTerm) ||
          t.summary?.toLowerCase().includes(searchTerm) ||
          `#${t.reference}`.toLowerCase().includes(searchTerm)
      );
    }

    const limited = filtered.slice(0, requestedLimit);
    if (limited.length === 0) {
      return [];
    }

    const [
      requesterDocs,
      assigneeDocs,
      queueDocs,
      companyDocs,
      machineDocs,
      activeSessionDocs,
      categoryDocs,
      subcategoryDocs,
    ] = await Promise.all([
      loadDocs(ctx, limited.map((t) => t.requesterId)),
      loadDocs(ctx, limited.map((t) => (t.assigneeId as Id<"users"> | null) ?? null)),
      loadDocs(ctx, limited.map((t) => (t.queueId as Id<"queues"> | null) ?? null)),
      loadDocs(ctx, limited.map((t) => (t.companyId as Id<"companies"> | null) ?? null)),
      loadDocs(ctx, limited.map((t) => (t.machineId as Id<"machines"> | null) ?? null)),
      loadDocs(ctx, limited.map((t) => (t.activeSessionId as Id<"ticketWorkSessions"> | null) ?? null)),
      loadDocs(ctx, limited.map((t) => (t.categoryId as Id<"ticketCategories"> | null) ?? null)),
      loadDocs(ctx, limited.map((t) => (t.subcategoryId as Id<"ticketSubcategories"> | null) ?? null)),
    ]);

    const serverNow = Date.now();
    const result = limited.map((t) => {
      const requesterSnapshot = t.requesterSnapshot as UserSnapshot | undefined;
      const requesterDoc = requesterDocs.get(String(t.requesterId)) ?? null;
      const requesterSummary = requesterDoc
        ? buildRequesterSummary(requesterDoc, t.requesterId, { ticketId: t._id })
        : buildRequesterFromSnapshot(t.requesterId, requesterSnapshot, { ticketId: t._id });

      const assigneeDoc = t.assigneeId
        ? assigneeDocs.get(String(t.assigneeId)) ?? null
        : null;
      const assigneeSummary = t.assigneeId
        ? assigneeDoc
          ? {
              id: assigneeDoc._id,
              name: assigneeDoc.name,
              email: assigneeDoc.email,
              avatarUrl: assigneeDoc.avatarUrl,
              teams: normalizeTeams(assigneeDoc.teams),
            }
          : buildAssigneeFromSnapshot(t.assigneeId, t.assigneeSnapshot ?? undefined)
        : null;

      const queueDoc = t.queueId ? queueDocs.get(String(t.queueId)) ?? null : null;
      const queueName = normalizeQueueName(queueDoc);

      const companyDoc = t.companyId ? companyDocs.get(String(t.companyId)) ?? null : null;
      const companySummary = companyDoc
        ? { id: companyDoc._id, name: companyDoc.name, isAvulso: companyDoc.isAvulso ?? false }
        : t.companyId || t.companySnapshot
        ? buildCompanyFromSnapshot(t.companyId as Id<"companies"> | undefined, t.companySnapshot ?? undefined)
        : null;

      const machineSnapshot = t.machineSnapshot as
        | {
            hostname?: string;
            persona?: string;
            assignedUserName?: string;
            assignedUserEmail?: string;
            status?: string;
          }
        | undefined;
      const machineDoc = t.machineId ? machineDocs.get(String(t.machineId)) ?? null : null;
      let machineSummary:
        | {
            id: Id<"machines"> | null;
            hostname: string | null;
            persona: string | null;
            assignedUserName: string | null;
            assignedUserEmail: string | null;
            status: string | null;
          }
        | null = null;
      if (t.machineId) {
        machineSummary = {
          id: t.machineId,
          hostname: machineDoc?.hostname ?? machineSnapshot?.hostname ?? null,
          persona: machineDoc?.persona ?? machineSnapshot?.persona ?? null,
          assignedUserName: machineDoc?.assignedUserName ?? machineSnapshot?.assignedUserName ?? null,
          assignedUserEmail: machineDoc?.assignedUserEmail ?? machineSnapshot?.assignedUserEmail ?? null,
          status: machineDoc?.status ?? machineSnapshot?.status ?? null,
        };
      } else if (machineSnapshot) {
        machineSummary = {
          id: null,
          hostname: machineSnapshot.hostname ?? null,
          persona: machineSnapshot.persona ?? null,
          assignedUserName: machineSnapshot.assignedUserName ?? null,
          assignedUserEmail: machineSnapshot.assignedUserEmail ?? null,
          status: machineSnapshot.status ?? null,
        };
      }

      const categoryDoc = t.categoryId ? categoryDocs.get(String(t.categoryId)) ?? null : null;
      const categorySummary = categoryDoc
        ? { id: categoryDoc._id, name: categoryDoc.name }
        : null;

      const subcategoryDoc = t.subcategoryId
        ? subcategoryDocs.get(String(t.subcategoryId)) ?? null
        : null;
      const subcategorySummary = subcategoryDoc
        ? { id: subcategoryDoc._id, name: subcategoryDoc.name, categoryId: subcategoryDoc.categoryId }
        : null;

      const activeSessionDoc = t.activeSessionId
        ? activeSessionDocs.get(String(t.activeSessionId)) ?? null
        : null;
      const activeSession = activeSessionDoc
        ? {
            id: activeSessionDoc._id,
            agentId: activeSessionDoc.agentId,
            startedAt: activeSessionDoc.startedAt,
            workType: activeSessionDoc.workType ?? "INTERNAL",
          }
        : null;

      return {
        id: t._id,
        reference: t.reference,
        tenantId: t.tenantId,
        subject: t.subject,
        summary: t.summary,
        status: normalizeStatus(t.status),
        priority: t.priority,
        channel: t.channel,
        queue: queueName,
        csatScore: typeof t.csatScore === "number" ? t.csatScore : null,
        csatMaxScore: typeof t.csatMaxScore === "number" ? t.csatMaxScore : null,
        csatComment:
          typeof t.csatComment === "string" && t.csatComment.trim().length > 0
            ? t.csatComment.trim()
            : null,
        csatRatedAt: t.csatRatedAt ?? null,
        csatRatedBy: t.csatRatedBy ? String(t.csatRatedBy) : null,
        formTemplate: t.formTemplate ?? null,
        formTemplateLabel: resolveFormTemplateLabel(
          t.formTemplate ?? null,
          t.formTemplateLabel ?? null,
        ),
        company: companySummary,
        requester: requesterSummary,
      assignee: assigneeSummary,
      slaPolicy: null,
      dueAt: t.dueAt ?? null,
      visitStatus: t.visitStatus ?? null,
      visitPerformedAt: t.visitPerformedAt ?? null,
      firstResponseAt: t.firstResponseAt ?? null,
      resolvedAt: t.resolvedAt ?? null,
      updatedAt: t.updatedAt,
      createdAt: t.createdAt,
      tags: t.tags ?? [],
        lastTimelineEntry: null,
        metrics: null,
        category: categorySummary,
        subcategory: subcategorySummary,
        machine: machineSummary,
        workSummary: {
          totalWorkedMs: t.totalWorkedMs ?? 0,
          internalWorkedMs: t.internalWorkedMs ?? 0,
          externalWorkedMs: t.externalWorkedMs ?? 0,
          serverNow,
          activeSession,
        },
      };
    });

    // sort by updatedAt desc
    return result.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
  },
});

export const getById = query({
  args: { tenantId: v.string(), id: v.id("tickets"), viewerId: v.id("users") },
  handler: async (ctx, { tenantId, id, viewerId }) => {
    const { user, role } = await requireUser(ctx, viewerId, tenantId)
    const t = await ctx.db.get(id);
    if (!t || t.tenantId !== tenantId) return null;
    if (role === "COLLABORATOR") {
      const isOwnerById = String(t.requesterId) === String(viewerId)
      const snapshotEmail = (t.requesterSnapshot as { email?: string } | undefined)?.email?.trim().toLowerCase?.() ?? null
      const viewerEmail = user.email.trim().toLowerCase()
      const isOwnerByEmail = Boolean(snapshotEmail && snapshotEmail === viewerEmail)
      if (!isOwnerById && !isOwnerByEmail) {
        return null
      }
    }
    // no customer role; managers are constrained to company via ensureManagerTicketAccess
    let requester: Doc<"users"> | null = null
    if (role === "MANAGER") {
      requester = (await ensureManagerTicketAccess(ctx, user, t)) ?? null
    }
    if (!requester) {
      requester = (await ctx.db.get(t.requesterId)) as Doc<"users"> | null
    }
    const assignee = t.assigneeId ? ((await ctx.db.get(t.assigneeId)) as Doc<"users"> | null) : null;
    const queue = t.queueId ? ((await ctx.db.get(t.queueId)) as Doc<"queues"> | null) : null;
    const company = t.companyId ? ((await ctx.db.get(t.companyId)) as Doc<"companies"> | null) : null;
    const machineSnapshot = t.machineSnapshot as
      | {
          hostname?: string
          persona?: string
          assignedUserName?: string
          assignedUserEmail?: string
          status?: string
        }
      | undefined;
    let machineSummary:
      | {
          id: Id<"machines"> | null
          hostname: string | null
          persona: string | null
          assignedUserName: string | null
          assignedUserEmail: string | null
          status: string | null
        }
      | null = null;
    if (t.machineId) {
      const machineDoc = (await ctx.db.get(t.machineId)) as Doc<"machines"> | null;
      machineSummary = {
        id: t.machineId,
        hostname: machineDoc?.hostname ?? machineSnapshot?.hostname ?? null,
        persona: machineDoc?.persona ?? machineSnapshot?.persona ?? null,
        assignedUserName: machineDoc?.assignedUserName ?? machineSnapshot?.assignedUserName ?? null,
        assignedUserEmail: machineDoc?.assignedUserEmail ?? machineSnapshot?.assignedUserEmail ?? null,
        status: machineDoc?.status ?? machineSnapshot?.status ?? null,
      };
    } else if (machineSnapshot) {
      machineSummary = {
        id: null,
        hostname: machineSnapshot.hostname ?? null,
        persona: machineSnapshot.persona ?? null,
        assignedUserName: machineSnapshot.assignedUserName ?? null,
        assignedUserEmail: machineSnapshot.assignedUserEmail ?? null,
        status: machineSnapshot.status ?? null,
      };
    }
    const queueName = normalizeQueueName(queue);
    const category = t.categoryId ? await ctx.db.get(t.categoryId) : null;
    const subcategory = t.subcategoryId ? await ctx.db.get(t.subcategoryId) : null;
    const comments = await ctx.db
      .query("ticketComments")
      .withIndex("by_ticket", (q) => q.eq("ticketId", id))
      .take(50);
    const canViewInternalComments = role === "ADMIN" || role === "AGENT";
    const visibleComments = canViewInternalComments
      ? comments
      : comments.filter((comment) => comment.visibility !== "INTERNAL");
    const visibleCommentKeys = new Set(
      visibleComments.map((comment) => `${comment.createdAt}:${comment.authorId}`)
    )
    const visibleCommentTimestamps = new Set(visibleComments.map((comment) => comment.createdAt))
    const serverNow = Date.now()

    let timelineRecords = await ctx.db
      .query("ticketEvents")
      .withIndex("by_ticket", (q) => q.eq("ticketId", id))
      .take(50);

    if (!(role === "ADMIN" || role === "AGENT")) {
      timelineRecords = timelineRecords.filter((event) => {
        const payload = (event.payload ?? {}) as Record<string, unknown>
        switch (event.type) {
          case "CREATED":
            return true
          case "QUEUE_CHANGED":
            return true
          case "ASSIGNEE_CHANGED":
            return true
          case "CATEGORY_CHANGED":
            return true
          case "COMMENT_ADDED": {
            const authorIdRaw = (payload as { authorId?: string }).authorId
            if (typeof authorIdRaw === "string" && authorIdRaw.trim().length > 0) {
              const key = `${event.createdAt}:${authorIdRaw}`
              if (visibleCommentKeys.has(key)) {
                return true
              }
            }
            return visibleCommentTimestamps.has(event.createdAt)
          }
          case "STATUS_CHANGED": {
            const toLabelRaw = (payload as { toLabel?: string }).toLabel
            const toRaw = (payload as { to?: string }).to
            const normalized = (typeof toLabelRaw === "string" && toLabelRaw.trim().length > 0
              ? toLabelRaw.trim()
              : typeof toRaw === "string"
                ? toRaw.trim()
                : "").toUpperCase()
            if (!normalized) return false
            return (
              normalized === "RESOLVED" ||
              normalized === "RESOLVIDO" ||
              normalized === "CLOSED" ||
              normalized === "FINALIZADO" ||
              normalized === "FINALIZED"
            )
          }
          default:
            return false
        }
      })
    }

    const customFieldsRecord = mapCustomFieldsToRecord(
      (t.customFields as NormalizedCustomField[] | undefined) ?? undefined
    );

    const commentsHydrated = await Promise.all(
      visibleComments.map(async (c) => {
        const author = (await ctx.db.get(c.authorId)) as Doc<"users"> | null;
        const attachments = await Promise.all(
          (c.attachments ?? []).map(async (att) => ({
            id: att.storageId,
            name: att.name,
            size: att.size,
            type: att.type,
            url: await ctx.storage.getUrl(att.storageId),
          }))
        );
        const authorSummary = buildCommentAuthorSummary(c, author, {
          ticketId: t._id,
          commentId: c._id,
        });
        return {
          id: c._id,
          author: authorSummary,
          visibility: c.visibility,
          body: c.body,
          attachments,
          createdAt: c.createdAt,
          updatedAt: c.updatedAt,
        };
      })
    );

    const activeSession = t.activeSessionId ? await ctx.db.get(t.activeSessionId) : null;
    const perAgentTotals = await computeAgentWorkTotals(ctx, id, serverNow);

    return {
      id: t._id,
      reference: t.reference,
      tenantId: t.tenantId,
      subject: t.subject,
      summary: t.summary,
      status: normalizeStatus(t.status),
      priority: t.priority,
      channel: t.channel,
      queue: queueName,
      company: company
        ? { id: company._id, name: company.name, isAvulso: company.isAvulso ?? false }
        : t.companyId || t.companySnapshot
        ? buildCompanyFromSnapshot(t.companyId as Id<"companies"> | undefined, t.companySnapshot ?? undefined)
        : null,
      requester: requester
        ? buildRequesterSummary(requester, t.requesterId, { ticketId: t._id })
        : buildRequesterFromSnapshot(
            t.requesterId,
            t.requesterSnapshot ?? undefined,
            { ticketId: t._id }
          ),
      assignee: t.assigneeId
        ? assignee
          ? {
              id: assignee._id,
              name: assignee.name,
              email: assignee.email,
              avatarUrl: assignee.avatarUrl,
              teams: normalizeTeams(assignee.teams),
            }
          : buildAssigneeFromSnapshot(t.assigneeId, t.assigneeSnapshot ?? undefined)
        : null,
      slaPolicy: null,
      dueAt: t.dueAt ?? null,
      firstResponseAt: t.firstResponseAt ?? null,
      resolvedAt: t.resolvedAt ?? null,
      updatedAt: t.updatedAt,
      createdAt: t.createdAt,
      tags: t.tags ?? [],
      checklist: Array.isArray(t.checklist)
        ? t.checklist.map((item) => ({
            id: item.id,
            text: item.text,
            description: item.description ?? undefined,
            type: item.type ?? "checkbox",
            options: item.options ?? undefined,
            answer: item.answer ?? undefined,
            done: item.done,
            required: typeof item.required === "boolean" ? item.required : true,
            templateId: item.templateId ? String(item.templateId) : undefined,
            templateItemId: item.templateItemId ?? undefined,
            templateDescription: item.templateDescription ?? undefined,
            createdAt: item.createdAt ?? undefined,
            createdBy: item.createdBy ? String(item.createdBy) : undefined,
            doneAt: item.doneAt ?? undefined,
            doneBy: item.doneBy ? String(item.doneBy) : undefined,
          }))
        : [],
      lastTimelineEntry: null,
      metrics: null,
      csatScore: typeof t.csatScore === "number" ? t.csatScore : null,
      csatMaxScore: typeof t.csatMaxScore === "number" ? t.csatMaxScore : null,
      csatComment: typeof t.csatComment === "string" && t.csatComment.trim().length > 0 ? t.csatComment.trim() : null,
      csatRatedAt: t.csatRatedAt ?? null,
      csatRatedBy: t.csatRatedBy ? String(t.csatRatedBy) : null,
      machine: machineSummary,
      category: category
        ? {
            id: category._id,
            name: category.name,
          }
        : null,
      subcategory: subcategory
        ? {
            id: subcategory._id,
            name: subcategory.name,
            categoryId: subcategory.categoryId,
          }
        : null,
      workSummary: {
        totalWorkedMs: t.totalWorkedMs ?? 0,
        internalWorkedMs: t.internalWorkedMs ?? 0,
        externalWorkedMs: t.externalWorkedMs ?? 0,
        serverNow,
        activeSession: activeSession
          ? {
              id: activeSession._id,
              agentId: activeSession.agentId,
              startedAt: activeSession.startedAt,
              workType: activeSession.workType ?? "INTERNAL",
            }
          : null,
        perAgentTotals: perAgentTotals.map((item) => ({
          agentId: item.agentId,
          agentName: item.agentName,
          agentEmail: item.agentEmail,
          avatarUrl: item.avatarUrl,
          totalWorkedMs: item.totalWorkedMs,
          internalWorkedMs: item.internalWorkedMs,
          externalWorkedMs: item.externalWorkedMs,
        })),
      },
      formTemplate: t.formTemplate ?? null,
      formTemplateLabel: resolveFormTemplateLabel(t.formTemplate ?? null, t.formTemplateLabel ?? null),
      chatEnabled: Boolean(t.chatEnabled),
      relatedTicketIds: Array.isArray(t.relatedTicketIds) ? t.relatedTicketIds.map((id) => String(id)) : [],
      resolvedWithTicketId: t.resolvedWithTicketId ? String(t.resolvedWithTicketId) : null,
      reopenDeadline: t.reopenDeadline ?? null,
      reopenedAt: t.reopenedAt ?? null,
      visitStatus: t.visitStatus ?? null,
      visitPerformedAt: t.visitPerformedAt ?? null,
      description: undefined,
      customFields: customFieldsRecord,
      timeline: consolidateChatEventsInTimeline(timelineRecords).map((ev) => {
        let payload = ev.payload;
        if (ev.type === "QUEUE_CHANGED" && payload && typeof payload === "object" && "queueName" in payload) {
          const normalized = renameQueueString((payload as { queueName?: string }).queueName ?? null);
          if (normalized && normalized !== (payload as { queueName?: string }).queueName) {
            payload = { ...payload, queueName: normalized };
          }
        }
        return {
          id: ev._id,
          type: ev.type,
          payload,
          createdAt: ev.createdAt,
        };
      }),
      comments: commentsHydrated,
    };
  },
});

export const create = mutation({
  args: {
    actorId: v.id("users"),
    tenantId: v.string(),
    subject: v.string(),
    summary: v.optional(v.string()),
    priority: v.string(),
    channel: v.string(),
    queueId: v.optional(v.id("queues")),
    requesterId: v.id("users"),
    assigneeId: v.optional(v.id("users")),
    categoryId: v.id("ticketCategories"),
    subcategoryId: v.id("ticketSubcategories"),
    machineId: v.optional(v.id("machines")),
    checklist: v.optional(
      v.array(
        v.object({
          text: v.string(),
          required: v.optional(v.boolean()),
        })
      )
    ),
    checklistTemplateIds: v.optional(v.array(v.id("ticketChecklistTemplates"))),
    customFields: v.optional(
      v.array(
        v.object({
          fieldId: v.id("ticketFields"),
          value: v.any(),
        })
      )
    ),
    formTemplate: v.optional(v.string()),
    chatEnabled: v.optional(v.boolean()),
    visitDate: v.optional(v.number()),
  },
  handler: async (ctx, args) => {
    const { user: actorUser, role } = await requireUser(ctx, args.actorId, args.tenantId)
    // no customer role; managers validated below

    if (args.assigneeId && (!role || !INTERNAL_STAFF_ROLES.has(role))) {
      throw new ConvexError("Somente a equipe interna pode definir o responsável")
    }

    let initialAssigneeId: Id<"users"> | undefined
    let initialAssignee: Doc<"users"> | null = null

    if (args.assigneeId) {
      const assignee = (await ctx.db.get(args.assigneeId)) as Doc<"users"> | null
      if (!assignee || assignee.tenantId !== args.tenantId) {
        throw new ConvexError("Responsável inválido")
      }
      const normalizedAssigneeRole = (assignee.role ?? "AGENT").toUpperCase()
      if (!STAFF_ROLES.has(normalizedAssigneeRole)) {
        throw new ConvexError("Responsável inválido")
      }
      initialAssigneeId = assignee._id
      initialAssignee = assignee
    } else if (role && INTERNAL_STAFF_ROLES.has(role)) {
      initialAssigneeId = actorUser._id
      initialAssignee = actorUser
    }

    const subject = args.subject.trim();
    if (subject.length < 3) {
      throw new ConvexError("Informe um assunto com pelo menos 3 caracteres");
    }
    if (args.summary && args.summary.trim().length > MAX_SUMMARY_CHARS) {
      throw new ConvexError(`Resumo muito longo (máx. ${MAX_SUMMARY_CHARS} caracteres)`);
    }
    const category = await ctx.db.get(args.categoryId);
    if (!category || category.tenantId !== args.tenantId) {
      throw new ConvexError("Categoria inválida");
    }
    const subcategory = await ctx.db.get(args.subcategoryId);
    if (!subcategory || subcategory.categoryId !== args.categoryId || subcategory.tenantId !== args.tenantId) {
      throw new ConvexError("Subcategoria inválida");
    }

    const requester = (await ctx.db.get(args.requesterId)) as Doc<"users"> | null
    if (!requester || requester.tenantId !== args.tenantId) {
      throw new ConvexError("Solicitante inválido")
    }
    if (role === "MANAGER") {
      if (!actorUser.companyId) {
        throw new ConvexError("Gestor não possui empresa vinculada")
      }
      if (requester.companyId !== actorUser.companyId) {
        throw new ConvexError("Gestores só podem abrir chamados para sua própria empresa")
      }
    }

    let machineDoc: Doc<"machines"> | null = null
    if (args.machineId) {
      const machine = (await ctx.db.get(args.machineId)) as Doc<"machines"> | null
      if (!machine || machine.tenantId !== args.tenantId) {
        throw new ConvexError("Dispositivo inválida para este chamado")
      }
      machineDoc = machine
    }

    let formTemplateKey = normalizeFormTemplateKey(args.formTemplate ?? null);
    let formTemplateLabel: string | null = null;
    if (formTemplateKey) {
      const templateDoc = await getTemplateByKey(ctx, args.tenantId, formTemplateKey);
      if (templateDoc && templateDoc.isArchived !== true) {
        formTemplateLabel = templateDoc.label;
      } else {
        const fallbackTemplate = TICKET_FORM_CONFIG.find((tpl) => tpl.key === formTemplateKey);
        if (fallbackTemplate) {
          formTemplateLabel = fallbackTemplate.label;
        } else {
          formTemplateKey = null;
        }
      }
    }
    const chatEnabled = typeof args.chatEnabled === "boolean" ? args.chatEnabled : true;
    const normalizedCustomFields = await normalizeCustomFieldValues(
      ctx,
      args.tenantId,
      args.customFields ?? undefined,
      formTemplateKey,
    );
    // compute next reference (simple monotonic counter per tenant)
    const existing = await ctx.db
      .query("tickets")
      .withIndex("by_tenant_reference", (q) => q.eq("tenantId", args.tenantId))
      .order("desc")
      .take(1);
    const nextRef = existing[0]?.reference ? existing[0].reference + 1 : 41000;
    const now = Date.now();
    const initialStatus: TicketStatusNormalized = "PENDING";
    const manualChecklist: TicketChecklistItem[] = (args.checklist ?? []).map((entry) => {
      const text = normalizeChecklistText(entry.text ?? "");
      if (!text) {
        throw new ConvexError("Item do checklist inválido.")
      }
      if (text.length > 240) {
        throw new ConvexError("Item do checklist muito longo (máx. 240 caracteres).")
      }
      return {
        id: crypto.randomUUID(),
        text,
        done: false,
        required: typeof entry.required === "boolean" ? entry.required : true,
        createdAt: now,
        createdBy: args.actorId,
      }
    })
    const requesterSnapshot = {
      name: requester.name,
      email: requester.email,
      avatarUrl: requester.avatarUrl ?? undefined,
      teams: requester.teams ?? undefined,
    }
    const slaSnapshot = await resolveTicketSlaSnapshot(ctx, args.tenantId, category as Doc<"ticketCategories"> | null, args.priority)
    let companyDoc = requester.companyId ? (await ctx.db.get(requester.companyId)) : null
    if (!companyDoc && machineDoc?.companyId) {
      const candidateCompany = await ctx.db.get(machineDoc.companyId)
      if (candidateCompany && candidateCompany.tenantId === args.tenantId) {
        companyDoc = candidateCompany as Doc<"companies">
      }
    }
    const companySnapshot = companyDoc
      ? { name: companyDoc.name, slug: companyDoc.slug, isAvulso: companyDoc.isAvulso ?? undefined }
      : undefined
    const resolvedCompanyId = companyDoc?._id ?? requester.companyId ?? undefined

    let checklist = manualChecklist
    for (const templateId of args.checklistTemplateIds ?? []) {
      const template = await ctx.db.get(templateId)
      if (!template || template.tenantId !== args.tenantId || template.isArchived === true) {
        throw new ConvexError("Template de checklist inválido.")
      }
      if (template.companyId && (!resolvedCompanyId || String(template.companyId) !== String(resolvedCompanyId))) {
        throw new ConvexError("Template de checklist não pertence à empresa do ticket.")
      }
      checklist = applyChecklistTemplateToItems(checklist, template, { now, actorId: args.actorId }).checklist
    }

    const assigneeSnapshot = initialAssignee
      ? {
          name: initialAssignee.name,
          email: initialAssignee.email,
          avatarUrl: initialAssignee.avatarUrl ?? undefined,
          teams: initialAssignee.teams ?? undefined,
        }
      : undefined

    // default queue: if none provided, prefer "Chamados"
    let resolvedQueueId = args.queueId as Id<"queues"> | undefined
    if (!resolvedQueueId) {
      const queues = await ctx.db
        .query("queues")
        .withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId))
        .take(100)
      const preferred = queues.find((q) => q.slug === "chamados") || queues.find((q) => q.name === "Chamados") || null
      if (preferred) {
        resolvedQueueId = preferred._id as Id<"queues">
      }
    }

    const slaFields = applySlaSnapshot(slaSnapshot, now)

    let resolvedQueueDoc: Doc<"queues"> | null = null
    if (resolvedQueueId) {
      const queueDoc = await ctx.db.get(resolvedQueueId)
      if (queueDoc && queueDoc.tenantId === args.tenantId) {
        resolvedQueueDoc = queueDoc as Doc<"queues">
      }
    }

    const queueLabel = (resolvedQueueDoc?.slug ?? resolvedQueueDoc?.name ?? "").toLowerCase()
    const isVisitQueue = VISIT_QUEUE_KEYWORDS.some((keyword) => queueLabel.includes(keyword))
    const visitDueAt =
      typeof args.visitDate === "number" && Number.isFinite(args.visitDate) ? args.visitDate : null

    if (isVisitQueue && !visitDueAt) {
      throw new ConvexError("Informe a data da visita para tickets da fila de visitas")
    }
    const id = await ctx.db.insert("tickets", {
      tenantId: args.tenantId,
      reference: nextRef,
      subject,
      summary: args.summary?.trim() || undefined,
      status: initialStatus,
      priority: args.priority,
      channel: args.channel,
      queueId: resolvedQueueId,
      categoryId: args.categoryId,
      subcategoryId: args.subcategoryId,
      requesterId: args.requesterId,
      requesterSnapshot,
      assigneeId: initialAssigneeId,
      assigneeSnapshot,
      companyId: resolvedCompanyId,
      companySnapshot,
      machineId: machineDoc?._id ?? undefined,
      machineSnapshot: machineDoc
        ? {
            hostname: machineDoc.hostname ?? undefined,
            persona: machineDoc.persona ?? undefined,
            assignedUserName: machineDoc.assignedUserName ?? undefined,
            assignedUserEmail: machineDoc.assignedUserEmail ?? undefined,
            status: machineDoc.status ?? undefined,
          }
        : undefined,
      formTemplate: formTemplateKey ?? undefined,
      formTemplateLabel: formTemplateLabel ?? undefined,
      chatEnabled,
      working: false,
      activeSessionId: undefined,
      totalWorkedMs: 0,
      createdAt: now,
      updatedAt: now,
      firstResponseAt: undefined,
      resolvedAt: undefined,
      closedAt: undefined,
      tags: [],
      checklist: checklist.length > 0 ? checklist : undefined,
      slaPolicyId: undefined,
      dueAt: visitDueAt && isVisitQueue ? visitDueAt : undefined,
      customFields: normalizedCustomFields.length ? normalizedCustomFields : undefined,
      visitStatus: isVisitQueue ? "scheduled" : undefined,
      visitPerformedAt: undefined,
      ...slaFields,
    });
    await ctx.db.insert("ticketEvents", {
      ticketId: id,
      type: "CREATED",
      payload: { requesterId: args.requesterId, requesterName: requester?.name, requesterAvatar: requester?.avatarUrl },
      createdAt: now,
    });

    // Notificação por e-mail: ticket criado para o solicitante
    try {
      const requesterEmail = requester?.email
      if (requesterEmail) {
        const schedulerRunAfter = ctx.scheduler?.runAfter
        if (typeof schedulerRunAfter === "function") {
          await schedulerRunAfter(0, api.ticketNotifications.sendTicketCreatedEmail, {
            to: requesterEmail,
            userId: String(requester._id),
            userName: requester.name ?? undefined,
            ticketId: String(id),
            reference: nextRef,
            subject,
            priority: args.priority,
            tenantId: args.tenantId,
          })
        }
      }
    } catch (e) {
      console.warn("[tickets] Falha ao agendar e-mail de ticket criado", e)
    }

    if (initialAssigneeId && initialAssignee) {
      await ctx.db.insert("ticketEvents", {
        ticketId: id,
        type: "ASSIGNEE_CHANGED",
        payload: {
          assigneeId: initialAssigneeId,
          assigneeName: initialAssignee.name,
          actorId: args.actorId,
          actorName: actorUser.name,
          actorAvatar: actorUser.avatarUrl ?? undefined,
          previousAssigneeId: null,
          previousAssigneeName: "Não atribuído",
        },
        createdAt: now,
      })
    }

    await runTicketAutomationsForEvent(ctx, {
      tenantId: args.tenantId,
      ticketId: id,
      eventType: "TICKET_CREATED",
    })

    return id;
  },
});

function ensureChecklistEditor(viewer: { role: string | null }) {
  const normalizedRole = (viewer.role ?? "").toUpperCase();
  if (!INTERNAL_STAFF_ROLES.has(normalizedRole)) {
    throw new ConvexError("Apenas administradores e agentes podem alterar o checklist.");
  }
}

function normalizeTicketChecklist(list: unknown): TicketChecklistItem[] {
  if (!Array.isArray(list)) return [];
  return list as TicketChecklistItem[];
}

export const addChecklistItem = mutation({
  args: {
    ticketId: v.id("tickets"),
    actorId: v.id("users"),
    text: v.string(),
    required: v.optional(v.boolean()),
  },
  handler: async (ctx, { ticketId, actorId, text, required }) => {
    const ticket = await ctx.db.get(ticketId);
    if (!ticket) {
      throw new ConvexError("Ticket não encontrado");
    }
    const ticketDoc = ticket as Doc<"tickets">;
    const viewer = await requireTicketStaff(ctx, actorId, ticketDoc);
    ensureChecklistEditor(viewer);

    const normalizedText = normalizeChecklistText(text);
    if (!normalizedText) {
      throw new ConvexError("Informe o texto do item do checklist.");
    }
    if (normalizedText.length > 240) {
      throw new ConvexError("Item do checklist muito longo (máx. 240 caracteres).");
    }

    const now = Date.now();
    const item: TicketChecklistItem = {
      id: crypto.randomUUID(),
      text: normalizedText,
      done: false,
      required: typeof required === "boolean" ? required : true,
      createdAt: now,
      createdBy: actorId,
    };

    const checklist = normalizeTicketChecklist(ticketDoc.checklist).concat(item);

    await ctx.db.patch(ticketId, {
      checklist: checklist.length > 0 ? checklist : undefined,
      updatedAt: now,
    });

    return { ok: true, item };
  },
});

export const updateChecklistItemText = mutation({
  args: {
    ticketId: v.id("tickets"),
    actorId: v.id("users"),
    itemId: v.string(),
    text: v.string(),
  },
  handler: async (ctx, { ticketId, actorId, itemId, text }) => {
    const ticket = await ctx.db.get(ticketId);
    if (!ticket) {
      throw new ConvexError("Ticket não encontrado");
    }
    const ticketDoc = ticket as Doc<"tickets">;
    const viewer = await requireTicketStaff(ctx, actorId, ticketDoc);
    ensureChecklistEditor(viewer);

    const normalizedText = normalizeChecklistText(text);
    if (!normalizedText) {
      throw new ConvexError("Informe o texto do item do checklist.");
    }
    if (normalizedText.length > 240) {
      throw new ConvexError("Item do checklist muito longo (máx. 240 caracteres).");
    }

    const checklist = normalizeTicketChecklist(ticketDoc.checklist);
    const index = checklist.findIndex((item) => item.id === itemId);
    if (index < 0) {
      throw new ConvexError("Item do checklist não encontrado.");
    }

    const nextChecklist = checklist.map((item) =>
      item.id === itemId ? { ...item, text: normalizedText } : item
    );

    await ctx.db.patch(ticketId, { checklist: nextChecklist, updatedAt: Date.now() });
    return { ok: true };
  },
});

export const setChecklistItemDone = mutation({
  args: {
    ticketId: v.id("tickets"),
    actorId: v.id("users"),
    itemId: v.string(),
    done: v.boolean(),
  },
  handler: async (ctx, { ticketId, actorId, itemId, done }) => {
    const ticket = await ctx.db.get(ticketId);
    if (!ticket) {
      throw new ConvexError("Ticket não encontrado");
    }
    const ticketDoc = ticket as Doc<"tickets">;
    await requireTicketStaff(ctx, actorId, ticketDoc);

    const checklist = normalizeTicketChecklist(ticketDoc.checklist);
    const index = checklist.findIndex((item) => item.id === itemId);
    if (index < 0) {
      throw new ConvexError("Item do checklist não encontrado.");
    }

    const previous = checklist[index]!;
    if (previous.done === done) {
      return { ok: true };
    }

    const now = Date.now();
    const nextChecklist = checklist.map((item) => {
      if (item.id !== itemId) return item;
      if (done) {
        return { ...item, done: true, doneAt: now, doneBy: actorId };
      }
      return { ...item, done: false, doneAt: undefined, doneBy: undefined };
    });

    await ctx.db.patch(ticketId, { checklist: nextChecklist, updatedAt: now });
    return { ok: true };
  },
});

export const setChecklistItemRequired = mutation({
  args: {
    ticketId: v.id("tickets"),
    actorId: v.id("users"),
    itemId: v.string(),
    required: v.boolean(),
  },
  handler: async (ctx, { ticketId, actorId, itemId, required }) => {
    const ticket = await ctx.db.get(ticketId);
    if (!ticket) {
      throw new ConvexError("Ticket não encontrado");
    }
    const ticketDoc = ticket as Doc<"tickets">;
    const viewer = await requireTicketStaff(ctx, actorId, ticketDoc);
    ensureChecklistEditor(viewer);

    const checklist = normalizeTicketChecklist(ticketDoc.checklist);
    const index = checklist.findIndex((item) => item.id === itemId);
    if (index < 0) {
      throw new ConvexError("Item do checklist não encontrado.");
    }

    const nextChecklist = checklist.map((item) => (item.id === itemId ? { ...item, required: Boolean(required) } : item));
    await ctx.db.patch(ticketId, { checklist: nextChecklist, updatedAt: Date.now() });
    return { ok: true };
  },
});

export const setChecklistItemAnswer = mutation({
  args: {
    ticketId: v.id("tickets"),
    actorId: v.id("users"),
    itemId: v.string(),
    answer: v.optional(v.string()),
  },
  handler: async (ctx, { ticketId, actorId, itemId, answer }) => {
    const ticket = await ctx.db.get(ticketId);
    if (!ticket) {
      throw new ConvexError("Ticket não encontrado");
    }
    const ticketDoc = ticket as Doc<"tickets">;
    await requireTicketStaff(ctx, actorId, ticketDoc);

    const checklist = normalizeTicketChecklist(ticketDoc.checklist);
    const index = checklist.findIndex((item) => item.id === itemId);
    if (index < 0) {
      throw new ConvexError("Item do checklist não encontrado.");
    }

    const item = checklist[index]!;
    if (item.type !== "question") {
      throw new ConvexError("Este item não é uma pergunta.");
    }

    const now = Date.now();
    const normalizedAnswer = answer?.trim() ?? "";
    const isDone = normalizedAnswer.length > 0;

    const nextChecklist = checklist.map((it) => {
      if (it.id !== itemId) return it;
      if (isDone) {
        return { ...it, answer: normalizedAnswer, done: true, doneAt: now, doneBy: actorId };
      }
      return { ...it, answer: undefined, done: false, doneAt: undefined, doneBy: undefined };
    });

    await ctx.db.patch(ticketId, { checklist: nextChecklist, updatedAt: now });
    return { ok: true };
  },
});

export const removeChecklistItem = mutation({
  args: {
    ticketId: v.id("tickets"),
    actorId: v.id("users"),
    itemId: v.string(),
  },
  handler: async (ctx, { ticketId, actorId, itemId }) => {
    const ticket = await ctx.db.get(ticketId);
    if (!ticket) {
      throw new ConvexError("Ticket não encontrado");
    }
    const ticketDoc = ticket as Doc<"tickets">;
    const viewer = await requireTicketStaff(ctx, actorId, ticketDoc);
    ensureChecklistEditor(viewer);

    const checklist = normalizeTicketChecklist(ticketDoc.checklist);
    const nextChecklist = checklist.filter((item) => item.id !== itemId);
    await ctx.db.patch(ticketId, {
      checklist: nextChecklist.length > 0 ? nextChecklist : undefined,
      updatedAt: Date.now(),
    });
    return { ok: true };
  },
});

export const completeAllChecklistItems = mutation({
  args: {
    ticketId: v.id("tickets"),
    actorId: v.id("users"),
  },
  handler: async (ctx, { ticketId, actorId }) => {
    const ticket = await ctx.db.get(ticketId);
    if (!ticket) {
      throw new ConvexError("Ticket não encontrado");
    }
    const ticketDoc = ticket as Doc<"tickets">;
    const viewer = await requireTicketStaff(ctx, actorId, ticketDoc);
    ensureChecklistEditor(viewer);

    const checklist = normalizeTicketChecklist(ticketDoc.checklist);
    if (checklist.length === 0) return { ok: true };

    const now = Date.now();
    const nextChecklist = checklist.map((item) => {
      if (item.done === true) return item;
      return { ...item, done: true, doneAt: now, doneBy: actorId };
    });

    await ctx.db.patch(ticketId, { checklist: nextChecklist, updatedAt: now });
    return { ok: true };
  },
});

export const uncompleteAllChecklistItems = mutation({
  args: {
    ticketId: v.id("tickets"),
    actorId: v.id("users"),
  },
  handler: async (ctx, { ticketId, actorId }) => {
    const ticket = await ctx.db.get(ticketId);
    if (!ticket) {
      throw new ConvexError("Ticket não encontrado");
    }
    const ticketDoc = ticket as Doc<"tickets">;
    const viewer = await requireTicketStaff(ctx, actorId, ticketDoc);
    ensureChecklistEditor(viewer);

    const checklist = normalizeTicketChecklist(ticketDoc.checklist);
    if (checklist.length === 0) return { ok: true };

    const now = Date.now();
    const nextChecklist = checklist.map((item) => {
      if (item.done === false) return item;
      return { ...item, done: false, doneAt: undefined, doneBy: undefined };
    });

    await ctx.db.patch(ticketId, { checklist: nextChecklist, updatedAt: now });
    return { ok: true };
  },
});

export const applyChecklistTemplate = mutation({
  args: {
    ticketId: v.id("tickets"),
    actorId: v.id("users"),
    templateId: v.id("ticketChecklistTemplates"),
  },
  handler: async (ctx, { ticketId, actorId, templateId }) => {
    const ticket = await ctx.db.get(ticketId);
    if (!ticket) {
      throw new ConvexError("Ticket não encontrado");
    }
    const ticketDoc = ticket as Doc<"tickets">;
    const viewer = await requireTicketStaff(ctx, actorId, ticketDoc);
    ensureChecklistEditor(viewer);

    const template = await ctx.db.get(templateId);
    if (!template || template.tenantId !== ticketDoc.tenantId || template.isArchived === true) {
      throw new ConvexError("Template de checklist inválido.");
    }

    if (template.companyId && (!ticketDoc.companyId || String(template.companyId) !== String(ticketDoc.companyId))) {
      throw new ConvexError("Template de checklist não pertence à empresa do ticket.");
    }

    const now = Date.now();
    const current = normalizeTicketChecklist(ticketDoc.checklist);
    const result = applyChecklistTemplateToItems(current, template, { now, actorId });
    if (result.added === 0) {
      return { ok: true, added: 0 };
    }

    await ctx.db.patch(ticketId, { checklist: result.checklist, updatedAt: now });
    return { ok: true, added: result.added };
  },
});

export const addComment = mutation({
  args: {
    ticketId: v.id("tickets"),
    authorId: v.id("users"),
    visibility: v.string(),
    body: v.string(),
    attachments: v.optional(
      v.array(
        v.object({
          storageId: v.id("_storage"),
          name: v.string(),
          size: v.optional(v.number()),
          type: v.optional(v.string()),
        })
      )
    ),
  },
  handler: async (ctx, args) => {
    const ticket = await ctx.db.get(args.ticketId);
    if (!ticket) {
      throw new ConvexError("Ticket não encontrado")
    }
    const ticketDoc = ticket as Doc<"tickets">

    const author = (await ctx.db.get(args.authorId)) as Doc<"users"> | null
    if (!author || author.tenantId !== ticketDoc.tenantId) {
      throw new ConvexError("Autor do comentário inválido")
    }

    const normalizedRole = (author.role ?? "AGENT").toUpperCase()

    const requestedVisibility = (args.visibility ?? "").toUpperCase()
    if (requestedVisibility !== "PUBLIC" && requestedVisibility !== "INTERNAL") {
      throw new ConvexError("Visibilidade inválida")
    }

    if (normalizedRole === "MANAGER") {
      await ensureManagerTicketAccess(ctx, author, ticketDoc)
      if (requestedVisibility !== "PUBLIC") {
        throw new ConvexError("Gestores só podem registrar comentários públicos")
      }
    }
    const canUseInternalComments = normalizedRole === "ADMIN" || normalizedRole === "AGENT"
    if (requestedVisibility === "INTERNAL" && !canUseInternalComments) {
      throw new ConvexError("Apenas administradores e agentes podem registrar comentários internos")
    }

    // Regra: a equipe (ADMIN/AGENT/MANAGER) só pode comentar se o ticket tiver responsável.
    // O solicitante (colaborador) pode comentar sempre.
    const isRequester = String(ticketDoc.requesterId) === String(author._id)
    const isAdminOrAgent = normalizedRole === "ADMIN" || normalizedRole === "AGENT"
    const hasAssignee = Boolean(ticketDoc.assigneeId)
    // Gestores podem comentar mesmo sem responsável; admin/agent só com responsável
    if (!isRequester && isAdminOrAgent && !hasAssignee) {
      throw new ConvexError("Somente é possível comentar quando o chamado possui um responsável.")
    }

    if (ticketDoc.requesterId === args.authorId) {
      // O próprio solicitante pode comentar seu ticket.
      // Comentários internos já são bloqueados acima para quem não é STAFF.
      // Portanto, nada a fazer aqui.
    } else {
      await requireTicketStaff(ctx, args.authorId, ticketDoc)
    }

    const attachments = args.attachments ?? []
    if (attachments.length > 5) {
      throw new ConvexError("É permitido anexar no máximo 5 arquivos por comentário")
    }
    const maxAttachmentSize = 5 * 1024 * 1024
    for (const attachment of attachments) {
      if (typeof attachment.size === "number" && attachment.size > maxAttachmentSize) {
        throw new ConvexError("Cada anexo pode ter até 5MB")
      }
    }

    const authorSnapshot: CommentAuthorSnapshot = {
      name: author.name,
      email: author.email,
      avatarUrl: author.avatarUrl ?? undefined,
      teams: author.teams ?? undefined,
    };

    const normalizedBody = await normalizeTicketMentions(ctx, args.body, { user: author, role: normalizedRole }, ticketDoc.tenantId)
    const bodyPlainLen = plainTextLength(normalizedBody)
    if (bodyPlainLen > MAX_COMMENT_CHARS) {
      throw new ConvexError(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`)
    }

    const now = Date.now();
    const id = await ctx.db.insert("ticketComments", {
      ticketId: args.ticketId,
      authorId: args.authorId,
      visibility: requestedVisibility,
      body: normalizedBody,
      authorSnapshot,
      attachments,
      createdAt: now,
      updatedAt: now,
    });
    await ctx.db.insert("ticketEvents", {
      ticketId: args.ticketId,
      type: "COMMENT_ADDED",
      payload: { authorId: args.authorId, authorName: author.name, authorAvatar: author.avatarUrl },
      createdAt: now,
    });
    const isStaffResponder =
      requestedVisibility === "PUBLIC" &&
      !isRequester &&
      (normalizedRole === "ADMIN" || normalizedRole === "AGENT" || normalizedRole === "MANAGER");
    const responsePatch =
      isStaffResponder && !ticketDoc.firstResponseAt ? buildResponseCompletionPatch(ticketDoc, now) : {};
    await ctx.db.patch(args.ticketId, { updatedAt: now, ...responsePatch });
    // Notificação por e-mail: comentário público para o solicitante
    try {
      const requesterSnapshot = ticketDoc.requesterSnapshot as { email?: string; name?: string } | undefined
      const snapshotEmail = requesterSnapshot?.email
      if (requestedVisibility === "PUBLIC" && snapshotEmail && String(ticketDoc.requesterId) !== String(args.authorId)) {
        const schedulerRunAfter = ctx.scheduler?.runAfter
        if (typeof schedulerRunAfter === "function") {
          await schedulerRunAfter(0, api.ticketNotifications.sendPublicCommentEmail, {
            to: snapshotEmail,
            userId: ticketDoc.requesterId ? String(ticketDoc.requesterId) : undefined,
            userName: requesterSnapshot?.name ?? undefined,
            ticketId: String(ticketDoc._id),
            reference: ticketDoc.reference ?? 0,
            subject: ticketDoc.subject ?? "",
            tenantId: ticketDoc.tenantId,
          })
        }
      }
    } catch (e) {
      console.warn("[tickets] Falha ao agendar e-mail de comentário", e)
    }

    await runTicketAutomationsForEvent(ctx, {
      tenantId: ticketDoc.tenantId,
      ticketId: args.ticketId,
      eventType: "COMMENT_ADDED",
    })

    return id;
  },
});

export const updateComment = mutation({
  args: {
    ticketId: v.id("tickets"),
    commentId: v.id("ticketComments"),
    actorId: v.id("users"),
    body: v.string(),
  },
  handler: async (ctx, { ticketId, commentId, actorId, body }) => {
    const ticket = await ctx.db.get(ticketId)
    if (!ticket) {
      throw new ConvexError("Ticket não encontrado")
    }
    const ticketDoc = ticket as Doc<"tickets">
    const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null
    if (!actor || actor.tenantId !== ticketDoc.tenantId) {
      throw new ConvexError("Autor do comentário inválido")
    }
    const comment = await ctx.db.get(commentId);
    if (!comment || comment.ticketId !== ticketId) {
      throw new ConvexError("Comentário não encontrado");
    }
    if (comment.authorId !== actorId) {
      throw new ConvexError("Você não tem permissão para editar este comentário");
    }
    const normalizedRole = (actor.role ?? "AGENT").toUpperCase()
    if (ticketDoc.requesterId === actorId) {
      if (STAFF_ROLES.has(normalizedRole)) {
        await requireTicketStaff(ctx, actorId, ticketDoc)
      } else {
        throw new ConvexError("Autor não possui permissão para editar")
      }
    } else {
      await requireTicketStaff(ctx, actorId, ticketDoc)
    }

    const normalizedBody = await normalizeTicketMentions(ctx, body, { user: actor, role: normalizedRole }, ticketDoc.tenantId)
    const bodyPlainLen = plainTextLength(normalizedBody)
    if (bodyPlainLen > MAX_COMMENT_CHARS) {
      throw new ConvexError(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`)
    }

    const now = Date.now();
    await ctx.db.patch(commentId, {
      body: normalizedBody,
      updatedAt: now,
    });

    await ctx.db.insert("ticketEvents", {
      ticketId,
      type: "COMMENT_EDITED",
      payload: {
        commentId,
        actorId,
        actorName: actor?.name,
        actorAvatar: actor?.avatarUrl,
      },
      createdAt: now,
    });

    await ctx.db.patch(ticketId, { updatedAt: now });
  },
});

export const removeCommentAttachment = mutation({
  args: {
    ticketId: v.id("tickets"),
    commentId: v.id("ticketComments"),
    attachmentId: v.id("_storage"),
    actorId: v.id("users"),
  },
  handler: async (ctx, { ticketId, commentId, attachmentId, actorId }) => {
    const ticket = await ctx.db.get(ticketId)
    if (!ticket) {
      throw new ConvexError("Ticket não encontrado")
    }
    const ticketDoc = ticket as Doc<"tickets">
    const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null
    if (!actor || actor.tenantId !== ticketDoc.tenantId) {
      throw new ConvexError("Autor do comentário inválido")
    }
    const comment = await ctx.db.get(commentId);
    if (!comment || comment.ticketId !== ticketId) {
      throw new ConvexError("Comentário não encontrado");
    }
    if (comment.authorId !== actorId) {
      throw new ConvexError("Você não pode alterar anexos de outro usuário")
    }

    const normalizedRole = (actor.role ?? "AGENT").toUpperCase()
    if (ticketDoc.requesterId === actorId) {
      if (STAFF_ROLES.has(normalizedRole)) {
        await requireTicketStaff(ctx, actorId, ticketDoc)
      } else {
        throw new ConvexError("Autor não possui permissão para alterar anexos")
      }
    } else {
      await requireTicketStaff(ctx, actorId, ticketDoc)
    }

    const attachments = comment.attachments ?? [];
    const target = attachments.find((att) => att.storageId === attachmentId);
    if (!target) {
      throw new ConvexError("Anexo não encontrado");
    }

    await ctx.storage.delete(attachmentId);

    const now = Date.now();
    await ctx.db.patch(commentId, {
      attachments: attachments.filter((att) => att.storageId !== attachmentId),
      updatedAt: now,
    });

    await ctx.db.insert("ticketEvents", {
      ticketId,
      type: "ATTACHMENT_REMOVED",
      payload: {
        attachmentId,
        attachmentName: target.name,
        actorId,
        actorName: actor?.name,
        actorAvatar: actor?.avatarUrl,
      },
      createdAt: now,
    });

    await ctx.db.patch(ticketId, { updatedAt: now });
  },
});

export const updateStatus = mutation({
  args: { ticketId: v.id("tickets"), status: v.string(), actorId: v.id("users") },
  handler: async (ctx, { ticketId, status, actorId }) => {
    const ticket = await ctx.db.get(ticketId)
    if (!ticket) {
      throw new ConvexError("Ticket não encontrado")
    }
    const ticketDoc = ticket as Doc<"tickets">
    await requireTicketStaff(ctx, actorId, ticketDoc)
    const normalizedStatus = normalizeStatus(status)
    if (normalizedStatus === "AWAITING_ATTENDANCE" && !ticketDoc.activeSessionId) {
      throw new ConvexError("Inicie o atendimento antes de marcar o ticket como em andamento.")
    }
    const now = Date.now();
    const slaPatch = buildSlaStatusPatch(ticketDoc, normalizedStatus, now);
    await ctx.db.patch(ticketId, { status: normalizedStatus, updatedAt: now, ...slaPatch });
    await ctx.db.insert("ticketEvents", {
      ticketId,
      type: "STATUS_CHANGED",
      payload: { to: normalizedStatus, toLabel: STATUS_LABELS[normalizedStatus], actorId },
      createdAt: now,
    });

    await runTicketAutomationsForEvent(ctx, {
      tenantId: ticketDoc.tenantId,
      ticketId,
      eventType: "STATUS_CHANGED",
    })
  },
});

export async function resolveTicketHandler(
  ctx: MutationCtx,
  { ticketId, actorId, resolvedWithTicketId, relatedTicketIds, reopenWindowDays }: {
    ticketId: Id<"tickets">
    actorId: Id<"users">
    resolvedWithTicketId?: Id<"tickets">
    relatedTicketIds?: Id<"tickets">[]
    reopenWindowDays?: number | null
  }
) {
    const ticket = await ctx.db.get(ticketId)
    if (!ticket) {
      throw new ConvexError("Ticket não encontrado")
    }
    const ticketDoc = ticket as Doc<"tickets">
    const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
    const now = Date.now()

    if (checklistBlocksResolution((ticketDoc.checklist ?? []) as unknown as TicketChecklistItem[])) {
      throw new ConvexError("Conclua todos os itens obrigatórios do checklist antes de encerrar o ticket.")
    }

    const baseRelated = new Set<string>()
    for (const rel of relatedTicketIds ?? []) {
      if (String(rel) === String(ticketId)) continue
      baseRelated.add(String(rel))
    }
    if (resolvedWithTicketId && String(resolvedWithTicketId) !== String(ticketId)) {
      baseRelated.add(String(resolvedWithTicketId))
    }

    const linkedTickets: Doc<"tickets">[] = []
    for (const id of baseRelated) {
      const related = await ctx.db.get(id as Id<"tickets">)
      if (!related) continue
      if (related.tenantId !== ticketDoc.tenantId) {
        throw new ConvexError("Chamado vinculado pertence a outro tenant")
      }
      linkedTickets.push(related as Doc<"tickets">)
    }

    const resolvedWith =
      resolvedWithTicketId && String(resolvedWithTicketId) !== String(ticketId)
        ? (await ctx.db.get(resolvedWithTicketId)) ?? null
        : null
    if (resolvedWith && resolvedWith.tenantId !== ticketDoc.tenantId) {
      throw new ConvexError("Chamado vinculado pertence a outro tenant")
    }
    if (resolvedWithTicketId && !resolvedWith) {
      throw new ConvexError("Chamado vinculado não encontrado")
    }

    const reopenDays = resolveReopenWindowDays(reopenWindowDays)
    const reopenDeadline = computeReopenDeadline(now, reopenDays)
    const normalizedStatus = "RESOLVED"
    const relatedIdList = Array.from(
      new Set<string>(
        linkedTickets.map((rel) => String(rel._id)),
      ),
    ).map((id) => id as Id<"tickets">)

    const slaPausePatch = buildSlaStatusPatch(ticketDoc, normalizedStatus, now);
    const mergedTicket = mergeTicketState(ticketDoc, slaPausePatch);
    const slaSolutionPatch = buildSolutionCompletionPatch(mergedTicket, now);

    await ctx.db.patch(ticketId, {
      status: normalizedStatus,
      resolvedAt: now,
      closedAt: now,
      updatedAt: now,
      reopenDeadline,
      reopenedAt: undefined,
      resolvedWithTicketId: resolvedWith ? resolvedWith._id : undefined,
      relatedTicketIds: relatedIdList.length ? relatedIdList : undefined,
      activeSessionId: undefined,
      working: false,
      ...slaPausePatch,
      ...slaSolutionPatch,
    })

    await ctx.db.insert("ticketEvents", {
      ticketId,
      type: "STATUS_CHANGED",
      payload: { to: normalizedStatus, toLabel: STATUS_LABELS[normalizedStatus], actorId },
      createdAt: now,
    })

    // Notificação por e-mail: encerramento do chamado
    try {
      const requesterDoc = await ctx.db.get(ticketDoc.requesterId) as Doc<"users"> | null
      const requesterSnapshot = ticketDoc.requesterSnapshot as { email?: string; name?: string } | undefined
      const email = requesterDoc?.email || requesterSnapshot?.email || null
      const userName = requesterDoc?.name || requesterSnapshot?.name || undefined
      if (email) {
        const schedulerRunAfter = ctx.scheduler?.runAfter
        if (typeof schedulerRunAfter === "function") {
          await schedulerRunAfter(0, api.ticketNotifications.sendResolvedEmail, {
            to: email,
            userId: ticketDoc.requesterId ? String(ticketDoc.requesterId) : undefined,
            userName,
            ticketId: String(ticketId),
            reference: ticketDoc.reference ?? 0,
            subject: ticketDoc.subject ?? "",
            tenantId: ticketDoc.tenantId,
          })
        }
      }
    } catch (e) {
      console.warn("[tickets] Falha ao agendar e-mail de encerramento", e)
    }

    for (const rel of linkedTickets) {
      const existing = new Set<string>((rel.relatedTicketIds ?? []).map((value) => String(value)))
      existing.add(String(ticketId))
      await ctx.db.patch(rel._id, {
        relatedTicketIds: Array.from(existing).map((value) => value as Id<"tickets">),
        updatedAt: now,
      })
      const linkKind = resolvedWith && String(resolvedWith._id) === String(rel._id) ? "resolved_with" : "related"
      await ctx.db.insert("ticketEvents", {
        ticketId,
        type: "TICKET_LINKED",
        payload: {
          actorId,
          actorName: viewer.user.name,
          linkedTicketId: rel._id,
          linkedReference: rel.reference ?? null,
          linkedSubject: rel.subject ?? null,
          kind: linkKind,
        },
        createdAt: now,
      })
      await ctx.db.insert("ticketEvents", {
        ticketId: rel._id,
        type: "TICKET_LINKED",
        payload: {
          actorId,
          actorName: viewer.user.name,
          linkedTicketId: ticketId,
          linkedReference: ticketDoc.reference ?? null,
          linkedSubject: ticketDoc.subject ?? null,
          kind: linkKind === "resolved_with" ? "resolution_parent" : "related",
        },
        createdAt: now,
      })
    }

    await runTicketAutomationsForEvent(ctx, {
      tenantId: ticketDoc.tenantId,
      ticketId,
      eventType: "TICKET_RESOLVED",
    })

    return { ok: true, reopenDeadline, reopenWindowDays: reopenDays }
}

export const resolveTicket = mutation({
  args: {
    ticketId: v.id("tickets"),
    actorId: v.id("users"),
    resolvedWithTicketId: v.optional(v.id("tickets")),
    relatedTicketIds: v.optional(v.array(v.id("tickets"))),
    reopenWindowDays: v.optional(v.number()),
  },
  handler: resolveTicketHandler,
})

export async function reopenTicketHandler(
  ctx: MutationCtx,
  { ticketId, actorId }: { ticketId: Id<"tickets">; actorId: Id<"users"> }
) {
    const ticket = await ctx.db.get(ticketId)
    if (!ticket) {
      throw new ConvexError("Ticket não encontrado")
    }
    const ticketDoc = ticket as Doc<"tickets">
    const viewer = await requireUser(ctx, actorId, ticketDoc.tenantId)
    const normalizedRole = viewer.role ?? ""
    const now = Date.now()
    const status = normalizeStatus(ticketDoc.status)
    if (status !== "RESOLVED") {
      throw new ConvexError("Somente chamados resolvidos podem ser reabertos")
    }
    if (!isWithinReopenWindow(ticketDoc, now)) {
      throw new ConvexError("O prazo para reabrir este chamado expirou")
    }
    if (normalizedRole === "COLLABORATOR") {
      if (String(ticketDoc.requesterId) !== String(actorId)) {
        throw new ConvexError("Somente o solicitante pode reabrir este chamado")
      }
    } else if (normalizedRole === "MANAGER") {
      await ensureManagerTicketAccess(ctx, viewer.user, ticketDoc)
    } else if (normalizedRole !== "ADMIN" && normalizedRole !== "AGENT") {
      throw new ConvexError("Usuário não possui permissão para reabrir este chamado")
    }

    const slaPatch = buildSlaStatusPatch(ticketDoc, "AWAITING_ATTENDANCE", now)
    await ctx.db.patch(ticketId, {
      status: "AWAITING_ATTENDANCE",
      reopenedAt: now,
      resolvedAt: undefined,
      closedAt: undefined,
      updatedAt: now,
      ...slaPatch,
    })

    await ctx.db.insert("ticketEvents", {
      ticketId,
      type: "TICKET_REOPENED",
      payload: { actorId, actorName: viewer.user.name, actorRole: normalizedRole },
      createdAt: now,
    })

    await ctx.db.insert("ticketEvents", {
      ticketId,
      type: "STATUS_CHANGED",
      payload: { to: "AWAITING_ATTENDANCE", toLabel: STATUS_LABELS.AWAITING_ATTENDANCE, actorId },
      createdAt: now,
    })

    return { ok: true, reopenedAt: now }
}

export const reopenTicket = mutation({
  args: {
    ticketId: v.id("tickets"),
    actorId: v.id("users"),
  },
  handler: reopenTicketHandler,
})

export const changeAssignee = mutation({
  args: {
    ticketId: v.id("tickets"),
    assigneeId: v.id("users"),
    actorId: v.id("users"),
    reason: v.optional(v.string()),
  },
  handler: async (ctx, { ticketId, assigneeId, actorId, reason }) => {
    const ticket = await ctx.db.get(ticketId)
    if (!ticket) {
      throw new ConvexError("Ticket não encontrado")
    }
    const ticketDoc = ticket as Doc<"tickets">
    const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
    const viewerUser = viewer.user
    const isAdmin = viewer.role === "ADMIN"
    const assignee = (await ctx.db.get(assigneeId)) as Doc<"users"> | null
    if (!assignee || assignee.tenantId !== ticketDoc.tenantId) {
      throw new ConvexError("Responsável inválido")
    }
    if (viewer.role === "MANAGER") {
      throw new ConvexError("Gestores não podem reatribuir chamados")
    }
    const hasActiveSession = Boolean(ticketDoc.activeSessionId)
    const currentAssigneeId = ticketDoc.assigneeId ?? null
    if (currentAssigneeId && currentAssigneeId !== actorId && !isAdmin) {
      throw new ConvexError("Somente o responsável atual pode reatribuir este chamado")
    }

    const normalizedReason = (typeof reason === "string" ? reason : "").replace(/\r\n/g, "\n").trim()
    if (normalizedReason.length > 0 && normalizedReason.length < 5) {
      throw new ConvexError("Informe um motivo com pelo menos 5 caracteres ou deixe em branco")
    }
    if (normalizedReason.length > 1000) {
      throw new ConvexError("Motivo muito longo (máx. 1000 caracteres)")
    }
    const previousAssigneeName =
      ((ticketDoc.assigneeSnapshot as { name?: string } | null)?.name as string | undefined) ??
      "Não atribuído"
    const nextAssigneeName = assignee.name ?? assignee.email ?? "Responsável"

    const now = Date.now();
    const assigneeSnapshot = {
      name: assignee.name,
      email: assignee.email,
      avatarUrl: assignee.avatarUrl ?? undefined,
      teams: assignee.teams ?? undefined,
    }
    const ticketPatch: Partial<Doc<"tickets">> = {
      assigneeId,
      assigneeSnapshot,
      updatedAt: now,
    }

    if (hasActiveSession) {
      const session = await ctx.db.get(ticketDoc.activeSessionId as Id<"ticketWorkSessions">)
      if (session) {
        const durationMs = Math.max(0, now - session.startedAt)
        const sessionType = (session.workType ?? "INTERNAL").toUpperCase()
        const deltaInternal = sessionType === "INTERNAL" ? durationMs : 0
        const deltaExternal = sessionType === "EXTERNAL" ? durationMs : 0

        await ctx.db.patch(session._id, {
          stoppedAt: now,
          durationMs,
        })

        ticketPatch.totalWorkedMs = (ticketDoc.totalWorkedMs ?? 0) + durationMs
        ticketPatch.internalWorkedMs = (ticketDoc.internalWorkedMs ?? 0) + deltaInternal
        ticketPatch.externalWorkedMs = (ticketDoc.externalWorkedMs ?? 0) + deltaExternal

        const newSessionId = await ctx.db.insert("ticketWorkSessions", {
          ticketId,
          agentId: assigneeId,
          workType: sessionType,
          startedAt: now,
        })

        ticketPatch.activeSessionId = newSessionId
        ticketPatch.working = true
        ticketPatch.status = "AWAITING_ATTENDANCE"

        ticketDoc.totalWorkedMs = ticketPatch.totalWorkedMs as number
        ticketDoc.internalWorkedMs = ticketPatch.internalWorkedMs as number
        ticketDoc.externalWorkedMs = ticketPatch.externalWorkedMs as number
        ticketDoc.activeSessionId = newSessionId
      } else {
        ticketPatch.activeSessionId = undefined
        ticketPatch.working = false
      }
    }

    await ctx.db.patch(ticketId, ticketPatch);
    await ctx.db.insert("ticketEvents", {
      ticketId,
      type: "ASSIGNEE_CHANGED",
      payload: {
        assigneeId,
        assigneeName: assignee.name,
        actorId,
        actorName: viewerUser.name,
        actorAvatar: viewerUser.avatarUrl ?? undefined,
        previousAssigneeId: currentAssigneeId,
        previousAssigneeName,
        reason: normalizedReason.length > 0 ? normalizedReason : undefined,
      },
      createdAt: now,
    });

  },
});

export const listChatMessages = query({
  args: {
    ticketId: v.id("tickets"),
    viewerId: v.id("users"),
  },
  handler: async (ctx, { ticketId, viewerId }) => {
    const ticket = await ctx.db.get(ticketId)
    if (!ticket) {
      throw new ConvexError("Ticket não encontrado")
    }
    const ticketDoc = ticket as Doc<"tickets">
    await requireTicketChatParticipant(ctx, viewerId, ticketDoc)
    const now = Date.now()
    const status = normalizeStatus(ticketDoc.status)
    const chatEnabled = Boolean(ticketDoc.chatEnabled)
    const withinWindow = isWithinReopenWindow(ticketDoc, now)
    const canPost = chatEnabled && (status !== "RESOLVED" || withinWindow)
    // Busca as 50 mensagens mais recentes (desc) e reverte para ordem cronológica
    const messages = await ctx.db
      .query("ticketChatMessages")
      .withIndex("by_ticket_created", (q) => q.eq("ticketId", ticketId))
      .order("desc")
      .take(50)
      .then((msgs) => msgs.reverse())

    // Verificar maquina e sessao de chat ao vivo
    let liveChat: {
      hasMachine: boolean
      machineOnline: boolean
      machineHostname: string | null
      activeSession: {
        sessionId: Id<"liveChatSessions">
        agentId: Id<"users">
        agentName: string | null
        startedAt: number
        unreadByAgent: number
      } | null
    } = {
      hasMachine: false,
      machineOnline: false,
      machineHostname: null,
      activeSession: null,
    }

    if (ticketDoc.machineId) {
      const machine = await ctx.db.get(ticketDoc.machineId)
      if (machine) {
        const fiveMinutesAgo = now - 5 * 60 * 1000
        liveChat.hasMachine = true
        // Usa tabela de heartbeats (fonte de verdade) com fallback para campo legado
        let lastHeartbeatAt: number | null = null
        const hb = await ctx.db
          .query("machineHeartbeats")
          .withIndex("by_machine", (q) => q.eq("machineId", machine._id))
          .first()
        if (hb?.lastHeartbeatAt) {
          lastHeartbeatAt = hb.lastHeartbeatAt
        } else if (machine.lastHeartbeatAt) {
          lastHeartbeatAt = machine.lastHeartbeatAt
        }

        liveChat.machineOnline = Boolean(lastHeartbeatAt && lastHeartbeatAt > fiveMinutesAgo)
        liveChat.machineHostname = machine.hostname

        // Verificar sessao ativa
        const activeSession = await ctx.db
          .query("liveChatSessions")
          .withIndex("by_ticket", (q) => q.eq("ticketId", ticketId))
          .filter((q) => q.eq(q.field("status"), "ACTIVE"))
          .first()

        if (activeSession) {
          liveChat.activeSession = {
            sessionId: activeSession._id,
            agentId: activeSession.agentId,
            agentName: activeSession.agentSnapshot?.name ?? null,
            startedAt: activeSession.startedAt,
            unreadByAgent: activeSession.unreadByAgent ?? 0,
          }
        }
      }
    }

    return {
      ticketId: String(ticketId),
      chatEnabled,
      status,
      canPost,
      reopenDeadline: ticketDoc.reopenDeadline ?? null,
      liveChat,
      messages: messages
        .sort((a, b) => a.createdAt - b.createdAt)
        .map((message) => ({
          id: message._id,
          body: message.body,
          createdAt: message.createdAt,
          updatedAt: message.updatedAt,
          authorId: String(message.authorId),
          authorName: message.authorSnapshot?.name ?? null,
          authorEmail: message.authorSnapshot?.email ?? null,
          attachments: (message.attachments ?? []).map((attachment) => ({
            storageId: attachment.storageId,
            name: attachment.name,
            size: attachment.size ?? null,
            type: attachment.type ?? null,
          })),
          readBy: (message.readBy ?? []).map((entry) => ({
            userId: String(entry.userId),
            readAt: entry.readAt,
          })),
        })),
    }
  },
})

export const listTicketForms = query({
  args: {
    tenantId: v.string(),
    viewerId: v.id("users"),
    companyId: v.optional(v.id("companies")),
  },
  handler: async (ctx, { tenantId, viewerId, companyId }) => {
    const viewer = await requireUser(ctx, viewerId, tenantId)
    const viewerCompanyId = companyId ?? viewer.user.companyId ?? null
    const viewerRole = (viewer.role ?? "").toUpperCase()
    const templates = await fetchTemplateSummaries(ctx, tenantId)

    const defaultTemplate: TemplateSummary = {
      key: "default",
      label: "Chamado",
      description: "Campos adicionais exibidos em chamados gerais ou sem template específico.",
      defaultEnabled: true,
    }

    const templatesWithDefault = [defaultTemplate, ...templates.filter((template) => template.key !== "default")]

    const scopes = templatesWithDefault.map((template) => template.key)
    const fieldsByScope = await fetchTicketFieldsByScopes(ctx, tenantId, scopes, viewerCompanyId)

    const staffOverride = viewerRole === "ADMIN" || viewerRole === "AGENT"
    const settingsByTemplate = staffOverride
      ? new Map<string, Doc<"ticketFormSettings">[]>()
      : await fetchViewerScopedFormSettings(ctx, tenantId, scopes, viewer.user._id, viewerCompanyId)

    const forms = [] as Array<{
      key: string
      label: string
      description: string
      fields: Array<{
        id: Id<"ticketFields">
        key: string
        label: string
        type: string
        required: boolean
        description: string
        options: { value: string; label: string }[]
      }>
    }>

    for (const template of templatesWithDefault) {
      const templateSettings = settingsByTemplate.get(template.key) ?? []
      let enabled = staffOverride
        ? true
        : resolveFormEnabled(template.key, template.defaultEnabled, templateSettings, {
            companyId: viewerCompanyId,
            userId: viewer.user._id,
          })
      if (!enabled) {
        continue
      }
      const scopedFields = fieldsByScope.get(template.key) ?? []
      forms.push({
        key: template.key,
        label: template.label,
        description: template.description,
        fields: scopedFields
          .sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
          .map((field) => ({
            id: field._id,
            key: field.key,
            label: field.label,
            type: field.type,
            required: Boolean(field.required),
            description: field.description ?? "",
            options: field.options ?? [],
          })),
      })
    }

    return forms
  },
})

export const findByReference = query({
  args: {
    tenantId: v.string(),
    viewerId: v.id("users"),
    reference: v.number(),
  },
  handler: async (ctx, { tenantId, viewerId, reference }) => {
    const viewer = await requireUser(ctx, viewerId, tenantId)
    const ticket = await ctx.db
      .query("tickets")
      .withIndex("by_tenant_reference", (q) => q.eq("tenantId", tenantId).eq("reference", reference))
      .first()
    if (!ticket) {
      return null
    }
    const normalizedRole = viewer.role ?? ""
    if (normalizedRole === "MANAGER") {
      await ensureManagerTicketAccess(ctx, viewer.user, ticket as Doc<"tickets">)
    } else if (normalizedRole === "COLLABORATOR") {
      if (String(ticket.requesterId) !== String(viewer.user._id)) {
        return null
      }
    } else if (normalizedRole !== "ADMIN" && normalizedRole !== "AGENT") {
      return null
    }
    return {
      id: ticket._id,
      reference: ticket.reference,
      subject: ticket.subject,
      status: ticket.status,
    }
  },
})

export const postChatMessage = mutation({
  args: {
    ticketId: v.id("tickets"),
    actorId: v.id("users"),
    body: v.string(),
    attachments: v.optional(
      v.array(
        v.object({
          storageId: v.id("_storage"),
          name: v.string(),
          size: v.optional(v.number()),
          type: v.optional(v.string()),
        })
      )
    ),
  },
  handler: async (ctx, { ticketId, actorId, body, attachments }) => {
    const ticket = await ctx.db.get(ticketId)
    if (!ticket) {
      throw new ConvexError("Ticket não encontrado")
    }
    const ticketDoc = ticket as Doc<"tickets">
    if (!ticketDoc.chatEnabled) {
      throw new ConvexError("Chat não habilitado para este chamado")
    }
    const participant = await requireTicketChatParticipant(ctx, actorId, ticketDoc)
    const now = Date.now()
    if (!isWithinReopenWindow(ticketDoc, now) && normalizeStatus(ticketDoc.status) === "RESOLVED") {
      throw new ConvexError("O chat deste chamado está encerrado")
    }

    const trimmedBody = body.replace(/\r\n/g, "\n").trim()
    const files = attachments ?? []

    // Validar que há pelo menos texto ou anexo
    if (trimmedBody.length === 0 && files.length === 0) {
      throw new ConvexError("Digite uma mensagem ou anexe um arquivo")
    }
    if (trimmedBody.length > 4000) {
      throw new ConvexError("Mensagem muito longa (máx. 4000 caracteres)")
    }

    // Validar anexos
    if (files.length > 5) {
      throw new ConvexError("Envie até 5 arquivos por mensagem")
    }
    const maxAttachmentSize = 5 * 1024 * 1024
    for (const file of files) {
      if (typeof file.size === "number" && file.size > maxAttachmentSize) {
        throw new ConvexError("Cada arquivo pode ter até 5MB")
      }
    }

    // Normalizar corpo apenas se houver texto
    let normalizedBody = ""
    if (trimmedBody.length > 0) {
      normalizedBody = await normalizeTicketMentions(ctx, trimmedBody, { user: participant.user, role: participant.role ?? "" }, ticketDoc.tenantId)
      const plainLength = plainTextLength(normalizedBody)
      if (plainLength > 4000) {
        throw new ConvexError("Mensagem muito longa (máx. 4000 caracteres)")
      }
    }

    const authorSnapshot: CommentAuthorSnapshot = {
      name: participant.user.name,
      email: participant.user.email,
      avatarUrl: participant.user.avatarUrl ?? undefined,
      teams: participant.user.teams ?? undefined,
    }

    const messageId = await ctx.db.insert("ticketChatMessages", {
      ticketId,
      tenantId: ticketDoc.tenantId,
      companyId: ticketDoc.companyId ?? undefined,
      authorId: actorId,
      authorSnapshot,
      body: normalizedBody,
      attachments: files,
      notifiedAt: undefined,
      createdAt: now,
      updatedAt: now,
      readBy: [{ userId: actorId, readAt: now }],
    })

    await ctx.db.insert("ticketEvents", {
      ticketId,
      type: "CHAT_MESSAGE_ADDED",
      payload: {
        messageId,
        authorId: actorId,
        authorName: participant.user.name,
        actorRole: participant.role ?? null,
      },
      createdAt: now,
    })

    await ctx.db.patch(ticketId, { updatedAt: now })

    // Se o autor for um agente (ADMIN, MANAGER, AGENT), incrementar unreadByMachine na sessao de chat ativa
    // IMPORTANTE: Buscar sessao IMEDIATAMENTE antes do patch para evitar race conditions
    // O Convex faz retry automatico em caso de OCC conflict
    const actorRole = participant.role?.toUpperCase() ?? ""
    if (["ADMIN", "MANAGER", "AGENT"].includes(actorRole)) {
      const activeSession = await ctx.db
        .query("liveChatSessions")
        .withIndex("by_ticket", (q) => q.eq("ticketId", ticketId))
        .filter((q) => q.eq(q.field("status"), "ACTIVE"))
        .first()

      if (activeSession) {
        // Refetch para garantir valor mais recente (OCC protection)
        const freshSession = await ctx.db.get(activeSession._id)
        if (freshSession) {
          await ctx.db.patch(activeSession._id, {
            unreadByMachine: (freshSession.unreadByMachine ?? 0) + 1,
            lastActivityAt: now,
            lastAgentMessageAt: now, // Novo: timestamp da ultima mensagem do agente
          })
        }
      }
    }

    return { ok: true, messageId }
  },
})

export const markChatRead = mutation({
  args: {
    ticketId: v.id("tickets"),
    actorId: v.id("users"),
    messageIds: v.array(v.id("ticketChatMessages")),
  },
  handler: async (ctx, { ticketId, actorId, messageIds }) => {
    const ticket = await ctx.db.get(ticketId)
    if (!ticket) {
      throw new ConvexError("Ticket não encontrado")
    }
    const ticketDoc = ticket as Doc<"tickets">
    await requireTicketChatParticipant(ctx, actorId, ticketDoc)
    const uniqueIds = Array.from(new Set(messageIds.map((id) => String(id))))
    const now = Date.now()
    for (const id of uniqueIds) {
      const message = await ctx.db.get(id as Id<"ticketChatMessages">)
      if (!message || String(message.ticketId) !== String(ticketId)) {
        continue
      }
      const readBy = new Map<string, { userId: Id<"users">; readAt: number }>()
      for (const entry of message.readBy ?? []) {
        readBy.set(String(entry.userId), { userId: entry.userId, readAt: entry.readAt })
      }
      readBy.set(String(actorId), { userId: actorId, readAt: now })
      await ctx.db.patch(id as Id<"ticketChatMessages">, {
        readBy: Array.from(readBy.values()),
        updatedAt: now,
      })
    }

    // Zerar contador de nao lidas pelo agente na sessao ativa
    const session = await ctx.db
      .query("liveChatSessions")
      .withIndex("by_ticket", (q) => q.eq("ticketId", ticketId))
      .filter((q) => q.eq(q.field("status"), "ACTIVE"))
      .first()

    if (session) {
      await ctx.db.patch(session._id, { unreadByAgent: 0 })
    }

    return { ok: true }
  },
})

export const ensureTicketFormDefaults = mutation({
  args: {
    tenantId: v.string(),
    actorId: v.id("users"),
  },
  handler: async (ctx, { tenantId, actorId }) => {
    await requireUser(ctx, actorId, tenantId);
    await ensureTicketFormDefaultsForTenant(ctx, tenantId);
    return { ok: true };
  },
});

export async function submitCsatHandler(
  ctx: MutationCtx,
  { ticketId, actorId, score, maxScore, comment }: { ticketId: Id<"tickets">; actorId: Id<"users">; score: number; maxScore?: number | null; comment?: string | null }
) {
  const ticket = await ctx.db.get(ticketId)
  if (!ticket) {
    throw new ConvexError("Ticket não encontrado")
  }

  const normalizedStatus = normalizeStatus(ticket.status)
  if (normalizedStatus !== "RESOLVED") {
    throw new ConvexError("Avaliações só são permitidas após o encerramento do chamado")
  }

  const viewer = await requireUser(ctx, actorId, ticket.tenantId)
  const normalizedRole = (viewer.role ?? "").toUpperCase()
  if (normalizedRole !== "COLLABORATOR") {
    throw new ConvexError("Somente o solicitante pode avaliar o chamado")
  }

  const viewerEmail = viewer.user.email.trim().toLowerCase()
  const snapshotEmail = (ticket.requesterSnapshot as { email?: string } | undefined)?.email?.trim().toLowerCase() ?? null
  const isOwnerById = String(ticket.requesterId) === String(viewer.user._id)
  const isOwnerByEmail = snapshotEmail ? snapshotEmail === viewerEmail : false
  if (!isOwnerById && !isOwnerByEmail) {
    throw new ConvexError("Avaliação permitida apenas ao solicitante deste chamado")
  }

  if (typeof ticket.csatScore === "number") {
    throw new ConvexError("Este chamado já possui uma avaliação registrada")
  }

  if (!Number.isFinite(score)) {
    throw new ConvexError("Pontuação inválida")
  }
  const resolvedMaxScore =
    Number.isFinite(maxScore) && maxScore && maxScore > 0 ? Math.min(10, Math.round(maxScore)) : 5
  const normalizedScore = Math.max(1, Math.min(resolvedMaxScore, Math.round(score)))
  const normalizedComment =
    typeof comment === "string"
      ? comment
          .replace(/\r\n/g, "\n")
          .split("\n")
          .map((line) => line.trim())
          .join("\n")
          .trim()
      : ""
  if (normalizedComment.length > 2000) {
    throw new ConvexError("Comentário muito longo (máx. 2000 caracteres)")
  }

  const now = Date.now()

  let csatAssigneeId: Id<"users"> | undefined
  let csatAssigneeSnapshot:
    | {
        name: string
        email?: string
        avatarUrl?: string
        teams?: string[]
      }
    | undefined

  if (ticket.assigneeId) {
    const assigneeDoc = (await ctx.db.get(ticket.assigneeId)) as Doc<"users"> | null
    if (assigneeDoc) {
      csatAssigneeId = assigneeDoc._id
      csatAssigneeSnapshot = {
        name: assigneeDoc.name,
        email: assigneeDoc.email,
        avatarUrl: assigneeDoc.avatarUrl ?? undefined,
        teams: Array.isArray(assigneeDoc.teams) ? assigneeDoc.teams : undefined,
      }
    } else if (ticket.assigneeSnapshot && typeof ticket.assigneeSnapshot === "object") {
      const snapshot = ticket.assigneeSnapshot as {
        name?: string
        email?: string
        avatarUrl?: string
        teams?: string[]
      }
      if (typeof snapshot.name === "string" && snapshot.name.trim().length > 0) {
        csatAssigneeId = ticket.assigneeId
        csatAssigneeSnapshot = {
          name: snapshot.name,
          email: snapshot.email ?? undefined,
          avatarUrl: snapshot.avatarUrl ?? undefined,
          teams: snapshot.teams ?? undefined,
        }
      }
    }
  } else if (ticket.assigneeSnapshot && typeof ticket.assigneeSnapshot === "object") {
    const snapshot = ticket.assigneeSnapshot as {
      name?: string
      email?: string
      avatarUrl?: string
      teams?: string[]
    }
    if (typeof snapshot.name === "string" && snapshot.name.trim().length > 0) {
      csatAssigneeSnapshot = {
        name: snapshot.name,
        email: snapshot.email ?? undefined,
        avatarUrl: snapshot.avatarUrl ?? undefined,
        teams: snapshot.teams ?? undefined,
      }
    }
  }

  await ctx.db.patch(ticketId, {
    csatScore: normalizedScore,
    csatMaxScore: resolvedMaxScore,
    csatComment: normalizedComment.length > 0 ? normalizedComment : undefined,
    csatRatedAt: now,
    csatRatedBy: actorId,
    csatAssigneeId,
    csatAssigneeSnapshot,
  })

  await ctx.db.insert("ticketEvents", {
    ticketId,
    type: "CSAT_RATED",
    payload: {
      score: normalizedScore,
      maxScore: resolvedMaxScore,
      comment: normalizedComment.length > 0 ? normalizedComment : undefined,
      ratedBy: actorId,
      assigneeId: csatAssigneeId ?? null,
      assigneeName: csatAssigneeSnapshot?.name ?? null,
    },
    createdAt: now,
  })

  return {
    ok: true,
    score: normalizedScore,
    maxScore: resolvedMaxScore,
    comment: normalizedComment.length > 0 ? normalizedComment : null,
    ratedAt: now,
  }
}

export const submitCsat = mutation({
  args: {
    ticketId: v.id("tickets"),
    actorId: v.id("users"),
    score: v.number(),
    maxScore: v.optional(v.number()),
    comment: v.optional(v.string()),
  },
  handler: submitCsatHandler,
})

export const changeRequester = mutation({
  args: { ticketId: v.id("tickets"), requesterId: v.id("users"), actorId: v.id("users") },
  handler: async (ctx, { ticketId, requesterId, actorId }) => {
    const ticket = await ctx.db.get(ticketId)
    if (!ticket) {
      throw new ConvexError("Ticket não encontrado")
    }
    const ticketDoc = ticket as Doc<"tickets">
    const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
    const viewerRole = (viewer.role ?? "AGENT").toUpperCase()
    const actor = viewer.user

    if (String(ticketDoc.requesterId) === String(requesterId)) {
      return { status: "unchanged" }
    }

    const requester = (await ctx.db.get(requesterId)) as Doc<"users"> | null
    if (!requester || requester.tenantId !== ticketDoc.tenantId) {
      throw new ConvexError("Solicitante inválido")
    }

    if (viewerRole === "MANAGER") {
      if (!actor.companyId) {
        throw new ConvexError("Gestor não possui empresa vinculada")
      }
      if (requester.companyId !== actor.companyId) {
        throw new ConvexError("Gestores só podem alterar para usuários da própria empresa")
      }
    }

    const now = Date.now()
    const requesterSnapshot = {
      name: requester.name,
      email: requester.email,
      avatarUrl: requester.avatarUrl ?? undefined,
      teams: requester.teams ?? undefined,
    }

    let companyId: Id<"companies"> | undefined
    let companySnapshot: { name: string; slug?: string; isAvulso?: boolean } | undefined

    if (requester.companyId) {
      const company = await ctx.db.get(requester.companyId)
      if (company) {
        companyId = company._id as Id<"companies">
        companySnapshot = {
          name: company.name,
          slug: company.slug ?? undefined,
          isAvulso: company.isAvulso ?? undefined,
        }
      }
    }

    const patch: Record<string, unknown> = {
      requesterId,
      requesterSnapshot,
      updatedAt: now,
    }
    if (companyId) {
      patch["companyId"] = companyId
      patch["companySnapshot"] = companySnapshot
    } else {
      patch["companyId"] = undefined
      patch["companySnapshot"] = undefined
    }

    await ctx.db.patch(ticketId, patch)

    await ctx.db.insert("ticketEvents", {
      ticketId,
      type: "REQUESTER_CHANGED",
      payload: {
        requesterId,
        requesterName: requester.name,
        requesterEmail: requester.email,
        companyId: companyId ?? null,
        companyName: companySnapshot?.name ?? null,
        actorId,
        actorName: actor.name,
        actorAvatar: actor.avatarUrl,
      },
      createdAt: now,
    })

    return { status: "updated" }
  },
})

export const purgeTicketsForUsers = mutation({
  args: {
    tenantId: v.string(),
    actorId: v.id("users"),
    userIds: v.array(v.id("users")),
  },
  handler: async (ctx, { tenantId, actorId, userIds }) => {
    await requireAdmin(ctx, actorId, tenantId)
    if (userIds.length === 0) {
      return { deleted: 0 }
    }
    const uniqueIds = Array.from(new Set(userIds.map((id) => id)))
    let deleted = 0
    const MAX_BATCH = 100 // Limita para evitar OOM em tenants grandes
    for (const userId of uniqueIds) {
      // Processa em batches para evitar carregar todos na memoria
      let hasMore = true
      while (hasMore) {
        const requesterTickets = await ctx.db
          .query("tickets")
          .withIndex("by_tenant_requester", (q) => q.eq("tenantId", tenantId).eq("requesterId", userId))
          .take(MAX_BATCH)
        hasMore = requesterTickets.length === MAX_BATCH
        for (const ticket of requesterTickets) {
          await ctx.db.delete(ticket._id)
          deleted += 1
        }
      }
      hasMore = true
      while (hasMore) {
        const assigneeTickets = await ctx.db
          .query("tickets")
          .withIndex("by_tenant_assignee", (q) => q.eq("tenantId", tenantId).eq("assigneeId", userId))
          .take(MAX_BATCH)
        hasMore = assigneeTickets.length === MAX_BATCH
        for (const ticket of assigneeTickets) {
          await ctx.db.delete(ticket._id)
          deleted += 1
        }
      }
    }
    return { deleted }
  },
})


export const updateVisitSchedule = mutation({
  args: {
    ticketId: v.id("tickets"),
    actorId: v.id("users"),
    visitDate: v.number(),
  },
  handler: async (ctx, { ticketId, actorId, visitDate }) => {
    const ticket = await ctx.db.get(ticketId)
    if (!ticket) {
      throw new ConvexError("Ticket não encontrado")
    }
    const ticketDoc = ticket as Doc<"tickets">
    const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
    if (viewer.role === "MANAGER") {
      throw new ConvexError("Gestores não podem alterar a data da visita")
    }
    if (!Number.isFinite(visitDate)) {
      throw new ConvexError("Data da visita inválida")
    }
    if (!ticketDoc.queueId) {
      throw new ConvexError("Este ticket não possui fila configurada")
    }
    const queue = (await ctx.db.get(ticketDoc.queueId)) as Doc<"queues"> | null
    if (!queue) {
      throw new ConvexError("Fila não encontrada para este ticket")
    }
    const queueLabel = (normalizeQueueName(queue) ?? queue.name ?? "").toLowerCase()
    const isVisitQueue = VISIT_QUEUE_KEYWORDS.some((keyword) => queueLabel.includes(keyword))
    if (!isVisitQueue) {
      throw new ConvexError("Somente tickets da fila de visitas possuem data de visita")
    }
    const now = Date.now()
    const previousVisitDate = typeof ticketDoc.dueAt === "number" ? ticketDoc.dueAt : null
    const actor = viewer.user
    await ctx.db.patch(ticketId, {
      dueAt: visitDate,
      visitStatus: "scheduled",
      visitPerformedAt: undefined,
      updatedAt: now,
    })
    await ctx.db.insert("ticketEvents", {
      ticketId,
      type: "VISIT_SCHEDULE_CHANGED",
      payload: {
        visitDate,
        previousVisitDate,
        actorId,
        actorName: actor.name,
        actorAvatar: actor.avatarUrl ?? undefined,
      },
      createdAt: now,
    })
    return { status: "updated" }
  },
})

export const updateVisitStatus = mutation({
  args: {
    ticketId: v.id("tickets"),
    actorId: v.id("users"),
    status: v.string(),
    performedAt: v.optional(v.number()),
  },
  handler: async (ctx, { ticketId, actorId, status, performedAt }) => {
    const ticket = await ctx.db.get(ticketId)
    if (!ticket) {
      throw new ConvexError("Ticket não encontrado")
    }
    const ticketDoc = ticket as Doc<"tickets">
    const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
    if (viewer.role === "MANAGER") {
      throw new ConvexError("Gestores não podem alterar o status da visita")
    }
    const normalizedStatus = (status ?? "").toLowerCase()
    if (!VISIT_STATUSES.has(normalizedStatus)) {
      throw new ConvexError("Status da visita inválido")
    }
    if (!ticketDoc.queueId) {
      throw new ConvexError("Este ticket não possui fila configurada")
    }
    const queue = (await ctx.db.get(ticketDoc.queueId)) as Doc<"queues"> | null
    if (!queue) {
      throw new ConvexError("Fila não encontrada para este ticket")
    }
    const queueLabel = (normalizeQueueName(queue) ?? queue.name ?? "").toLowerCase()
    const isVisitQueue = VISIT_QUEUE_KEYWORDS.some((keyword) => queueLabel.includes(keyword))
    if (!isVisitQueue) {
      throw new ConvexError("Somente tickets da fila de visitas possuem status de visita")
    }
    const now = Date.now()
    const completed = VISIT_COMPLETED_STATUSES.has(normalizedStatus)
    const resolvedPerformedAt =
      completed && typeof performedAt === "number" && Number.isFinite(performedAt) ? performedAt : completed ? now : undefined

    await ctx.db.patch(ticketId, {
      visitStatus: normalizedStatus,
      visitPerformedAt: completed ? resolvedPerformedAt : undefined,
      // Mantemos dueAt para não perder o agendamento original.
      dueAt: ticketDoc.dueAt,
      updatedAt: now,
    })

    await ctx.db.insert("ticketEvents", {
      ticketId,
      type: "VISIT_STATUS_CHANGED",
      payload: {
        status: normalizedStatus,
        performedAt: resolvedPerformedAt,
        actorId,
        actorName: viewer.user.name,
        actorAvatar: viewer.user.avatarUrl ?? undefined,
      },
      createdAt: now,
    })

    return { status: normalizedStatus }
  },
})

export const changeQueue = mutation({
  args: { ticketId: v.id("tickets"), queueId: v.id("queues"), actorId: v.id("users") },
  handler: async (ctx, { ticketId, queueId, actorId }) => {
    const ticket = await ctx.db.get(ticketId)
    if (!ticket) {
      throw new ConvexError("Ticket não encontrado")
    }
    const ticketDoc = ticket as Doc<"tickets">
    const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
    if (viewer.role === "MANAGER") {
      throw new ConvexError("Gestores não podem alterar a fila do chamado")
    }
    const queue = (await ctx.db.get(queueId)) as Doc<"queues"> | null
    if (!queue || queue.tenantId !== ticketDoc.tenantId) {
      throw new ConvexError("Fila inválida")
    }
    const now = Date.now()
    const queueName = normalizeQueueName(queue)
    const normalizedQueueLabel = (queueName ?? queue.name ?? "").toLowerCase()
    const isVisitQueueTarget = VISIT_QUEUE_KEYWORDS.some((keyword) => normalizedQueueLabel.includes(keyword))
    const patch: Partial<Doc<"tickets">> = { queueId, updatedAt: now }
    if (!isVisitQueueTarget) {
      patch.dueAt = ticketDoc.slaSolutionDueAt ?? undefined
    }
    await ctx.db.patch(ticketId, patch)
    await ctx.db.insert("ticketEvents", {
      ticketId,
      type: "QUEUE_CHANGED",
      payload: { queueId, queueName, actorId },
      createdAt: now,
    })

    await runTicketAutomationsForEvent(ctx, {
      tenantId: ticketDoc.tenantId,
      ticketId,
      eventType: "QUEUE_CHANGED",
    })
  },
});

export const updateCategories = mutation({
  args: {
    ticketId: v.id("tickets"),
    categoryId: v.union(v.id("ticketCategories"), v.null()),
    subcategoryId: v.union(v.id("ticketSubcategories"), v.null()),
    actorId: v.id("users"),
  },
  handler: async (ctx, { ticketId, categoryId, subcategoryId, actorId }) => {
    const ticket = await ctx.db.get(ticketId)
    if (!ticket) {
      throw new ConvexError("Ticket não encontrado")
    }
    const ticketDoc = ticket as Doc<"tickets">
    const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
    if (viewer.role === "MANAGER") {
      throw new ConvexError("Gestores não podem alterar a categorização do chamado")
    }

    if (categoryId === null) {
      if (subcategoryId !== null) {
        throw new ConvexError("Subcategoria inválida")
      }
      if (!ticketDoc.categoryId && !ticketDoc.subcategoryId) {
        return { status: "unchanged" }
      }
      const now = Date.now()
      await ctx.db.patch(ticketId, {
        categoryId: undefined,
        subcategoryId: undefined,
        updatedAt: now,
      })
      const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null
      await ctx.db.insert("ticketEvents", {
        ticketId,
        type: "CATEGORY_CHANGED",
        payload: {
          categoryId: null,
          categoryName: null,
          subcategoryId: null,
          subcategoryName: null,
          actorId,
          actorName: actor?.name,
          actorAvatar: actor?.avatarUrl,
        },
        createdAt: now,
      })
      return { status: "cleared" }
    }

    const category = await ctx.db.get(categoryId)
    if (!category || category.tenantId !== ticketDoc.tenantId) {
      throw new ConvexError("Categoria inválida")
    }

    let subcategoryName: string | null = null
    if (subcategoryId !== null) {
      const subcategory = await ctx.db.get(subcategoryId)
      if (!subcategory || subcategory.categoryId !== categoryId || subcategory.tenantId !== ticketDoc.tenantId) {
        throw new ConvexError("Subcategoria inválida")
      }
      subcategoryName = subcategory.name
    }

    if (ticketDoc.categoryId === categoryId && (ticketDoc.subcategoryId ?? null) === subcategoryId) {
      return { status: "unchanged" }
    }

    const now = Date.now()
    await ctx.db.patch(ticketId, {
      categoryId,
      subcategoryId: subcategoryId ?? undefined,
      updatedAt: now,
    })

    const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null
    await ctx.db.insert("ticketEvents", {
      ticketId,
      type: "CATEGORY_CHANGED",
      payload: {
        categoryId,
        categoryName: category.name,
        subcategoryId,
        subcategoryName,
        actorId,
        actorName: actor?.name,
        actorAvatar: actor?.avatarUrl,
      },
      createdAt: now,
    })

    return { status: "updated" }
  },
})

export const workSummary = query({
  args: { ticketId: v.id("tickets"), viewerId: v.id("users") },
  handler: async (ctx, { ticketId, viewerId }) => {
    const ticket = await ctx.db.get(ticketId)
    if (!ticket) return null
    await requireStaff(ctx, viewerId, ticket.tenantId)

    const activeSession = ticket.activeSessionId ? await ctx.db.get(ticket.activeSessionId) : null
    const serverNow = Date.now()
    const perAgentTotals = await computeAgentWorkTotals(ctx, ticketId, serverNow)
    return {
      ticketId,
      totalWorkedMs: ticket.totalWorkedMs ?? 0,
      internalWorkedMs: ticket.internalWorkedMs ?? 0,
      externalWorkedMs: ticket.externalWorkedMs ?? 0,
      serverNow,
      activeSession: activeSession
        ? {
            id: activeSession._id,
            agentId: activeSession.agentId,
            startedAt: activeSession.startedAt,
            workType: activeSession.workType ?? "INTERNAL",
          }
        : null,
      perAgentTotals: perAgentTotals.map((item) => ({
        agentId: item.agentId,
        agentName: item.agentName,
        agentEmail: item.agentEmail,
        avatarUrl: item.avatarUrl,
        totalWorkedMs: item.totalWorkedMs,
        internalWorkedMs: item.internalWorkedMs,
        externalWorkedMs: item.externalWorkedMs,
      })),
    }
  },
})

export const updatePriority = mutation({
  args: { ticketId: v.id("tickets"), priority: v.string(), actorId: v.id("users") },
  handler: async (ctx, { ticketId, priority, actorId }) => {
    const ticket = await ctx.db.get(ticketId)
    if (!ticket) {
      throw new ConvexError("Ticket não encontrado")
    }
    await requireStaff(ctx, actorId, ticket.tenantId)
    const now = Date.now();
    await ctx.db.patch(ticketId, { priority, updatedAt: now });
    const pt: Record<string, string> = { LOW: "Baixa", MEDIUM: "Média", HIGH: "Alta", URGENT: "Urgente" };
    await ctx.db.insert("ticketEvents", {
      ticketId,
      type: "PRIORITY_CHANGED",
      payload: { to: priority, toLabel: pt[priority] ?? priority, actorId },
      createdAt: now,
    });

    await runTicketAutomationsForEvent(ctx, {
      tenantId: ticket.tenantId,
      ticketId,
      eventType: "PRIORITY_CHANGED",
    })
  },
});

export const startWork = mutation({
  args: { ticketId: v.id("tickets"), actorId: v.id("users"), workType: v.optional(v.string()) },
  handler: async (ctx, { ticketId, actorId, workType }) => {
    const ticket = await ctx.db.get(ticketId)
    if (!ticket) {
      throw new ConvexError("Ticket não encontrado")
    }
    const ticketDoc = ticket as Doc<"tickets">
    const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
    const currentAssigneeId = ticketDoc.assigneeId ?? null
    const now = Date.now()

    if (!currentAssigneeId) {
      throw new ConvexError("Defina um responsável antes de iniciar o atendimento")
    }

    if (ticketDoc.activeSessionId) {
      const session = await ctx.db.get(ticketDoc.activeSessionId)
      return {
        status: "already_started",
        sessionId: ticketDoc.activeSessionId,
        startedAt: session?.startedAt ?? now,
        serverNow: now,
      }
    }

    const sessionId = await ctx.db.insert("ticketWorkSessions", {
      ticketId,
      agentId: currentAssigneeId,
      workType: (workType ?? "INTERNAL").toUpperCase(),
      startedAt: now,
    })

    const slaStartPatch = buildSlaStatusPatch(ticketDoc, "AWAITING_ATTENDANCE", now);
    await ctx.db.patch(ticketId, {
      working: true,
      activeSessionId: sessionId,
      status: "AWAITING_ATTENDANCE",
      updatedAt: now,
      ...slaStartPatch,
    })

    await ctx.db.insert("ticketEvents", {
      ticketId,
      type: "WORK_STARTED",
      payload: {
        actorId,
        actorName: viewer.user.name,
        actorAvatar: viewer.user.avatarUrl,
        sessionId,
        workType: (workType ?? "INTERNAL").toUpperCase(),
      },
      createdAt: now,
    })

    return { status: "started", sessionId, startedAt: now, serverNow: now }
  },
})

export const pauseWork = mutation({
  args: {
    ticketId: v.id("tickets"),
    actorId: v.id("users"),
    reason: v.string(),
    note: v.optional(v.string()),
  },
  handler: async (ctx, { ticketId, actorId, reason, note }) => {
    const ticket = await ctx.db.get(ticketId)
    if (!ticket) {
      throw new ConvexError("Ticket não encontrado")
    }
    const ticketDoc = ticket as Doc<"tickets">
    const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
    const isAdmin = viewer.role === "ADMIN"
    if (ticketDoc.assigneeId && ticketDoc.assigneeId !== actorId && !isAdmin) {
      throw new ConvexError("Somente o responsável atual pode pausar este chamado")
    }

    if (!ticketDoc.activeSessionId) {
      const normalizedStatus = normalizeStatus(ticketDoc.status)
      if (normalizedStatus === "AWAITING_ATTENDANCE") {
        const now = Date.now()
        const slaPausePatch = buildSlaStatusPatch(ticketDoc, "PAUSED", now)
        await ctx.db.patch(ticketId, {
          status: "PAUSED",
          working: false,
          updatedAt: now,
          ...slaPausePatch,
        })
        await ctx.db.insert("ticketEvents", {
          ticketId,
          type: "STATUS_CHANGED",
          payload: {
            to: "PAUSED",
            toLabel: STATUS_LABELS.PAUSED,
            actorId,
          },
          createdAt: now,
        })
        return { status: "paused", durationMs: 0, pauseReason: reason, pauseNote: note ?? "", serverNow: now }
      }
      return { status: "already_paused" }
    }

    if (!PAUSE_REASON_LABELS[reason]) {
      throw new ConvexError("Motivo de pausa inválido")
    }

    const session = await ctx.db.get(ticketDoc.activeSessionId)
    if (!session) {
      await ctx.db.patch(ticketId, { activeSessionId: undefined, working: false })
      return { status: "session_missing" }
    }

    const now = Date.now()
    const durationMs = now - session.startedAt

    await ctx.db.patch(ticketDoc.activeSessionId, {
      stoppedAt: now,
      durationMs,
      pauseReason: reason,
      pauseNote: note ?? "",
    })

  const sessionType = (session.workType ?? "INTERNAL").toUpperCase()
    const deltaInternal = sessionType === "INTERNAL" ? durationMs : 0
    const deltaExternal = sessionType === "EXTERNAL" ? durationMs : 0

    const slaPausePatch = buildSlaStatusPatch(ticketDoc, "PAUSED", now)
    await ctx.db.patch(ticketId, {
      working: false,
      activeSessionId: undefined,
      status: "PAUSED",
      totalWorkedMs: (ticket.totalWorkedMs ?? 0) + durationMs,
      internalWorkedMs: (ticket.internalWorkedMs ?? 0) + deltaInternal,
      externalWorkedMs: (ticket.externalWorkedMs ?? 0) + deltaExternal,
      updatedAt: now,
      ...slaPausePatch,
    })

    const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null
    await ctx.db.insert("ticketEvents", {
      ticketId,
      type: "WORK_PAUSED",
      payload: {
        actorId,
        actorName: actor?.name,
        actorAvatar: actor?.avatarUrl,
        sessionId: session._id,
        sessionDurationMs: durationMs,
        workType: sessionType,
        pauseReason: reason,
        pauseReasonLabel: PAUSE_REASON_LABELS[reason],
        pauseNote: note ?? "",
      },
      createdAt: now,
    })

    return {
      status: "paused",
      durationMs,
      pauseReason: reason,
      pauseNote: note ?? "",
      serverNow: now,
    }
  },
})

export const pauseInternalSessionsForLunch = mutation({
  args: {},
  handler: async (ctx) => {
    const now = Date.now()
    const { hour } = getHourMinuteInTimezone(now, LUNCH_BREAK_TIMEZONE)
    if (hour !== LUNCH_BREAK_HOUR) {
      return { skipped: true, reason: "outside_lunch_window" as const }
    }

    // Limita a 200 sessoes por execucao para evitar OOM
    // Se houver mais, o proximo cron pegara o restante
    const activeSessions = await ctx.db
      .query("ticketWorkSessions")
      .filter((q) => q.eq(q.field("stoppedAt"), undefined))
      .take(200)

    let paused = 0
    for (const sessionDoc of activeSessions) {
      const session = sessionDoc as Doc<"ticketWorkSessions">
      const workType = (session.workType ?? "INTERNAL").toUpperCase()
      if (workType !== "INTERNAL") continue

      const ticket = (await ctx.db.get(session.ticketId)) as Doc<"tickets"> | null
      if (!ticket || ticket.activeSessionId !== session._id) {
        continue
      }

      await pauseSessionForLunch(ctx, ticket, session)
      paused += 1
    }

    return { skipped: false, paused }
  },
})

export const adjustWorkSummary = mutation({
  args: {
    ticketId: v.id("tickets"),
    actorId: v.id("users"),
    internalWorkedMs: v.number(),
    externalWorkedMs: v.number(),
    reason: v.string(),
  },
  handler: async (ctx, { ticketId, actorId, internalWorkedMs, externalWorkedMs, reason }) => {
    const ticket = await ctx.db.get(ticketId)
    if (!ticket) {
      throw new ConvexError("Ticket não encontrado")
    }
    const ticketDoc = ticket as Doc<"tickets">
    const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
    const normalizedRole = (viewer.role ?? "").toUpperCase()
    if (normalizedRole !== "ADMIN" && normalizedRole !== "AGENT") {
      throw new ConvexError("Somente administradores e agentes podem ajustar as horas de um chamado.")
    }
    if (ticketDoc.activeSessionId) {
      throw new ConvexError("Pause o atendimento antes de ajustar as horas do chamado.")
    }

    const trimmedReason = reason.trim()
    if (trimmedReason.length < 5) {
      throw new ConvexError("Informe um motivo com pelo menos 5 caracteres.")
    }
    if (trimmedReason.length > 1000) {
      throw new ConvexError("Motivo muito longo (máx. 1000 caracteres).")
    }

    const previousInternal = Math.max(0, ticketDoc.internalWorkedMs ?? 0)
    const previousExternal = Math.max(0, ticketDoc.externalWorkedMs ?? 0)
    const previousTotal = Math.max(0, ticketDoc.totalWorkedMs ?? previousInternal + previousExternal)

    const nextInternal = Math.max(0, Math.round(internalWorkedMs))
    const nextExternal = Math.max(0, Math.round(externalWorkedMs))
    const nextTotal = nextInternal + nextExternal

    const deltaInternal = nextInternal - previousInternal
    const deltaExternal = nextExternal - previousExternal
    const deltaTotal = nextTotal - previousTotal

    const now = Date.now()
    await ctx.db.patch(ticketId, {
      internalWorkedMs: nextInternal,
      externalWorkedMs: nextExternal,
      totalWorkedMs: nextTotal,
      updatedAt: now,
    })

    await ctx.db.insert("ticketEvents", {
      ticketId,
      type: "WORK_ADJUSTED",
      payload: {
        actorId,
        actorName: viewer.user.name,
        actorAvatar: viewer.user.avatarUrl,
        previousInternalMs: previousInternal,
        previousExternalMs: previousExternal,
        previousTotalMs: previousTotal,
        nextInternalMs: nextInternal,
        nextExternalMs: nextExternal,
        nextTotalMs: nextTotal,
        deltaInternalMs: deltaInternal,
        deltaExternalMs: deltaExternal,
        deltaTotalMs: deltaTotal,
      },
      createdAt: now,
    })

    const bodyHtml = [
      "<p><strong>Ajuste manual de horas</strong></p>",
      "<ul>",
      `<li>Horas internas: ${escapeHtml(formatWorkDuration(previousInternal))} &rarr; ${escapeHtml(formatWorkDuration(nextInternal))} (${escapeHtml(formatWorkDelta(deltaInternal))})</li>`,
      `<li>Horas externas: ${escapeHtml(formatWorkDuration(previousExternal))} &rarr; ${escapeHtml(formatWorkDuration(nextExternal))} (${escapeHtml(formatWorkDelta(deltaExternal))})</li>`,
      `<li>Total: ${escapeHtml(formatWorkDuration(previousTotal))} &rarr; ${escapeHtml(formatWorkDuration(nextTotal))} (${escapeHtml(formatWorkDelta(deltaTotal))})</li>`,
      "</ul>",
      `<p><strong>Motivo:</strong> ${escapeHtml(trimmedReason)}</p>`,
    ].join("")

    const authorSnapshot: CommentAuthorSnapshot = {
      name: viewer.user.name,
      email: viewer.user.email,
      avatarUrl: viewer.user.avatarUrl ?? undefined,
      teams: viewer.user.teams ?? undefined,
    }

    await ctx.db.insert("ticketComments", {
      ticketId,
      authorId: actorId,
      visibility: "INTERNAL",
      body: bodyHtml,
      authorSnapshot,
      attachments: [],
      createdAt: now,
      updatedAt: now,
    })

    const perAgentTotals = await computeAgentWorkTotals(ctx, ticketId, now)

    return {
      ticketId,
      totalWorkedMs: nextTotal,
      internalWorkedMs: nextInternal,
      externalWorkedMs: nextExternal,
      serverNow: now,
      perAgentTotals: perAgentTotals.map((item) => ({
        agentId: item.agentId,
        agentName: item.agentName,
        agentEmail: item.agentEmail,
        avatarUrl: item.avatarUrl,
        totalWorkedMs: item.totalWorkedMs,
        internalWorkedMs: item.internalWorkedMs,
        externalWorkedMs: item.externalWorkedMs,
      })),
    }
  },
})

export const updateSubject = mutation({
  args: { ticketId: v.id("tickets"), subject: v.string(), actorId: v.id("users") },
  handler: async (ctx, { ticketId, subject, actorId }) => {
    const now = Date.now();
    const t = await ctx.db.get(ticketId);
    if (!t) {
      throw new ConvexError("Ticket não encontrado")
    }
    await requireStaff(ctx, actorId, t.tenantId)
    const trimmed = subject.trim();
    if (trimmed.length < 3) {
      throw new ConvexError("Informe um assunto com pelo menos 3 caracteres");
    }
    await ctx.db.patch(ticketId, { subject: trimmed, updatedAt: now });
    const actor = await ctx.db.get(actorId);
    await ctx.db.insert("ticketEvents", {
      ticketId,
      type: "SUBJECT_CHANGED",
      payload: { from: t.subject, to: trimmed, actorId, actorName: (actor as Doc<"users"> | null)?.name, actorAvatar: (actor as Doc<"users"> | null)?.avatarUrl },
      createdAt: now,
    });
  },
});

export const updateSummary = mutation({
  args: { ticketId: v.id("tickets"), summary: v.optional(v.string()), actorId: v.id("users") },
  handler: async (ctx, { ticketId, summary, actorId }) => {
    const now = Date.now();
    const t = await ctx.db.get(ticketId);
    if (!t) {
      throw new ConvexError("Ticket não encontrado")
    }
    await requireStaff(ctx, actorId, t.tenantId)
    if (summary && summary.trim().length > MAX_SUMMARY_CHARS) {
      throw new ConvexError(`Resumo muito longo (máx. ${MAX_SUMMARY_CHARS} caracteres)`)
    }
    await ctx.db.patch(ticketId, { summary, updatedAt: now });
    const actor = await ctx.db.get(actorId);
    await ctx.db.insert("ticketEvents", {
      ticketId,
      type: "SUMMARY_CHANGED",
      payload: { actorId, actorName: (actor as Doc<"users"> | null)?.name, actorAvatar: (actor as Doc<"users"> | null)?.avatarUrl },
      createdAt: now,
    });
  },
});

export const updateCustomFields = mutation({
  args: {
    ticketId: v.id("tickets"),
    actorId: v.id("users"),
    fields: v.array(
      v.object({
        fieldId: v.id("ticketFields"),
        value: v.any(),
      })
    ),
  },
  handler: async (ctx, { ticketId, actorId, fields }) => {
    const ticket = await ctx.db.get(ticketId)
    if (!ticket) {
      throw new ConvexError("Ticket não encontrado")
    }
    const ticketDoc = ticket as Doc<"tickets">
    const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
    const normalizedRole = (viewer.role ?? "").toUpperCase()
    if (normalizedRole !== "ADMIN" && normalizedRole !== "AGENT") {
      throw new ConvexError("Somente administradores e agentes podem editar campos personalizados.")
    }

    const previousEntries = (ticketDoc.customFields as NormalizedCustomField[] | undefined) ?? []
    const previousRecord = mapCustomFieldsToRecord(previousEntries)

    const sanitizedInputs: CustomFieldInput[] = fields
      .filter((entry) => entry.value !== undefined)
      .map((entry) => ({
        fieldId: entry.fieldId,
        value: entry.value,
      }))

    const normalized = await normalizeCustomFieldValues(
      ctx,
      ticketDoc.tenantId,
      sanitizedInputs,
      ticketDoc.formTemplate ?? null
    )

    const nextRecord = mapCustomFieldsToRecord(normalized)
    const metaByFieldKey = new Map<
      string,
      { fieldId?: Id<"ticketFields">; label: string; type: string }
    >()
    for (const entry of previousEntries) {
      metaByFieldKey.set(entry.fieldKey, {
        fieldId: entry.fieldId,
        label: entry.label,
        type: entry.type,
      })
    }
    for (const entry of normalized) {
      metaByFieldKey.set(entry.fieldKey, {
        fieldId: entry.fieldId,
        label: entry.label,
        type: entry.type,
      })
    }

    const keyOrder = [...Object.keys(previousRecord), ...Object.keys(nextRecord)]
    const changedKeys = Array.from(new Set(keyOrder)).filter((key) => {
      const previousEntry = getCustomFieldRecordEntry(previousRecord, key)
      const nextEntry = getCustomFieldRecordEntry(nextRecord, key)
      return !areCustomFieldEntriesEqual(previousEntry, nextEntry)
    })

    if (changedKeys.length === 0) {
      return {
        customFields: previousRecord,
        updatedAt: ticketDoc.updatedAt ?? Date.now(),
      }
    }

    const now = Date.now()

    await ctx.db.patch(ticketId, {
      customFields: normalized.length > 0 ? normalized : undefined,
      updatedAt: now,
    })

    await ctx.db.insert("ticketEvents", {
      ticketId,
      type: "CUSTOM_FIELDS_UPDATED",
      payload: {
        actorId,
        actorName: viewer.user.name,
        actorAvatar: viewer.user.avatarUrl ?? undefined,
        fields: changedKeys.map((fieldKey) => {
          const meta = metaByFieldKey.get(fieldKey)
          const previous = getCustomFieldRecordEntry(previousRecord, fieldKey)
          const next = getCustomFieldRecordEntry(nextRecord, fieldKey)
          return {
            fieldId: meta?.fieldId,
            fieldKey,
            label: meta?.label ?? fieldKey,
            type: meta?.type ?? "text",
            previousValue: previous?.value ?? null,
            nextValue: next?.value ?? null,
            previousDisplayValue: previous?.displayValue ?? null,
            nextDisplayValue: next?.displayValue ?? null,
            changeType: !previous ? "added" : !next ? "removed" : "updated",
          }
        }),
      },
      createdAt: now,
    })

    return {
      customFields: nextRecord,
      updatedAt: now,
    }
  },
})

export const playNext = mutation({
  args: {
    tenantId: v.string(),
    queueId: v.optional(v.id("queues")),
    agentId: v.id("users"),
  },
  handler: async (ctx, { tenantId, queueId, agentId }) => {
    const { user: agent } = await requireStaff(ctx, agentId, tenantId)
    // Find eligible tickets: not resolved/closed and not assigned
    // Limita busca a 500 tickets mais antigos (createdAt asc) para evitar OOM
    // Isso garante que pegamos os tickets mais antigos primeiro
    let candidates: Doc<"tickets">[] = []
    if (queueId) {
      candidates = await ctx.db
        .query("tickets")
        .withIndex("by_tenant_queue", (q) => q.eq("tenantId", tenantId).eq("queueId", queueId))
        .take(500)
    } else {
      candidates = await ctx.db
        .query("tickets")
        .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
        .take(500)
    }

    candidates = candidates.filter(
      (t) => t.status !== "RESOLVED" && !t.assigneeId
    );

    if (candidates.length === 0) return null;

    // prioritize by priority then createdAt
    const rank: Record<string, number> = { URGENT: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }
    candidates.sort((a, b) => {
      const pa = rank[a.priority] ?? 999
      const pb = rank[b.priority] ?? 999
      if (pa !== pb) return pa - pb
      return a.createdAt - b.createdAt
    })

    const chosen = candidates[0];
    const now = Date.now();
    const currentStatus = normalizeStatus(chosen.status);
    const nextStatus: TicketStatusNormalized = currentStatus;
    const assigneeSnapshot = {
      name: agent.name,
      email: agent.email,
      avatarUrl: agent.avatarUrl ?? undefined,
      teams: agent.teams ?? undefined,
    }
    await ctx.db.patch(chosen._id, {
      assigneeId: agentId,
      assigneeSnapshot,
      status: nextStatus,
      working: false,
      activeSessionId: undefined,
      updatedAt: now,
    });
    await ctx.db.insert("ticketEvents", {
      ticketId: chosen._id,
      type: "ASSIGNEE_CHANGED",
      payload: { assigneeId: agentId, assigneeName: agent.name },
      createdAt: now,
    });

    // hydrate minimal public ticket like in list
    const requester = (await ctx.db.get(chosen.requesterId)) as Doc<"users"> | null
    const assignee = chosen.assigneeId ? ((await ctx.db.get(chosen.assigneeId)) as Doc<"users"> | null) : null
    const queue = chosen.queueId ? ((await ctx.db.get(chosen.queueId)) as Doc<"queues"> | null) : null
    const queueName = normalizeQueueName(queue)
    return {
      id: chosen._id,
      reference: chosen.reference,
      tenantId: chosen.tenantId,
      subject: chosen.subject,
      summary: chosen.summary,
      status: nextStatus,
      priority: chosen.priority,
      channel: chosen.channel,
      queue: queueName,
      requester: requester
        ? buildRequesterSummary(requester, chosen.requesterId, { ticketId: chosen._id })
        : buildRequesterFromSnapshot(
            chosen.requesterId,
            chosen.requesterSnapshot ?? undefined,
            { ticketId: chosen._id }
          ),
      assignee: chosen.assigneeId
        ? assignee
          ? {
              id: assignee._id,
              name: assignee.name,
              email: assignee.email,
              avatarUrl: assignee.avatarUrl,
              teams: normalizeTeams(assignee.teams),
            }
          : buildAssigneeFromSnapshot(chosen.assigneeId, chosen.assigneeSnapshot ?? undefined)
        : null,
      slaPolicy: null,
      dueAt: chosen.dueAt ?? null,
      firstResponseAt: chosen.firstResponseAt ?? null,
      resolvedAt: chosen.resolvedAt ?? null,
      updatedAt: chosen.updatedAt,
      createdAt: chosen.createdAt,
      tags: chosen.tags ?? [],
      lastTimelineEntry: null,
      metrics: null,
    }
  },
});

export const remove = mutation({
  args: { ticketId: v.id("tickets"), actorId: v.id("users") },
  handler: async (ctx, { ticketId, actorId }) => {
    const ticket = await ctx.db.get(ticketId)
    if (!ticket) {
      throw new ConvexError("Ticket não encontrado")
    }
    await requireAdmin(ctx, actorId, ticket.tenantId)
    // delete comments (and attachments) em batches para evitar OOM
    const BATCH_SIZE = 100
    let hasMoreComments = true
    while (hasMoreComments) {
      const comments = await ctx.db
        .query("ticketComments")
        .withIndex("by_ticket", (q) => q.eq("ticketId", ticketId))
        .take(BATCH_SIZE);
      hasMoreComments = comments.length === BATCH_SIZE
      for (const c of comments) {
        for (const att of c.attachments ?? []) {
          try { await ctx.storage.delete(att.storageId); } catch {}
        }
        await ctx.db.delete(c._id);
      }
    }
    // delete events em batches
    let hasMoreEvents = true
    while (hasMoreEvents) {
      const events = await ctx.db
        .query("ticketEvents")
        .withIndex("by_ticket", (q) => q.eq("ticketId", ticketId))
        .take(BATCH_SIZE);
      hasMoreEvents = events.length === BATCH_SIZE
      for (const ev of events) await ctx.db.delete(ev._id);
    }
    // delete ticket
    await ctx.db.delete(ticketId);
    // (optional) event is moot after deletion
    return true;
  },
});

export const reassignTicketsByEmail = mutation({
  args: {
    tenantId: v.string(),
    actorId: v.id("users"),
    fromEmail: v.string(),
    toUserId: v.id("users"),
    dryRun: v.optional(v.boolean()),
    limit: v.optional(v.number()),
    updateSnapshot: v.optional(v.boolean()),
  },
  handler: async (ctx, { tenantId, actorId, fromEmail, toUserId, dryRun, limit, updateSnapshot }) => {
    await requireAdmin(ctx, actorId, tenantId)

    const normalizedFrom = fromEmail.trim().toLowerCase()
    if (!normalizedFrom || !normalizedFrom.includes("@")) {
      throw new ConvexError("E-mail de origem inválido")
    }

    const toUser = await ctx.db.get(toUserId)
    if (!toUser || toUser.tenantId !== tenantId) {
      throw new ConvexError("Usuário de destino inválido para o tenant")
    }

    // Coletar tickets por requesterId (quando possível via usuário antigo)
    const fromUser = await ctx.db
      .query("users")
      .withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", normalizedFrom))
      .first()

    // Limita a 1000 tickets por requesterId para evitar OOM
    const byRequesterId: Doc<"tickets">[] = fromUser
      ? await ctx.db
          .query("tickets")
          .withIndex("by_tenant_requester", (q) => q.eq("tenantId", tenantId).eq("requesterId", fromUser._id))
          .take(1000)
      : []

    // Buscar tickets por snapshot de email (limitado a 2000 para evitar OOM)
    // Se houver mais, o usuario pode rodar novamente
    const allTenant = await ctx.db
      .query("tickets")
      .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
      .take(2000)

    const bySnapshotEmail = allTenant.filter((t) => {
      const rs = t.requesterSnapshot as { email?: string } | undefined
      const email = typeof rs?.email === "string" ? rs.email.trim().toLowerCase() : null
      if (!email || email !== normalizedFrom) return false
      // Evita duplicar os já coletados por requesterId
      if (fromUser && t.requesterId === fromUser._id) return false
      return true
    })

    const candidatesMap = new Map<string, Doc<"tickets">>()
    for (const t of byRequesterId) candidatesMap.set(String(t._id), t)
    for (const t of bySnapshotEmail) candidatesMap.set(String(t._id), t)
    const candidates = Array.from(candidatesMap.values())

    const maxToProcess = Math.max(0, Math.min(limit && limit > 0 ? limit : candidates.length, candidates.length))
    const toProcess = candidates.slice(0, maxToProcess)

    if (dryRun) {
      return {
        dryRun: true as const,
        fromEmail: normalizedFrom,
        toUserId,
        candidates: candidates.length,
        willUpdate: toProcess.length,
      }
    }

    const now = Date.now()
    let updated = 0
    for (const t of toProcess) {
      const patch: Record<string, unknown> = { requesterId: toUserId, updatedAt: now }
      if (updateSnapshot) {
        patch.requesterSnapshot = {
          name: toUser.name,
          email: toUser.email,
          avatarUrl: toUser.avatarUrl ?? undefined,
          teams: toUser.teams ?? undefined,
        }
      }
      await ctx.db.patch(t._id, patch)
      await ctx.db.insert("ticketEvents", {
        ticketId: t._id,
        type: "REQUESTER_CHANGED",
        payload: {
          fromUserId: fromUser?._id ?? null,
          fromEmail: normalizedFrom,
          toUserId,
          toUserName: toUser.name,
        },
        createdAt: now,
      })
      updated += 1
    }

    return {
      dryRun: false as const,
      fromEmail: normalizedFrom,
      toUserId,
      candidates: candidates.length,
      updated,
    }
  },
})

/**
 * Query paginada para listagem de tickets.
 * Utiliza a paginação nativa do Convex para evitar carregar todos os tickets de uma vez.
 * Filtros são aplicados no servidor para melhor performance.
 */
export const listPaginated = query({
  args: {
    viewerId: v.optional(v.id("users")),
    tenantId: v.string(),
    status: v.optional(v.string()),
    priority: v.optional(v.union(v.string(), v.array(v.string()))),
    channel: v.optional(v.string()),
    queueId: v.optional(v.id("queues")),
    assigneeId: v.optional(v.id("users")),
    requesterId: v.optional(v.id("users")),
    search: v.optional(v.string()),
    paginationOpts: paginationOptsValidator,
  },
  handler: async (ctx, args) => {
    if (!args.viewerId) {
      return { page: [], isDone: true, continueCursor: "" };
    }
    const viewerId = args.viewerId as Id<"users">;
    const { user, role } = await requireUser(ctx, viewerId, args.tenantId);
    if (role === "MANAGER" && !user.companyId) {
      throw new ConvexError("Gestor não possui empresa vinculada");
    }

    const normalizedStatusFilter = args.status ? normalizeStatus(args.status) : null;
    const normalizedPriorityFilter = normalizePriorityFilter(args.priority);
    const prioritySet = normalizedPriorityFilter.length > 0 ? new Set(normalizedPriorityFilter) : null;
    const normalizedChannelFilter = args.channel ? args.channel.toUpperCase() : null;
    const searchTerm = args.search?.trim().toLowerCase() ?? null;

    // Constrói a query base com índice apropriado
    let baseQuery;
    if (role === "MANAGER") {
      baseQuery = ctx.db
        .query("tickets")
        .withIndex("by_tenant_company", (q) => q.eq("tenantId", args.tenantId).eq("companyId", user.companyId!));
    } else if (args.assigneeId) {
      baseQuery = ctx.db
        .query("tickets")
        .withIndex("by_tenant_assignee", (q) => q.eq("tenantId", args.tenantId).eq("assigneeId", args.assigneeId!));
    } else if (args.requesterId) {
      baseQuery = ctx.db
        .query("tickets")
        .withIndex("by_tenant_requester", (q) => q.eq("tenantId", args.tenantId).eq("requesterId", args.requesterId!));
    } else if (args.queueId) {
      baseQuery = ctx.db
        .query("tickets")
        .withIndex("by_tenant_queue", (q) => q.eq("tenantId", args.tenantId).eq("queueId", args.queueId!));
    } else if (normalizedStatusFilter) {
      baseQuery = ctx.db
        .query("tickets")
        .withIndex("by_tenant_status", (q) => q.eq("tenantId", args.tenantId).eq("status", normalizedStatusFilter));
    } else {
      baseQuery = ctx.db.query("tickets").withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId));
    }

    // Executa a paginação
    const paginationResult = await baseQuery.order("desc").paginate(args.paginationOpts);

    // Aplica filtros que não puderam ser feitos via índice
    let filtered = paginationResult.page;
    if (role === "MANAGER") {
      filtered = filtered.filter((t) => t.companyId === user.companyId);
    }
    if (prioritySet) {
      filtered = filtered.filter((t) => prioritySet.has(t.priority));
    }
    if (normalizedChannelFilter) {
      filtered = filtered.filter((t) => t.channel === normalizedChannelFilter);
    }
    if (args.assigneeId && !args.assigneeId) {
      filtered = filtered.filter((t) => String(t.assigneeId ?? "") === String(args.assigneeId));
    }
    if (args.requesterId && !args.requesterId) {
      filtered = filtered.filter((t) => String(t.requesterId) === String(args.requesterId));
    }
    if (normalizedStatusFilter && !args.queueId && !args.assigneeId && !args.requesterId && role !== "MANAGER") {
      filtered = filtered.filter((t) => normalizeStatus(t.status) === normalizedStatusFilter);
    }
    if (searchTerm) {
      filtered = filtered.filter(
        (t) =>
          t.subject.toLowerCase().includes(searchTerm) ||
          t.summary?.toLowerCase().includes(searchTerm) ||
          `#${t.reference}`.toLowerCase().includes(searchTerm)
      );
    }

    // Enriquece os dados dos tickets
    if (filtered.length === 0) {
      return {
        page: [],
        isDone: paginationResult.isDone,
        continueCursor: paginationResult.continueCursor,
      };
    }

    const [
      requesterDocs,
      assigneeDocs,
      queueDocs,
      companyDocs,
      machineDocs,
      activeSessionDocs,
      categoryDocs,
      subcategoryDocs,
    ] = await Promise.all([
      loadDocs(ctx, filtered.map((t) => t.requesterId)),
      loadDocs(ctx, filtered.map((t) => (t.assigneeId as Id<"users"> | null) ?? null)),
      loadDocs(ctx, filtered.map((t) => (t.queueId as Id<"queues"> | null) ?? null)),
      loadDocs(ctx, filtered.map((t) => (t.companyId as Id<"companies"> | null) ?? null)),
      loadDocs(ctx, filtered.map((t) => (t.machineId as Id<"machines"> | null) ?? null)),
      loadDocs(ctx, filtered.map((t) => (t.activeSessionId as Id<"ticketWorkSessions"> | null) ?? null)),
      loadDocs(ctx, filtered.map((t) => (t.categoryId as Id<"ticketCategories"> | null) ?? null)),
      loadDocs(ctx, filtered.map((t) => (t.subcategoryId as Id<"ticketSubcategories"> | null) ?? null)),
    ]);

    const serverNow = Date.now();
    const page = filtered.map((t) => {
      const requesterSnapshot = t.requesterSnapshot as UserSnapshot | undefined;
      const requesterDoc = requesterDocs.get(String(t.requesterId)) ?? null;
      const requesterSummary = requesterDoc
        ? buildRequesterSummary(requesterDoc, t.requesterId, { ticketId: t._id })
        : buildRequesterFromSnapshot(t.requesterId, requesterSnapshot, { ticketId: t._id });

      const assigneeDoc = t.assigneeId ? assigneeDocs.get(String(t.assigneeId)) ?? null : null;
      const assigneeSummary = t.assigneeId
        ? assigneeDoc
          ? {
              id: assigneeDoc._id,
              name: assigneeDoc.name,
              email: assigneeDoc.email,
              avatarUrl: assigneeDoc.avatarUrl,
              teams: normalizeTeams(assigneeDoc.teams),
            }
          : buildAssigneeFromSnapshot(t.assigneeId, t.assigneeSnapshot ?? undefined)
        : null;

      const queueDoc = t.queueId ? queueDocs.get(String(t.queueId)) ?? null : null;
      const queueName = normalizeQueueName(queueDoc);

      const companyDoc = t.companyId ? companyDocs.get(String(t.companyId)) ?? null : null;
      const companySummary = companyDoc
        ? { id: companyDoc._id, name: companyDoc.name, isAvulso: companyDoc.isAvulso ?? false }
        : t.companyId || t.companySnapshot
        ? buildCompanyFromSnapshot(t.companyId as Id<"companies"> | undefined, t.companySnapshot ?? undefined)
        : null;

      const machineSnapshot = t.machineSnapshot as
        | { hostname?: string; persona?: string; assignedUserName?: string; assignedUserEmail?: string; status?: string }
        | undefined;
      const machineDoc = t.machineId ? machineDocs.get(String(t.machineId)) ?? null : null;
      let machineSummary: {
        id: Id<"machines"> | null;
        hostname: string | null;
        persona: string | null;
        assignedUserName: string | null;
        assignedUserEmail: string | null;
        status: string | null;
      } | null = null;
      if (t.machineId) {
        machineSummary = {
          id: t.machineId,
          hostname: machineDoc?.hostname ?? machineSnapshot?.hostname ?? null,
          persona: machineDoc?.persona ?? machineSnapshot?.persona ?? null,
          assignedUserName: machineDoc?.assignedUserName ?? machineSnapshot?.assignedUserName ?? null,
          assignedUserEmail: machineDoc?.assignedUserEmail ?? machineSnapshot?.assignedUserEmail ?? null,
          status: machineDoc?.status ?? machineSnapshot?.status ?? null,
        };
      } else if (machineSnapshot) {
        machineSummary = {
          id: null,
          hostname: machineSnapshot.hostname ?? null,
          persona: machineSnapshot.persona ?? null,
          assignedUserName: machineSnapshot.assignedUserName ?? null,
          assignedUserEmail: machineSnapshot.assignedUserEmail ?? null,
          status: machineSnapshot.status ?? null,
        };
      }

      const categoryDoc = t.categoryId ? categoryDocs.get(String(t.categoryId)) ?? null : null;
      const categorySummary = categoryDoc ? { id: categoryDoc._id, name: categoryDoc.name } : null;

      const subcategoryDoc = t.subcategoryId ? subcategoryDocs.get(String(t.subcategoryId)) ?? null : null;
      const subcategorySummary = subcategoryDoc
        ? { id: subcategoryDoc._id, name: subcategoryDoc.name, categoryId: subcategoryDoc.categoryId }
        : null;

      const activeSessionDoc = t.activeSessionId ? activeSessionDocs.get(String(t.activeSessionId)) ?? null : null;
      const activeSession = activeSessionDoc
        ? {
            id: activeSessionDoc._id,
            agentId: activeSessionDoc.agentId,
            startedAt: activeSessionDoc.startedAt,
            workType: activeSessionDoc.workType ?? "INTERNAL",
          }
        : null;

      return {
        id: t._id,
        reference: t.reference,
        tenantId: t.tenantId,
        subject: t.subject,
        summary: t.summary,
        status: normalizeStatus(t.status),
        priority: t.priority,
        channel: t.channel,
        queue: queueName,
        csatScore: typeof t.csatScore === "number" ? t.csatScore : null,
        csatMaxScore: typeof t.csatMaxScore === "number" ? t.csatMaxScore : null,
        csatComment: typeof t.csatComment === "string" && t.csatComment.trim().length > 0 ? t.csatComment.trim() : null,
        csatRatedAt: t.csatRatedAt ?? null,
        csatRatedBy: t.csatRatedBy ? String(t.csatRatedBy) : null,
        formTemplate: t.formTemplate ?? null,
        formTemplateLabel: resolveFormTemplateLabel(t.formTemplate ?? null, t.formTemplateLabel ?? null),
        company: companySummary,
        requester: requesterSummary,
        assignee: assigneeSummary,
        slaPolicy: null,
        dueAt: t.dueAt ?? null,
        visitStatus: t.visitStatus ?? null,
        visitPerformedAt: t.visitPerformedAt ?? null,
        firstResponseAt: t.firstResponseAt ?? null,
        resolvedAt: t.resolvedAt ?? null,
        updatedAt: t.updatedAt,
        createdAt: t.createdAt,
        tags: t.tags ?? [],
        lastTimelineEntry: null,
        metrics: null,
        category: categorySummary,
        subcategory: subcategorySummary,
        machine: machineSummary,
        workSummary: {
          totalWorkedMs: t.totalWorkedMs ?? 0,
          internalWorkedMs: t.internalWorkedMs ?? 0,
          externalWorkedMs: t.externalWorkedMs ?? 0,
          serverNow,
          activeSession,
        },
      };
    });

    return {
      page,
      isDone: paginationResult.isDone,
      continueCursor: paginationResult.continueCursor,
    };
  },
})

// Exporta tickets resolvidos para arquivamento externo (somente com segredo)
export const exportForArchive = query({
  args: {
    tenantId: v.string(),
    before: v.number(), // timestamp ms
    limit: v.optional(v.number()),
    secret: v.optional(v.string()),
  },
  handler: async (ctx, args) => {
    const allowedSecret = process.env["INTERNAL_HEALTH_TOKEN"] ?? process.env["REPORTS_CRON_SECRET"]
    if (allowedSecret && args.secret !== allowedSecret) {
      throw new ConvexError("Nao autorizado")
    }
    const cutoff = args.before
    const limit = Math.min(args.limit ?? 50, 200)
    const candidates = await ctx.db
      .query("tickets")
      .withIndex("by_tenant_resolved", (q) => q.eq("tenantId", args.tenantId).lt("resolvedAt", cutoff))
      .order("desc")
      .take(limit)

    const result: Array<{
      ticket: Doc<"tickets">
      comments: Array<Doc<"ticketComments">>
      events: Array<Doc<"ticketEvents">>
    }> = []

    for (const t of candidates) {
      const comments = await ctx.db
        .query("ticketComments")
        .withIndex("by_ticket", (q) => q.eq("ticketId", t._id))
        .take(200)

      const events = await ctx.db
        .query("ticketEvents")
        .withIndex("by_ticket", (q) => q.eq("ticketId", t._id))
        .order("desc")
        .take(200)

      result.push({
        ticket: t,
        comments,
        events,
      })
    }

    return {
      total: result.length,
      items: result.map((item) => ({
        ticket: item.ticket,
        comments: item.comments.map((c) => ({
          ...c,
          attachments: (c.attachments ?? []).map((att) => ({
            storageId: att.storageId,
            name: att.name,
            size: att.size ?? null,
            type: att.type ?? null,
          })),
        })),
        events: item.events,
      })),
    }
  },
})
