// Data layer for SINTRACON admin v3
// - Falls back to mock data for empty/error responses so the UI looks complete
// - All API calls go through `api()` which attaches the JWT and handles 401

const TOKEN_KEY = 'sintracon_admin_token';
const THEME_KEY = 'sintracon_admin_theme';

// Hierarquia de roles (RBAC Fase 3). Espelha server/middleware/require_role.js.
// viewer < editor < admin < owner.
const ROLE_LEVEL = { viewer: 1, editor: 2, admin: 3, owner: 4 };

// Token vive em sessionStorage (não localStorage): assim fechar o navegador
// encerra a sessão e o próximo acesso cai no login pedindo senha. Recarregar
// a mesma aba mantém o login. Limpa qualquer token legado deixado em
// localStorage por versões anteriores pra não persistir sessão indevidamente.
try { localStorage.removeItem(TOKEN_KEY); } catch (e) {}

const Auth = {
  get token() { try { return sessionStorage.getItem(TOKEN_KEY) || ''; } catch (e) { return ''; } },
  set token(v) {
    try {
      if (v) sessionStorage.setItem(TOKEN_KEY, v);
      else   sessionStorage.removeItem(TOKEN_KEY);
    } catch (e) {}
  },
  // Role do user atual — populada via DataLoader.whoami() no boot.
  // Default 'admin' até carregar (compat com tokens legados sem role).
  userRole: 'admin',
  /**
   * Checa se user atual tem permissão pra ação. Aceita string com role mínima
   * OU array de ações conhecidas. Usado em toda UI pra esconder/desabilitar
   * elementos. Retorna false se role insuficiente.
   *
   * Ex:
   *   Auth.userCan('admin')           → user é admin OU owner
   *   Auth.userCan('manage_users')    → user é owner (mapeado via PERMISSIONS)
   */
  userCan(minRoleOrAction) {
    if (!minRoleOrAction) return true;
    // Resolve action → minRole se aparecer em PERMISSIONS, senão assume role direta.
    const minRole = PERMISSIONS[minRoleOrAction] || minRoleOrAction;
    const myLevel = ROLE_LEVEL[this.userRole] || 0;
    const reqLevel = ROLE_LEVEL[minRole] || 0;
    return myLevel >= reqLevel;
  },
  logout() { this.token = ''; window.location.reload(); },
};

// Mapeamento de ações conhecidas → role mínima requerida. Centralizar aqui
// facilita revisão e evita strings espalhadas pelo código. Espelha as
// decisões do middleware backend.
const PERMISSIONS = {
  // owner
  manage_users:    'owner',
  // admin
  edit_branding:   'admin',
  edit_persona:    'admin',
  edit_smtp:       'admin',
  edit_llm_config: 'admin',
  edit_system:     'admin',
  delete_files:    'admin',   // hard-delete arquivos
  lgpd_actions:    'admin',
  reindex:         'admin',
  // editor
  upload_files:    'editor',
  edit_contexts:   'editor',
  edit_pages:      'editor',
  rate_responses:  'editor',
  ai_improve:      'editor',
  // viewer (default — qualquer authenticated)
  view:            'viewer',
};

async function api(path, opts = {}) {
  const headers = Object.assign({}, opts.headers || {});
  if (Auth.token) headers['Authorization'] = 'Bearer ' + Auth.token;
  if (opts.body && !(opts.body instanceof FormData) && !headers['Content-Type']) {
    headers['Content-Type'] = 'application/json';
  }
  const init = Object.assign({}, opts, { headers });
  if (init.body && typeof init.body === 'object' && !(init.body instanceof FormData)) {
    init.body = JSON.stringify(init.body);
  }
  const r = await fetch(path, init);
  if (r.status === 401) {
    Auth.token = '';
    // Sessão inválida/expirada no meio do uso: avisa o App pra voltar ao login
    // em vez de deixar a casca renderizada sem conteúdo.
    try { window.dispatchEvent(new CustomEvent('auth:expired')); } catch (e) {}
    throw new Error('unauthorized');
  }
  const txt = await r.text();
  let json = null;
  try { json = txt ? JSON.parse(txt) : null; } catch (e) { /* not json */ }
  if (!r.ok) {
    const msg = (json && (json.error || json.message)) || ('HTTP ' + r.status);
    // 403 = sem permissão. Emite evento global pro app mostrar toast amigável,
    // além de lançar erro normal (handler local ainda funciona).
    if (r.status === 403) {
      try {
        window.dispatchEvent(new CustomEvent('rbac:forbidden', {
          detail: {
            message: msg,
            required_role: json && json.required_role,
            current_role: json && json.current_role,
            path,
          },
        }));
      } catch (e) {}
    }
    throw new Error(msg);
  }
  return json;
}

// ---------------------------------------------------------------------------
// Static fallback data — used while API call resolves OR if it fails. Keeps
// the UI populated so a fresh install / disconnected dev environment still
// looks like the design.
// ---------------------------------------------------------------------------
const Data = {
  // Estado inicial neutro — todos populados via DataLoader.* no boot.
  // Não preencher com valores fake aqui: a UI mostra 0/— enquanto carrega.
  stats: {
    conversations: 0,
    conversationsDelta: 0,
    files: 0,
    avgTime: 0,
    avgMsDelta: 0,
    fallback: 0,
    fallbackDelta: 0,
    sessions: 0,
    sessionsActive30: null,
    contexts: 0,
    sitePages: 0,
  },
  conversationsByDay: [],
  topQuestions: [],
  ragHealth: [
    { name: "Similaridade média", val: 0, max: 100, unit: "%", color: "gr" },
    { name: "Chunks indexados", val: 0, max: 100, unit: "", color: "bl" },
  ],
  ragHealthMeta: { lowSim: 0, totalAnswered: 0, avgTopSim: null },
  // Estado de ratings (Fase 1). Populado em DataLoader.metrics().
  ratings: {
    totalRated: 0,
    totalConversations: 0,
    avgRating: null,
    histogram: { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 },
    trendDaily: [],
    lowRatedRecent: [],
  },
  channels: [],
  activity: [],
  recentLogs: [],
  directContexts: [],
  files: [],
  sitePages: [],
  redirects: [],
  voices: [
    { id: "piper-faber", name: "Faber", desc: "Masculina, pt-BR · recomendada", gender: "M" },
    { id: "piper-edresson", name: "Edresson", desc: "Feminina, pt-BR", gender: "F" },
    { id: "piper-ana", name: "Ana", desc: "Feminina, pt-BR · jovem", gender: "F" },
  ],
  fonts: [
    "Inter", "Roboto", "Open Sans", "Montserrat", "Poppins", "Lato",
    "Nunito", "Ubuntu", "Raleway", "Sora", "JetBrains Mono",
  ],
  endpoints: {
    public: [
      { m: "GET",  p: "/api/persona", d: "Retorna a configuração visual do widget (cores, fontes, mascote, mensagem)." },
      { m: "POST", p: "/api/v1/chat/stream", d: "Envio de mensagem com resposta em streaming (SSE). Recomendado." },
      { m: "POST", p: "/api/v1/chat", d: "Versão não-streaming. Use só se streaming não funcionar." },
      { m: "POST", p: "/api/tts", d: "Text-to-Speech. Recebe { text } e devolve áudio MP3." },
      { m: "GET",  p: "/api/v1/health", d: "Health check: banco, Ollama e modelos carregados." },
      { m: "GET",  p: "/api/version", d: "Versão atual do servidor." },
    ],
    auth: [
      { m: "POST", p: "/api/admin/login", d: "Emite JWT." },
      { m: "POST", p: "/api/admin/logout", d: "Revoga JWT (blacklist)." },
      { m: "GET",  p: "/api/admin/whoami", d: "Info do token atual." },
    ],
    config: [
      { m: "GET",  p: "/api/admin/persona", d: "Lê persona (tom, modelo, voz, cores, mensagens)." },
      { m: "POST", p: "/api/admin/persona", d: "Salva persona." },
      { m: "GET",  p: "/api/admin/direct-contexts", d: "Lê contexto direto." },
      { m: "POST", p: "/api/admin/direct-contexts", d: "Salva contexto direto." },
      { m: "GET",  p: "/api/admin/system-config", d: "Lê configurações operacionais." },
      { m: "PATCH",p: "/api/admin/system-config", d: "Atualiza configurações operacionais." },
    ],
    rag: [
      { m: "GET",  p: "/api/admin/files", d: "Lista arquivos indexados." },
      { m: "POST", p: "/api/admin/upload", d: "Upload de arquivo (multipart/form-data)." },
      { m: "POST", p: "/api/admin/process", d: "Processa arquivos pendentes." },
      { m: "POST", p: "/api/admin/reindex", d: "Marca todos os arquivos para reprocessar." },
      { m: "DELETE",p: "/api/admin/files/:id", d: "Remove arquivo e seus embeddings." },
      { m: "GET",  p: "/api/admin/site-pages", d: "Lista URLs indexadas." },
      { m: "POST", p: "/api/admin/site-pages", d: "Indexa nova URL." },
    ],
  },
  cmdkItems: [
    { group: "Navegar", icon: "fa-chart-line", title: "Ir para Dashboard", to: "dashboard" },
    { group: "Navegar", icon: "fa-brain", title: "Contexto Direto", to: "knowledge:context" },
    { group: "Navegar", icon: "fa-folder-open", title: "Repositório de arquivos", to: "knowledge:files" },
    { group: "Navegar", icon: "fa-globe", title: "Páginas do site", to: "knowledge:pages" },
    { group: "Navegar", icon: "fa-rss", title: "CMS (conteúdo do site)", to: "knowledge:cms" },
    { group: "Navegar", icon: "fa-user-cog", title: "Persona da IA", to: "chatbot:persona" },
    { group: "Navegar", icon: "fa-comments", title: "Aparência do widget", to: "chatbot:chat" },
    { group: "Navegar", icon: "fa-terminal", title: "System prompt", to: "chatbot:prompt" },
    { group: "Navegar", icon: "fa-headset", title: "Atendimento humano", to: "chatbot:handoff" },
    { group: "Navegar", icon: "fa-history", title: "Histórico de conversas", to: "history" },
    { group: "Navegar", icon: "fa-sliders-h", title: "Configurações do sistema", to: "system:config" },
    { group: "Navegar", icon: "fa-stethoscope", title: "Diagnóstico", to: "system:diag" },
    { group: "Navegar", icon: "fa-code", title: "API & Integração", to: "system:api" },
    { group: "Ações", icon: "fa-plus", title: "Adicionar contexto direto", action: "add-context" },
    { group: "Ações", icon: "fa-upload", title: "Upload de arquivo", action: "upload" },
    { group: "Ações", icon: "fa-link", title: "Cadastrar página do site", action: "add-page" },
    { group: "Ações", icon: "fa-sync", title: "Reindexar todos os arquivos", action: "reindex" },
    { group: "Ações", icon: "fa-trash-alt", title: "Limpar cache de respostas", action: "clear-cache" },
    { group: "Ações", icon: "fa-sign-out-alt", title: "Sair da sessão", action: "logout" },
    { group: "Visual", icon: "fa-moon", title: "Trocar tema (dark/light)", action: "toggle-theme" },
    { group: "Visual", icon: "fa-comments", title: "Abrir o chatbot (canto inferior)", action: "open-preview" },
  ],
};

// ---------------------------------------------------------------------------
// Real API loaders — populate Data.* in place. Components read from Data so
// the UI never crashes if a fetch fails; it just keeps the seed values.
// ---------------------------------------------------------------------------
const DataLoader = {
  async metrics() {
    try {
      const r = await api('/api/admin/metrics?days=14');
      if (!r) return;
      // Hoje (do delta) é o que importa pro card "Conversas hoje"
      const today = (r.deltas && r.deltas.today_count) || 0;
      if (today > 0 || r.summary) {
        Data.stats.conversations = today;
      }
      if (r.summary) {
        const s = r.summary;
        if (typeof parseFloat(s.fallback_pct) === 'number' && !Number.isNaN(parseFloat(s.fallback_pct))) {
          Data.stats.fallback = +parseFloat(s.fallback_pct).toFixed(1);
        }
        if (typeof s.avg_total_ms === 'number') Data.stats.avgTime = +(s.avg_total_ms / 1000).toFixed(2);
        if (typeof s.unique_sessions === 'number') Data.stats.sessions = s.unique_sessions;
      }
      // Deltas reais
      if (r.deltas) {
        Data.stats.conversationsDelta = r.deltas.conversations_pct_vs_yesterday || 0;
        Data.stats.fallbackDelta = r.deltas.fallback_pp_vs_prev_window || 0;
        Data.stats.avgMsDelta = r.deltas.avg_ms_vs_prev_window || 0;
      }
      if (typeof r.active_sessions_30min === 'number') {
        Data.stats.sessionsActive30 = r.active_sessions_30min;
      }
      // RAG health real
      if (r.rag_health) {
        Data.ragHealthMeta = {
          lowSim: r.rag_health.low_similarity_count || 0,
          totalAnswered: r.rag_health.total_answered || 0,
          avgTopSim: r.rag_health.avg_top_similarity,
        };
        if (typeof r.rag_health.avg_top_similarity === 'number') {
          const idx = Data.ragHealth.findIndex((row) => /similaridade/i.test(row.name));
          if (idx >= 0) Data.ragHealth[idx].val = Math.round(r.rag_health.avg_top_similarity * 100);
        }
      }
      if (Array.isArray(r.daily) && r.daily.length) {
        // daily comes desc — reverse for chronological order
        const days = [...r.daily].reverse();
        Data.conversationsByDay = days.map((row) => ({
          d: row.day ? new Date(row.day).toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' }) : '',
          v: row.total_messages || 0,
          fb: row.fallbacks || 0,
        }));
      }
      // Bloco de ratings (Fase 1 do sistema de qualidade). Pode vir undefined
      // se a versão do backend ainda não expõe — UI fallback pra null.
      if (r.ratings) {
        Data.ratings = {
          totalRated:        r.ratings.total_rated || 0,
          totalConversations: r.ratings.total_conversations || 0,
          avgRating:         typeof r.ratings.avg_rating === 'number' ? r.ratings.avg_rating : null,
          histogram:         r.ratings.histogram || { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 },
          trendDaily:        Array.isArray(r.ratings.trend_daily) ? r.ratings.trend_daily : [],
          lowRatedRecent:    Array.isArray(r.ratings.low_rated_recent) ? r.ratings.low_rated_recent : [],
        };
      }
    } catch (e) { /* swallow */ }
  },
  async topQuestions() {
    try {
      const r = await api('/api/admin/top-questions?limit=7');
      if (r && Array.isArray(r.questions) && r.questions.length) {
        Data.topQuestions = r.questions;
      } else {
        Data.topQuestions = [];
      }
    } catch (e) { Data.topQuestions = []; }
  },
  async activity() {
    try {
      const r = await api('/api/admin/activity?limit=10');
      if (r && Array.isArray(r.activity)) Data.activity = r.activity;
      else Data.activity = [];
    } catch (e) { Data.activity = []; }
  },
  async debugLogs() {
    try {
      const r = await api('/api/admin/debug-logs?limit=20');
      if (r && Array.isArray(r.lines)) return r.lines;
    } catch (e) {}
    return null;
  },
  async whoami() {
    try {
      const r = await api('/api/admin/whoami');
      if (r && r.user) {
        // Popula role no Auth pra toda UI usar via Auth.userCan(...)
        if (r.user.role) Auth.userRole = r.user.role;
        return r.user;
      }
    } catch (e) {}
    return null;
  },
  async files() {
    try {
      const r = await api('/api/admin/files');
      if (r && Array.isArray(r.files || r)) {
        const list = r.files || r;
        Data.files = list.map((f) => {
          const ft = String(f.file_type || f.type || '').toLowerCase();
          const type = ft.includes('pdf') ? 'PDF'
                     : ft.includes('docx') || ft.includes('word') ? 'DOCX'
                     : ft.includes('image') || ft.includes('jpg') || ft.includes('png') ? 'IMG'
                     : ft === 'text' ? 'TEXT'
                     : ft === 'md' || ft === 'markdown' ? 'MD'
                     : 'TXT';
          const ts = f.processed_at || f.uploaded_at || f.indexed_at || f.created_at;
          return {
            id: f.id,
            name: f.original_name || f.title || f.filename || '—',
            type,
            sourceType: f.source_type || 'upload',
            docType: f.doc_type || null,
            status: f.status || 'indexed',
            date: ts ? new Date(ts).toLocaleString('pt-BR') : '—',
            size: f.file_size_kb ? humanSize(f.file_size_kb * 1024) : (f.size_bytes ? humanSize(f.size_bytes) : '—'),
            chunks: f.chunk_count || 0,
          };
        });
        Data.stats.files = Data.files.filter((f) => f.status === 'indexed').length;
        // Atualiza chunks indexados na "Saúde do RAG" (índice 1 — "Chunks indexados")
        const totalChunks = Data.files.reduce((s, f) => s + (f.chunks || 0), 0);
        const idx = Data.ragHealth.findIndex((r) => /chunks/i.test(r.name));
        if (idx >= 0) {
          Data.ragHealth[idx].val = totalChunks;
          Data.ragHealth[idx].max = Math.max(totalChunks * 1.5, 100);
        }
      }
    } catch (e) {}
  },
  async sitePages() {
    try {
      const r = await api('/api/admin/site-pages');
      if (r && Array.isArray(r.pages || r)) {
        const list = r.pages || r;
        Data.sitePages = list.map((p) => {
          const url = p.source_url || p.url || '';
          return {
            id: p.id,
            title: p.title || p.original_name || url,
            url,
            desc: p.description || p.extracted_text || '',
            date: p.created_at ? new Date(p.created_at).toLocaleDateString('pt-BR') : (p.uploaded_at ? new Date(p.uploaded_at).toLocaleDateString('pt-BR') : '—'),
            status: p.status || 'indexed',
          };
        });
      }
    } catch (e) {}
  },
  async directContexts() {
    try {
      const r = await api('/api/admin/direct-contexts');
      if (r && Array.isArray(r.contexts || r)) {
        const list = r.contexts || r;
        Data.directContexts = list.map((c) => ({
          id: c.id,
          title: c.title,
          body: c.body || '',
          tags: typeof c.tag === 'string'
            ? c.tag.split(',').map((s) => s.trim()).filter(Boolean)
            : (Array.isArray(c.tags) ? c.tags : []),
          always: !!c.always_inject,
          enabled: c.enabled !== false,
          kind: c.kind === 'diretriz' ? 'diretriz' : 'informacao',
          // ISO string ou null. Backend retorna timestamptz já formatado.
          expires_at: c.expires_at || null,
        }));
      }
    } catch (e) {}
  },
  async redirects() {
    try {
      const r = await api('/api/admin/support-redirects');
      if (r && Array.isArray(r.redirects || r)) {
        const list = r.redirects || r;
        Data.redirects = list.map((rd) => ({
          id: rd.id,
          channel: rd.channel,
          label: rd.label,
          url: rd.url,
          sort: rd.sort_order || rd.sort || 100,
          enabled: rd.enabled !== false,
        }));
        Data.channels = Data.redirects.slice(0, 3).map((rd) => {
          const map = {
            whatsapp: { t: 'wa',   ico: 'fa-whatsapp', n: 'WhatsApp' },
            phone:    { t: 'tel',  ico: 'fa-phone',    n: 'Telefone Geral' },
            email:    { t: 'mail', ico: 'fa-envelope', n: 'E-mail' },
            rdstation:{ t: 'mail', ico: 'fa-broadcast-tower', n: 'RD Station' },
            link:     { t: 'tel',  ico: 'fa-link', n: 'Link' },
          };
          const m = map[rd.channel] || map.link;
          return { type: m.t, name: rd.label || m.n, url: rd.url.replace(/^https?:\/\//, ''), active: rd.enabled !== false, icon: m.ico };
        });
      }
    } catch (e) {}
  },
  async logs(opts = {}) {
    try {
      // Compat: aceita logs(2) (número = página) ou logs({page, since, search, filter}).
      const o = (typeof opts === 'number') ? { page: opts } : (opts || {});
      const page   = o.page  || 1;
      const limit  = o.limit || 20;
      const params = new URLSearchParams();
      params.set('page', page);
      params.set('limit', limit);
      if (o.since) params.set('since', o.since);
      if (o.search && o.search.trim()) params.set('search', o.search.trim());
      if (o.filter && o.filter !== 'all') params.set('filter', o.filter);
      const r = await api('/api/admin/logs?' + params.toString());
      if (r && Array.isArray(r.logs || r)) {
        const list = r.logs || r;
        Data.recentLogs = list.map((l) => {
          const ts = l.created_at ? new Date(l.created_at) : null;
          const sourcesList = parseSources(l.sources_used);
          return {
            id: l.id,
            session: l.session_id,
            time: ts ? ts.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) : '—',
            timeFull: ts ? ts.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '—',
            date: ts ? ts.toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '—',
            dateShort: ts ? ts.toLocaleDateString('pt-BR') : '—',
            iso: ts ? ts.toISOString() : null,
            q: l.user_message || l.question || '',
            a: l.bot_response || l.bot_message || l.answer || '',
            // PG retorna NUMERIC como string — usar parseFloat sempre.
            sim:    parseNumeric(l.top_similarity ?? l.similarity ?? l.max_similarity),
            minSim: parseNumeric(l.min_similarity),
            sources: sourcesList.length,
            sourcesList,
            fb: typeof l.fallback_triggered === 'boolean' ? l.fallback_triggered : !!l.is_fallback,
            responseTimeMs: parseIntOrZero(l.response_time_ms),
            timings: {
              embedding:   parseIntOrZero(l.time_embedding_ms),
              retrieval:   parseIntOrZero(l.time_retrieval_ms),
              llm:         parseIntOrZero(l.time_llm_ms),
              postprocess: parseIntOrZero(l.time_postprocess_ms),
            },
            streamMode: !!l.stream_mode,
          };
        });
        return {
          total: r.total != null ? r.total : list.length,
          totalPages: r.totalPages || 1,
          counts: r.counts || null,
          avgSim: r.avgSim != null ? String(r.avgSim) : null,
        };
      }
    } catch (e) {}
    return null;
  },
  async usageSummary() {
    try {
      const r = await api('/api/admin/usage/summary');
      if (r && r.totals) return r;
    } catch (e) {}
    return null;
  },
  async conversationThread(sessionId) {
    try {
      const r = await api('/api/admin/conversations/' + encodeURIComponent(sessionId));
      if (r && Array.isArray(r.messages)) {
        return r.messages.map((m) => {
          const ts = m.created_at ? new Date(m.created_at) : null;
          const sourcesList = parseSources(m.sources_used);
          return {
            id: m.id,
            q: m.user_message || '',
            a: m.bot_response || '',
            sim:    parseNumeric(m.top_similarity),
            minSim: parseNumeric(m.min_similarity),
            sources: sourcesList.length,
            sourcesList,
            fb: !!m.fallback_triggered,
            responseTimeMs: parseIntOrZero(m.response_time_ms),
            timings: {
              embedding:   parseIntOrZero(m.time_embedding_ms),
              retrieval:   parseIntOrZero(m.time_retrieval_ms),
              llm:         parseIntOrZero(m.time_llm_ms),
              postprocess: parseIntOrZero(m.time_postprocess_ms),
            },
            streamMode: !!m.stream_mode,
            date: ts ? ts.toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '—',
            time: ts ? ts.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '—',
            // Avaliação 1-5★ (Fase 1 do sistema de qualidade). rating=null
            // significa "ainda não avaliado". Os outros campos populam só
            // quando rating != null.
            rating: m.admin_rating != null ? Number(m.admin_rating) : null,
            ratingAt: m.admin_rating_at || null,
            ratingBy: m.admin_rating_by || null,
            ratingNote: m.admin_rating_note || '',
          };
        });
      }
    } catch (e) {}
    return null;
  },
};

// PG retorna NUMERIC como string ("0.92") — node-postgres não converte por
// default. Usar essas helpers em qualquer campo numérico vindo do banco.
function parseNumeric(v) {
  if (v == null) return 0;
  const n = parseFloat(v);
  return Number.isFinite(n) ? n : 0;
}
function parseIntOrZero(v) {
  if (v == null) return 0;
  const n = parseInt(v, 10);
  return Number.isFinite(n) ? n : 0;
}

// Normaliza sources_used (pode vir como JSONB já parseado, string JSON, ou null)
function parseSources(raw) {
  if (Array.isArray(raw)) return raw;
  if (typeof raw === 'string') {
    try { const j = JSON.parse(raw); return Array.isArray(j) ? j : []; }
    catch (e) { return []; }
  }
  return [];
}

function humanSize(b) {
  if (b == null || isNaN(b)) return '—';
  const u = ['B', 'KB', 'MB', 'GB', 'TB'];
  let i = 0; while (b >= 1024 && i < u.length - 1) { b /= 1024; i++; }
  return b.toFixed(b < 10 && i > 0 ? 1 : 0) + ' ' + u[i];
}

function humanDuration(secs) {
  if (!secs || secs < 60) return Math.max(0, Math.round(secs || 0)) + 's';
  const m = Math.floor(secs / 60);
  if (m < 60) return m + 'm';
  const h = Math.floor(m / 60);
  const rem = m % 60;
  if (h < 24) return h + 'h ' + rem + 'm';
  const d = Math.floor(h / 24);
  return d + 'd ' + (h % 24) + 'h';
}

Object.assign(window, { Data, DataLoader, api, Auth, TOKEN_KEY, THEME_KEY, humanSize, humanDuration });
