/* pedal-app.jsx — shell, store partilhado, persistência, navegação e Tweaks */

const { useState: useStateA, useEffect: useEffectA, useRef: useRefA } = React;

const STORE_KEY = 'pedal_v3';
// Refresh token da coordenação: localStorage, para a sessão sobreviver a
// reloads, fecho do browser e vários dias — decisão explícita da associação,
// alinhada com o lado do candidato (account.refreshToken). Nunca se persiste
// a password (PED-59): o refresh token é rodado a cada uso e revogável no
// servidor. O access token continua só em memória.
const COORD_REFRESH_KEY = 'pedal_coord_refresh';
function readCoordRefreshToken() {
  // sessionStorage é legado de uma versão intermédia; migra para localStorage
  // na próxima renovação (setCoordJwt) e evita pedir novo login na transição.
  try { return localStorage.getItem(COORD_REFRESH_KEY) || sessionStorage.getItem(COORD_REFRESH_KEY); } catch (_) { return null; }
}
function jwtExpiryMs(jwt) {
  try { return (JSON.parse(atob(jwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))).exp || 0) * 1000; } catch (_) { return 0; }
}
let pedalTurnstileLoader = null;

function loadTurnstileApi() {
  if (window.turnstile) return Promise.resolve(window.turnstile);
  if (pedalTurnstileLoader) return pedalTurnstileLoader;
  pedalTurnstileLoader = new Promise((resolve, reject) => {
    let existing = document.querySelector('script[data-pedal-turnstile]');
    if (existing && existing.dataset.loadFinished === '1' && !window.turnstile) {
      existing.remove();
      existing = null;
    }
    const script = existing || document.createElement('script');
    const timeout = setTimeout(() => {
      script.remove();
      reject(new Error('Turnstile demorou demasiado tempo a carregar'));
    }, 15000);
    const ready = () => {
      clearTimeout(timeout);
      script.dataset.loadFinished = '1';
      if (window.turnstile) resolve(window.turnstile);
      else {
        script.remove();
        reject(new Error('Turnstile não ficou disponível'));
      }
    };
    script.addEventListener('load', ready, { once: true });
    script.addEventListener('error', () => {
      clearTimeout(timeout);
      script.remove();
      reject(new Error('Não foi possível carregar a validação anti-robô'));
    }, { once: true });
    if (!existing) {
      script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
      script.async = true;
      script.defer = true;
      script.dataset.pedalTurnstile = '1';
      document.head.appendChild(script);
    }
  }).catch((error) => {
    pedalTurnstileLoader = null;
    throw error;
  });
  return pedalTurnstileLoader;
}

// Com `host`, o widget é renderizado inline nesse elemento com appearance
// interaction-only: fica invisível e só ganha corpo se a Cloudflare exigir
// interação humana. Sem `host` (fallback), abre o modal de verificação.
async function requestTurnstileToken(siteKey, host) {
  const turnstile = await loadTurnstileApi();
  return new Promise((resolve, reject) => {
    const inline = !!(host && host.isConnected);
    const widget = document.createElement('div');
    let overlay = null;
    if (inline) {
      widget.style.cssText = 'display:flex;justify-content:center';
      host.appendChild(widget);
    } else {
      overlay = document.createElement('div');
      overlay.setAttribute('role', 'dialog');
      overlay.setAttribute('aria-label', 'Validação anti-robô');
      overlay.style.cssText = 'position:fixed;inset:0;z-index:100000;background:rgba(22,22,22,.45);display:flex;align-items:center;justify-content:center;padding:20px';
      const card = document.createElement('div');
      card.style.cssText = 'width:min(100%,390px);background:#fff;border-radius:18px;padding:22px;box-shadow:0 24px 70px rgba(0,0,0,.25);text-align:center;font-family:Helvetica Neue,Arial,sans-serif';
      const title = document.createElement('div');
      title.textContent = 'Só falta uma verificação rápida';
      title.style.cssText = 'font-size:17px;font-weight:800;color:#161616;margin-bottom:8px';
      const help = document.createElement('div');
      help.textContent = 'Esta verificação protege as inscrições contra robôs.';
      help.style.cssText = 'font-size:13px;line-height:1.5;color:#6c736f;margin-bottom:16px';
      widget.style.cssText = 'display:flex;justify-content:center;min-height:65px';
      card.append(title, help, widget);
      overlay.appendChild(card);
      document.body.appendChild(overlay);
    }

    let widgetId = null;
    let settled = false;
    const finish = (error, token) => {
      if (settled) return;
      settled = true;
      clearTimeout(timer);
      if (widgetId != null) {
        try { turnstile.remove(widgetId); } catch (_) {}
      }
      if (overlay) overlay.remove();
      else widget.remove();
      if (error) reject(error);
      else resolve(token);
    };
    const timer = setTimeout(() => finish(new Error('A validação expirou. Tenta novamente.')), 120000);
    try {
      widgetId = turnstile.render(widget, {
        sitekey: siteKey,
        action: 'signup',
        appearance: inline ? 'interaction-only' : 'always',
        size: inline ? 'flexible' : 'normal',
        theme: 'light',
        language: 'pt',
        callback: (token) => finish(null, token),
        'error-callback': () => finish(new Error('A validação anti-robô falhou.')),
        'expired-callback': () => finish(new Error('A validação expirou. Tenta novamente.')),
      });
    } catch (error) {
      finish(error);
    }
  });
}

const INITIAL = {
  stage: null,
  candidate: { name: '', contact: '', email: '', dob: '', cc: '', locality: '', localities: [], periods: [], interview: {} },
  messages: [],
  onboarding: { done: {}, roleAccepted: false },
  validated: false,
  rejection: null,
  chat: { node: 'welcome', interviewStep: 0 },
  tab: 'conversa',
  scheduling: {},   // { [candidateId]: { slots, status, trainerId } }
  trainers: (window.PEDAL && window.PEDAL.SEED_TRAINERS || []).map((t) => ({ ...t })),
  contactRequests: (window.PEDAL && window.PEDAL.SEED_CONTACTS || []).map((c) => ({ ...c })),
  answeredContactIds: [], // ids (Supabase) de dúvidas já mostradas no chat, para nunca duplicar
  candidateId: null,        // ID do candidato no backend (Supabase)
  account: null,            // { email, refreshToken?, createdAt? }; nunca contém passwords
  emailVerificationRequired: false,
  session: { authed: false },// sessão ativa no agente (login)
  signature: null,          // dataURL da rubrica do piloto (formalização)
  termsAccepted: false,     // termos de compromisso aceites
  moduleContent: {},        // { [moduleId]: { videos:[], docs:[], agentInfo } } — conteúdos por fase
  stations: (window.PEDAL && window.PEDAL.SEED_STATIONS || []).map((s) => ({ ...s })),  // locais de encontro
  needs: (window.PEDAL && window.PEDAL.SEED_NEEDS || []).map((n) => ({ ...n })),         // necessidades/vagas abertas
  mgmtUsers: (window.PEDAL && window.PEDAL.SEED_MGMT_USERS || []).map((u) => ({ ...u })), // utilizadores de gestão
  coordProfile: { name: 'Maria Coelho', email: 'maria.coelho@pedalarsemidade.pt', phone: '936 100 200', role: 'Coordenação' },
  moduleConversations: {},   // { [moduleId]: [{ from, text, coord?, coordAuthor?, ts }] }
};

const LEGACY_SECRET_STATE_KEYS = new Set([
  'password',
  'pendingPassword',
  'initialPassword',
  'tempPassword',
]);

// Defesa em profundidade para PED-59: o estado já não recebe passwords nos
// fluxos atuais, mas todas as fronteiras de persistência removem também campos
// legacy. Assim uma aba antiga ou uma alteração futura não os reintroduz em
// localStorage.
function sanitizePersistedState(value) {
  if (Array.isArray(value)) return value.map(sanitizePersistedState);
  if (!value || typeof value !== 'object') return value;
  return Object.fromEntries(
    Object.entries(value)
      .filter(([key]) => !LEGACY_SECRET_STATE_KEYS.has(key))
      .map(([key, nestedValue]) => [key, sanitizePersistedState(nestedValue)])
  );
}

function loadStore() {
  try {
    const raw = localStorage.getItem(STORE_KEY);
    if (!raw) return { ...INITIAL };
    const p = sanitizePersistedState(JSON.parse(raw));
    const account = p.account;
    return { ...INITIAL, ...p, account, candidate: { ...INITIAL.candidate, ...(p.candidate || {}) }, onboarding: { ...INITIAL.onboarding, ...(p.onboarding || {}) }, chat: { ...INITIAL.chat, ...(p.chat || {}) }, scheduling: { ...(p.scheduling || {}) }, trainers: p.trainers || INITIAL.trainers, contactRequests: p.contactRequests || INITIAL.contactRequests, session: { ...INITIAL.session, ...(p.session || {}) }, moduleContent: { ...(p.moduleContent || {}) }, stations: p.stations || INITIAL.stations, mgmtUsers: p.mgmtUsers || INITIAL.mgmtUsers, needs: p.needs || INITIAL.needs, coordProfile: { ...INITIAL.coordProfile, ...(p.coordProfile || {}) }, moduleConversations: { ...(p.moduleConversations || {}) } };
  } catch (e) { return { ...INITIAL }; }
}

const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
  "palette": ["#ED1C24", "#FDE7E8", "#C4151C"],
  "textSize": "Normal",
  "tone": "Caloroso"
}/*EDITMODE-END*/;

function App() {
  const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
  const [S, setS] = useStateA(loadStore);
  const [view, setView] = useStateA(window.__PEDAL_MODE === 'coord' ? 'coordination' : 'candidate');
  const [resetKey, setResetKey] = useStateA(0);
  const [coordJwt, setCoordJwtRaw] = useStateA(null); // não persiste no localStorage
  const [coordRole, setCoordRoleRaw] = useStateA(null);
  // true enquanto se tenta repor a sessão de coordenação com o refresh token
  // guardado — evita mostrar o ecrã de login num reload.
  const [coordRestoring, setCoordRestoring] = useStateA(() => window.__PEDAL_MODE === 'coord' && !!readCoordRefreshToken());
  const [coordProfile, setCoordProfileRaw] = useStateA(null); // não persiste no localStorage — isolado por tab
  const [realCandidates, setRealCandidates] = useStateA(null);
  const [realTrainers, setRealTrainers] = useStateA(null);
  const [realNeeds, setRealNeeds] = useStateA(null);
  const [introVideoUrl, setIntroVideoUrl] = useStateA(null);
  const [documentUrls, setDocumentUrls] = useStateA({}); // { [settingsKey]: { url, name } }
  const [moduleAgentInfo, setModuleAgentInfo] = useStateA({}); // { [moduleId]: texto }
  const [moduleDocuments, setModuleDocuments] = useStateA({}); // { [moduleId]: [{ url, name, text }] }
  const [generalKnowledge, setGeneralKnowledge] = useStateA(''); // texto/FAQ geral para o chat principal
  const [aiEnabled, setAiEnabled] = useStateA(false);
  const [realStations, setRealStations] = useStateA(null);
  const [realLocalities, setRealLocalities] = useStateA(null);
  const [realNotifs, setRealNotifs] = useStateA(null);
  const [realContactRequests, setRealContactRequests] = useStateA(null);
  const [candidateJwt, setCandidateJwtRaw] = useStateA(null);
  const [chatLoaded, setChatLoaded] = useStateA(false);
  // Mensagem de sucesso ao regressar de /nova-palavra-passe (ver pedal-password-recovery.jsx).
  const [passwordJustChanged] = useStateA(() => new URLSearchParams(window.location.search).get('palavra-passe-alterada') === '1');
  const [accountJustActivated] = useStateA(() => new URLSearchParams(window.location.search).get('conta-ativada') === '1');
  useEffectA(() => {
    if (!passwordJustChanged && !accountJustActivated) return;
    window.history.replaceState(null, '', window.location.pathname);
    if (window.__PEDAL_MODE !== 'coord') {
      setS((p) => ({
        ...p,
        tab: 'perfil',
        ...(accountJustActivated ? {
          emailVerificationRequired: false,
          session: { ...p.session, authed: false },
        } : {}),
      }));
    }
  }, []);
  const msgSyncTimer = useRefA();
  const nodeSyncTimer = useRefA();
  const chatLoadedFor = useRefA(null);

  useEffectA(() => {
    localStorage.setItem(STORE_KEY, JSON.stringify(sanitizePersistedState(S)));
  }, [S]);

  // Sincronização em tempo real entre separadores (candidato ↔ coordenação)
  useEffectA(() => {
    const onStorage = (e) => {
      if (e.key === STORE_KEY && e.newValue) {
        try { setS(sanitizePersistedState(JSON.parse(e.newValue))); } catch (_) {}
      }
    };
    window.addEventListener('storage', onStorage);
    return () => window.removeEventListener('storage', onStorage);
  }, []);

  // Guarda mensagens na BD sempre que mudam (debounced 1.5s)
  useEffectA(() => {
    clearTimeout(msgSyncTimer.current);
    if (!S.candidateId || !candidateJwt || !S.messages.length) return;
    msgSyncTimer.current = setTimeout(() => {
      fetch(`/api/candidates/${S.candidateId}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${candidateJwt}` },
        body: JSON.stringify({ chat_messages: S.messages }),
      }).then(warnSync('chat_messages')).catch(() => {});
    }, 1500);
  }, [S.messages, candidateJwt]);

  // Guarda nó actual do chat na BD (debounced 1s)
  const chatNode = S.chat ? S.chat.node : null;
  useEffectA(() => {
    clearTimeout(nodeSyncTimer.current);
    if (!S.candidateId || !candidateJwt || !chatNode) return;
    nodeSyncTimer.current = setTimeout(() => {
      fetch(`/api/candidates/${S.candidateId}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${candidateJwt}` },
        body: JSON.stringify({ chat_node: chatNode }),
      }).then(warnSync('chat_node')).catch(() => {});
    }, 1000);
  }, [chatNode, S.candidateId, candidateJwt]);

  // Carrega histórico de mensagens e nó quando candidato autentica (uma vez por candidato)
  useEffectA(() => {
    if (!S.candidateId || !candidateJwt) {
      if (!chatLoaded) setChatLoaded(true);
      return;
    }
    if (chatLoadedFor.current === S.candidateId) {
      if (!chatLoaded) setChatLoaded(true);
      return;
    }
    fetch(`/api/candidates/${S.candidateId}`, {
      headers: { 'Authorization': `Bearer ${candidateJwt}` },
    })
      .then((r) => r.json())
      .then((data) => {
        if (!data) return;
        const msgs = Array.isArray(data.chat_messages) && data.chat_messages.length > 0 ? data.chat_messages : null;
        const cn = data.chat_node || null;
        const sched = data.scheduling || null;
        const candId = S.candidateId;
        // stages definidos pela coordenação que o candidato precisa de receber
        const coordStages = ['validacao', 'espera', 'onboarding', 'formalizacao', 'ativo', 'rejeitado'];
        const validatedStages = ['onboarding', 'pratica', 'formalizacao', 'ativo'];
        const stageSync = data.stage && coordStages.includes(data.stage) ? data.stage : null;
        if (msgs || cn || sched || stageSync) {
          setS((p) => ({
            ...p,
            ...(msgs ? { messages: msgs } : {}),
            ...(sched && candId ? { scheduling: { ...p.scheduling, [candId]: sched } } : {}),
            ...(stageSync && stageSync !== p.stage ? { stage: stageSync } : {}),
            ...(stageSync && validatedStages.includes(stageSync) && !p.validated ? { validated: true } : {}),
            chat: cn ? { ...p.chat, node: cn, restoreInteraction: !!(msgs && msgs.length > 0) } : p.chat,
          }));
        }
      })
      .catch(() => {})
      .finally(() => {
        chatLoadedFor.current = S.candidateId;
        setChatLoaded(true);
      });
  }, [S.candidateId, candidateJwt]);

  // Polling do agendamento — candidato busca o seu registo para detectar proposta da coordenação
  useEffectA(() => {
    if (!S.candidateId || !candidateJwt) return;
    const pollSched = () => {
      fetch(`/api/candidates/${S.candidateId}`, {
        headers: { 'Authorization': `Bearer ${candidateJwt}` },
      })
        .then((r) => r.json())
        .then((data) => {
          if (!data) return;
          setS((p) => {
            let next = p;
            if (data.scheduling) {
              const cur = p.scheduling[p.candidateId];
              if (JSON.stringify(cur) !== JSON.stringify(data.scheduling)) {
                next = { ...next, scheduling: { ...next.scheduling, [p.candidateId]: data.scheduling } };
              }
            }
            // sincroniza stages definidos pela coordenação
            const coordStages = ['validacao', 'espera', 'onboarding', 'formalizacao', 'ativo', 'rejeitado'];
            const validatedStages = ['onboarding', 'pratica', 'formalizacao', 'ativo'];
            if (data.stage && coordStages.includes(data.stage) && data.stage !== p.stage) {
              next = { ...next, stage: data.stage };
            }
            if (data.stage && validatedStages.includes(data.stage) && !p.validated) {
              next = { ...next, validated: true };
            }
            return next;
          });
        })
        .catch(() => {});
    };
    pollSched();
    const timer = setInterval(pollSched, 15000);
    return () => clearInterval(timer);
  }, [S.candidateId, candidateJwt]);

  // Re-autentica na carga da página com o refresh token guardado (nunca a
  // password — PED-59: uma password nunca deve ficar persistida, o refresh
  // token é revogável no servidor e é o mecanismo próprio do Supabase para isto).
  useEffectA(() => {
    if (!S.account?.refreshToken || candidateJwt) return;
    const authConfig = window.__PEDAL_AUTH_CONFIG || {};
    if (!authConfig.supabaseUrl || !authConfig.supabaseAnonKey) return;
    fetch(`${authConfig.supabaseUrl}/auth/v1/token?grant_type=refresh_token`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'apikey': authConfig.supabaseAnonKey },
      body: JSON.stringify({ refresh_token: S.account.refreshToken }),
    })
      .then((r) => r.json())
      .then((d) => {
        if (d.access_token) {
          setCandidateJwtRaw(d.access_token);
          if (d.refresh_token) setS((p) => ({ ...p, account: { ...p.account, refreshToken: d.refresh_token } }));
        } else {
          console.log('[PEDAL] re-auth falhou:', d.error);
        }
      })
      .catch(() => {});
  }, []); // só na montagem

  // Reposição da sessão de coordenação na carga da página, com o refresh token
  // guardado (ver COORD_REFRESH_KEY). Espelha a re-autenticação do candidato
  // acima; se a renovação falhar cai no ecrã de login.
  useEffectA(() => {
    if (!coordRestoring) return;
    refreshCoordSession().then(() => setCoordRestoring(false));
  }, []); // só na montagem

  // Mantém a sessão de coordenação viva: renova o access token pouco antes de
  // expirar (o Supabase emite tokens de ~1h) e ao regressar ao separador — um
  // portátil suspenso durante horas não dispara timers, mas dispara
  // visibilitychange ao acordar.
  useEffectA(() => {
    if (!coordJwt) return;
    const expMs = jwtExpiryMs(coordJwt);
    let timer;
    const renew = () => refreshCoordSession().then((token) => {
      if (token) return; // o novo coordJwt re-arma este efeito com o novo prazo
      if (!readCoordRefreshToken()) { clearCoordJwt(); return; } // rejeitado no servidor
      timer = setTimeout(renew, 60000); // falha de rede: volta a tentar
    });
    timer = setTimeout(renew, expMs ? Math.max(expMs - Date.now() - 120000, 5000) : 2700000);
    const onVisible = () => {
      if (document.visibilityState === 'visible' && expMs && expMs - Date.now() < 120000) renew();
    };
    document.addEventListener('visibilitychange', onVisible);
    return () => { clearTimeout(timer); document.removeEventListener('visibilitychange', onVisible); };
  }, [coordJwt]);

  // ── store helpers (functional updates) ──
  // Mensagens de transições únicas trazem id determinístico: se já existir no
  // histórico (outro separador ou um reload anunciou primeiro), não duplica.
  const addMessage = (m) => setS((p) => (m.id && p.messages.some((x) => x.id === m.id)
    ? p
    : { ...p, messages: [...p.messages, { id: m.id || ('m' + Math.random().toString(36).slice(2, 9)), ...m }] }));
  const patchCandidate = (c) => setS((p) => ({ ...p, candidate: { ...p.candidate, ...c } }));
  const setStage = (stage) => setS((p) => ({ ...p, stage }));
  const notify = (n) => {
    const candidateId = n.candidateId || n.candidate_id || S.candidateId || null;
    // Uma ação explícita da consola deve usar a sessão da coordenação, mesmo
    // quando o browser também conserva uma sessão de candidato de demonstração.
    const jwt = n.candidateId && coordJwt ? coordJwt : (candidateJwt || coordJwt);
    if (jwt) {
      fetch('/api/notifications', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${jwt}` },
        body: JSON.stringify({ type: n.type, text: n.text, candidate_id: candidateId }),
      }).catch(() => {});
    }
  };
  // Marca todo o feed como lido (estado partilhado pela equipa). Atualiza o
  // estado local de imediato para o badge do sino limpar sem esperar o polling.
  const markNotifsRead = () => {
    if (!coordJwt) return;
    const ts = new Date().toISOString();
    setRealNotifs((prev) => (prev || []).map((n) => (n.read_at ? n : { ...n, read_at: ts })));
    fetch('/api/notifications/read', {
      method: 'POST',
      headers: { 'Authorization': `Bearer ${coordJwt}` },
    }).catch(() => {});
  };
  const setOnboarding = (o) => setS((p) => ({ ...p, onboarding: { ...p.onboarding, ...o } }));
  const setChat = (c) => setS((p) => ({ ...p, chat: { ...p.chat, ...c } }));
  const up = (patch) => setS((p) => ({ ...p, ...patch }));
  const goTab = (tab) => setS((p) => ({ ...p, tab }));
  const setScheduling = (id, data) => setS((p) => ({ ...p, scheduling: { ...p.scheduling, [id]: { ...(p.scheduling[id] || { slots: [] }), ...data } } }));
  const addTrainer = (t) => {
    if (!coordJwt) return;
    fetch('/api/trainers', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${coordJwt}` },
      body: JSON.stringify(t),
    }).then((r) => r.json()).then((created) => {
      if (created && created.id) setRealTrainers((prev) => [...(prev || []), created]);
    }).catch(() => {});
  };
  const updateTrainer = (id, patch) => {
    if (!coordJwt) return;
    fetch(`/api/trainers/${id}`, {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${coordJwt}` },
      body: JSON.stringify(patch),
    }).then((r) => r.json()).then((updated) => {
      if (updated && updated.id) setRealTrainers((prev) => (prev || []).map((t) => t.id === id ? updated : t));
    }).catch(() => {});
  };
  const removeTrainer = (id) => {
    if (!coordJwt) return;
    fetch(`/api/trainers/${id}`, {
      method: 'DELETE',
      headers: { 'Authorization': `Bearer ${coordJwt}` },
    }).then((r) => { if (r.ok) setRealTrainers((prev) => (prev || []).filter((t) => t.id !== id)); }).catch(() => {});
  };
  const addContactRequest = (r) => {
    setS((p) => ({ ...p, contactRequests: [{ id: 'cr' + Math.random().toString(36).slice(2, 8), ago: 'agora mesmo', status: 'novo', ...r }, ...p.contactRequests] }));
    if (S.candidateId && candidateJwt) {
      fetch('/api/contact-requests', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${candidateJwt}` },
        // module_id na BD é inteiro 1..6; o id local ('m1') tem de ser convertido
        body: JSON.stringify({ candidate_id: S.candidateId, question: r.question, module_id: window.PEDAL.moduleNum(r.moduleId) }),
      }).then((res) => res.json().catch(() => null).then((created) => {
        if (created && created.id) setRealContactRequests((prev) => [created, ...(prev || [])]);
        else console.warn('[PEDAL] dúvida não gravada no servidor:', res.status, created && created.error);
      })).catch(() => {});
    }
  };
  // pedido "real" (gravado no Supabase) vs pedido só local (candidato ainda sem conta, ex.: SEED_CONTACTS de demo)
  const findRealContact = (id) => (realContactRequests || []).find((c) => c.id === id);
  const resolveContact = (id) => {
    const real = findRealContact(id);
    if (real && coordJwt) {
      fetch(`/api/contact-requests/${id}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${coordJwt}` },
        body: JSON.stringify({}),
      }).then((res) => res.json()).then((updated) => {
        if (updated && updated.id) setRealContactRequests((prev) => (prev || []).map((c) => c.id === id ? updated : c));
      }).catch(() => {});
      return;
    }
    setS((p) => ({ ...p, contactRequests: p.contactRequests.map((c) => (c.id === id ? { ...c, status: 'resolvido' } : c)) }));
  };
  const addModuleMessage = (moduleId, message) => setS((p) => ({ ...p, moduleConversations: { ...p.moduleConversations, [moduleId]: [...(p.moduleConversations[moduleId] || []), { ts: Date.now(), ...message }] } }));
  // resposta da coordenação a uma dúvida: marca o pedido como resolvido E publica a resposta
  // — no chat principal se a dúvida veio dali, ou no Q&A do módulo se foi feita durante a formação
  // echoLocal: injeta a resposta no chat/módulo desta sessão de imediato (candidato ao vivo no mesmo browser);
  // usado tanto pela resposta directa da coordenação como pelo polling do candidato quando a resposta vem de outro dispositivo
  const moduleTitleOf = (moduleId) => moduleId ? ((window.PEDAL.MODULES || []).find((m) => m.id === moduleId) || {}).title : null;
  // echoId: id real do pedido no Supabase — usado para nunca mostrar a mesma resposta duas vezes
  // (nem quando a coordenação responde na mesma sessão, nem quando o polling do candidato a detecta depois)
  const echoContactAnswer = (echoId, req, text, authorName) => setS((p) => {
    if (echoId && (p.answeredContactIds || []).includes(echoId)) return p;
    const msg = { id: 'm' + Math.random().toString(36).slice(2, 9), from: 'agent', coord: true, coordAuthor: authorName, text, originalQuestion: req && req.question };
    let messages = p.messages;
    let moduleConversations = p.moduleConversations;
    if (req && req.moduleId) {
      moduleConversations = { ...moduleConversations, [req.moduleId]: [...(moduleConversations[req.moduleId] || []), { from: 'agent', coord: true, coordAuthor: authorName, text, ts: Date.now() }] };
      messages = [...messages, { id: 'm' + Math.random().toString(36).slice(2, 9), from: 'system', text: `🎓 A coordenação respondeu à tua dúvida no módulo «${req.moduleTitle || 'formação'}» — abre o módulo para a veres.` }];
    } else {
      messages = [...messages, msg];
    }
    return {
      ...p, messages, moduleConversations,
      answeredContactIds: echoId ? [...(p.answeredContactIds || []), echoId] : p.answeredContactIds,
    };
  });
  const answerContactRequest = (id, answer, author) => {
    const text = (answer || '').trim(); if (!text) return;
    const authorName = author || (S.coordProfile && S.coordProfile.name) || 'Coordenação';
    const real = findRealContact(id);
    if (real && coordJwt) {
      fetch(`/api/contact-requests/${id}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${coordJwt}` },
        body: JSON.stringify({ answer: text }),
      }).then((res) => res.json()).then((updated) => {
        if (!updated || !updated.id) return;
        setRealContactRequests((prev) => (prev || []).map((c) => c.id === id ? updated : c));
        // candidato ao vivo no mesmo browser: mostra já, sem esperar pelo polling dele.
        // Usa o texto e o autor confirmados pelo servidor — se divergirem do que está
        // em contact_requests, o sanitizer de chat_messages retira a marca de coordenação.
        if (real.candidate_id === S.candidateId) {
          const moduleKey = window.PEDAL.moduleKey(real.module_id);
          echoContactAnswer(id, { question: real.question, moduleId: moduleKey, moduleTitle: moduleTitleOf(moduleKey) }, updated.answer || text, updated.answered_by || 'Coordenação');
        }
      }).catch(() => {});
      return;
    }
    setS((p) => ({ ...p, contactRequests: p.contactRequests.map((c) => (c.id === id ? { ...c, status: 'resolvido', answer: text, answeredAt: Date.now(), answeredBy: authorName } : c)) }));
    const localReq = (S.contactRequests || []).find((c) => c.id === id);
    if (localReq && localReq.live) echoContactAnswer(null, localReq, text, authorName);
  };
  // Candidato: deteta respostas dadas pela coordenação noutro dispositivo (via polling de realContactRequests)
  useEffectA(() => {
    if (!S.candidateId || !realContactRequests) return;
    realContactRequests.forEach((r) => {
      if (r.status !== 'answered') return;
      const moduleKey = window.PEDAL.moduleKey(r.module_id);
      echoContactAnswer(r.id, { question: r.question, moduleId: moduleKey, moduleTitle: moduleTitleOf(moduleKey) }, r.answer, r.answered_by || 'Coordenação');
    });
  }, [realContactRequests, S.candidateId]);
  // — Fase 3: conta, sessão, perfil, formalização e conteúdos —
  const signupInFlight = useRefA(false);
  const createAccount = async (candidate, turnstileHost) => {
    if (signupInFlight.current) return { ok: false, error: 'A inscrição já está a ser processada.' };
    signupInFlight.current = true;
    try {
      const configResponse = await fetch('/api/candidates/signup-config');
      const config = await configResponse.json().catch(() => ({}));
      if (!configResponse.ok || !config.registrationAvailable || !config.turnstileSiteKey) {
        return { ok: false, error: 'As inscrições estão temporariamente indisponíveis. Tenta novamente mais tarde.' };
      }
      const turnstileToken = await requestTurnstileToken(config.turnstileSiteKey, turnstileHost);
      const response = await fetch('/api/candidates', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          name: candidate.name,
          email: candidate.email,
          dob: candidate.dob || null,
          phone: candidate.contact || null,
          cc: candidate.cc || null,
          profissao: candidate.profissao || null,
          nif: candidate.nif || null,
          rua: candidate.rua || null,
          porta: candidate.porta || null,
          codigo_postal: candidate.codigo_postal || null,
          cidade: candidate.cidade || null,
          turnstileToken,
        }),
      });
      const data = await response.json().catch(() => ({}));
      if (!response.ok) {
        return { ok: false, error: data.error || 'Não foi possível concluir a inscrição.' };
      }
      setS((p) => ({
        ...p,
        candidate: { ...p.candidate, ...candidate },
        account: { email: candidate.email, createdAt: Date.now() },
        candidateId: null,
        emailVerificationRequired: true,
      }));
      return { ok: true };
    } catch (error) {
      return { ok: false, error: error.message || 'Não foi possível concluir a inscrição.' };
    } finally {
      signupInFlight.current = false;
    }
  };
  const setSession = (authed) => setS((p) => ({ ...p, session: { ...p.session, authed } }));
  const setModuleContent = (id, patch) => setS((p) => ({ ...p, moduleContent: { ...p.moduleContent, [id]: { ...(p.moduleContent[id] || {}), ...patch } } }));
  const setCoordJwt = (jwt, refreshToken) => {
    setCoordJwtRaw(jwt);
    // O Supabase roda o refresh token a cada uso; guardar sempre o mais recente.
    if (refreshToken) { try { localStorage.setItem(COORD_REFRESH_KEY, refreshToken); sessionStorage.removeItem(COORD_REFRESH_KEY); } catch (_) {} }
  };
  const setCoordRole = (role) => setCoordRoleRaw(role);
  const clearCoordJwt = () => { try { localStorage.removeItem(COORD_REFRESH_KEY); sessionStorage.removeItem(COORD_REFRESH_KEY); } catch (_) {} setCoordJwtRaw(null); setCoordRoleRaw(null); setRealCandidates(null); setRealTrainers(null); setRealStations(null); setCoordProfileRaw(null); };
  // Troca o refresh token guardado por um access token novo (e aplica perfil e
  // role ao estado). Devolve o novo access token, ou null se a renovação não
  // for possível. Só remove o token guardado quando o servidor o rejeita de
  // facto (revogado/apagado) — uma falha de rede não termina a sessão.
  const coordRefreshInFlight = useRefA(null);
  const refreshCoordSession = () => {
    if (coordRefreshInFlight.current) return coordRefreshInFlight.current;
    const refreshToken = readCoordRefreshToken();
    const authConfig = window.__PEDAL_AUTH_CONFIG || {};
    if (!refreshToken || !authConfig.supabaseUrl || !authConfig.supabaseAnonKey) return Promise.resolve(null);
    coordRefreshInFlight.current = fetch(`${authConfig.supabaseUrl}/auth/v1/token?grant_type=refresh_token`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'apikey': authConfig.supabaseAnonKey },
      body: JSON.stringify({ refresh_token: refreshToken }),
    })
      .then((r) => r.json().catch(() => ({})).then((d) => {
        if (d.access_token && d.user?.app_metadata?.role === 'coordinator') {
          window.applyCoordinatorSession({ setCoordProfile, setCoordRole, setCoordJwt }, d, d.user);
          return d.access_token;
        }
        if (r.status >= 400 && r.status < 500) { try { localStorage.removeItem(COORD_REFRESH_KEY); sessionStorage.removeItem(COORD_REFRESH_KEY); } catch (_) {} }
        return null;
      }))
      .catch(() => null)
      .then((token) => { coordRefreshInFlight.current = null; return token; });
    return coordRefreshInFlight.current;
  };
  const patchRealCandidate = (id, patch) => setRealCandidates((prev) => prev ? prev.map((c) => c.id === id ? { ...c, ...patch } : c) : prev);
  // Muda o estado de um candidato real no backend; só actualiza a lista local depois de confirmado.
  // Se a sessão tiver expirado (401), força novo login em vez de falhar em silêncio.
  const patchCandidateStage = (id, stage) => {
    if (!coordJwt) return Promise.resolve({ ok: false, error: 'Sem sessão activa' });
    return fetch(`/api/candidates/${id}`, {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${coordJwt}` },
      body: JSON.stringify({ stage }),
    }).then((r) => {
      // 401: tenta renovar a sessão em silêncio antes de forçar novo login —
      // acontece p.ex. num separador acabado de acordar de suspensão.
      if (r.status === 401) {
        return refreshCoordSession().then((token) => {
          if (token) return { ok: false, error: 'A sessão foi renovada — tenta outra vez.' };
          clearCoordJwt();
          return { ok: false, error: 'A tua sessão expirou. Inicia sessão novamente.' };
        });
      }
      if (!r.ok) return r.json().then((d) => ({ ok: false, error: (d && d.error) || 'Erro ao guardar' })).catch(() => ({ ok: false, error: 'Erro ao guardar' }));
      patchRealCandidate(id, { stage });
      return { ok: true };
    }).catch(() => ({ ok: false, error: 'Erro de rede' }));
  };

  // Localidades e necessidades: endpoints públicos, carregados na montagem
  useEffectA(() => {
    fetch('/api/localities')
      .then((r) => r.json())
      .then((data) => { if (Array.isArray(data)) setRealLocalities(data); })
      .catch(() => {});
  }, []);

  useEffectA(() => {
    fetch('/api/needs')
      .then((r) => r.json())
      .then((data) => { if (data && typeof data === 'object' && !Array.isArray(data) && !data.error) setRealNeeds(data); })
      .catch(() => {});
  }, []);

  useEffectA(() => {
    fetch('/api/settings/intro_video_url')
      .then((r) => r.json())
      .then((data) => { if (data && data.url) setIntroVideoUrl(data.url); })
      .catch(() => {});
  }, []);

  useEffectA(() => {
    (window.PEDAL.CONSENT_DOCUMENTS || []).forEach((doc) => {
      fetch(`/api/settings/${doc.settingsKey}`)
        .then((r) => r.json())
        .then((data) => { if (data && data.url) setDocumentUrls((p) => ({ ...p, [doc.settingsKey]: data })); })
        .catch(() => {});
    });
  }, []);

  // Base de conhecimento por módulo (informação escrita + documentos) — usada pela IA.
  useEffectA(() => {
    fetch('/api/settings/module_agentinfo')
      .then((r) => r.json())
      .then((data) => { if (data && typeof data === 'object') setModuleAgentInfo(data); })
      .catch(() => {});
    fetch('/api/settings/module_documents')
      .then((r) => r.json())
      .then((data) => { if (data && typeof data === 'object') setModuleDocuments(data); })
      .catch(() => {});
    fetch('/api/settings/general_knowledge')
      .then((r) => r.json())
      .then((data) => { if (data && data.text) setGeneralKnowledge(data.text); })
      .catch(() => {});
  }, []);

  useEffectA(() => {
    fetch('/api/ai/config')
      .then((r) => r.json())
      .then((data) => { if (data && data.aiEnabled) setAiEnabled(true); })
      .catch(() => {});
  }, []);
  // Pergunta livre à IA quando a correspondência por palavra-chave (FAQ) falha.
  // "context" é uma lista de textos (FAQ geral, ou info+documentos de um módulo)
  // que o chamador decide — este helper só faz o pedido e devolve {confident, answer?}.
  const askAI = (question, context) => {
    if (!aiEnabled) return Promise.resolve({ confident: false });
    return fetch('/api/ai/ask', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ question, context }),
    }).then((r) => r.json()).catch(() => ({ confident: false }));
  };

  // Refetch needs quando o candidato chega ao formulário de triagem — garante dados frescos
  useEffectA(() => {
    if (S.chat && S.chat.node === 'triage') {
      fetch('/api/needs')
        .then((r) => r.json())
        .then((data) => { if (data && typeof data === 'object' && !Array.isArray(data) && !data.error) setRealNeeds(data); })
        .catch(() => {});
    }
  }, [S.chat && S.chat.node]);

  useEffectA(() => {
    if (!coordJwt) { setRealTrainers(null); return; }
    fetch('/api/trainers', { headers: { 'Authorization': `Bearer ${coordJwt}` } })
      .then((r) => r.json())
      .then((data) => { if (Array.isArray(data)) setRealTrainers(data); })
      .catch(() => {});
  }, [coordJwt]);

  useEffectA(() => {
    if (!coordJwt) { setRealStations(null); return; }
    fetch('/api/stations', { headers: { 'Authorization': `Bearer ${coordJwt}` } })
      .then((r) => r.json())
      .then((data) => { if (Array.isArray(data)) setRealStations(data); })
      .catch(() => {});
  }, [coordJwt]);

  const mapCandidate = (c) => {
    const parts = (c.name || '').split(' ');
    const initials = [parts[0], parts[parts.length - 1]].filter(Boolean).map((p) => p[0].toUpperCase()).join('');
    const days = c.created_at ? Math.floor((Date.now() - new Date(c.created_at)) / 86400000) : 0;
    const perData = window.PEDAL && window.PEDAL.PERIODS;
    // A coluna periods é jsonb: ora chega como array, ora como string legada "Manhã, Tarde".
    const rawPeriods = Array.isArray(c.periods) ? c.periods : (typeof c.periods === 'string' && c.periods ? c.periods.split(', ').filter(Boolean) : []);
    const periods = rawPeriods.map((p) => { const f = perData && perData.find((x) => x.name === p); return f ? f.id : p; });
    return { id: c.id, name: c.name, email: c.email, contact: c.phone || '', dob: c.dob || '', cc: c.cc || '', profissao: c.profissao || '', nif: c.nif || '', stage: c.stage || 'inscricao', locality: c.locality || '—', localityId: null, initials, days, source: 'PEDAL', periods, availability: Array.isArray(c.availability) ? c.availability : [], weekdays: [...new Set((Array.isArray(c.availability) ? c.availability : []).map((a) => a.day))], contactDate: c.created_at ? c.created_at.slice(0, 10) : '', scheduling: c.scheduling || null, interview: c.interview || null, chat_messages: Array.isArray(c.chat_messages) ? c.chat_messages : null, rua: c.rua || '', porta: c.porta || '', codigo_postal: c.codigo_postal || '', cidade: c.cidade || '', signature: c.signature || null };
  };
  const refreshCandidates = () => {
    if (!coordJwt) return;
    fetch('/api/candidates', { headers: { 'Authorization': `Bearer ${coordJwt}` } })
      .then((r) => r.json())
      .then((data) => { if (Array.isArray(data)) setRealCandidates(data.map(mapCandidate)); })
      .catch(() => {});
  };
  useEffectA(() => {
    if (!coordJwt) { setRealCandidates(null); return; }
    refreshCandidates();
    const pollTimer = setInterval(refreshCandidates, 5000);
    return () => clearInterval(pollTimer);
  }, [coordJwt]);

  // Feed de notificações da coordenação — lido da BD, visível em qualquer dispositivo
  useEffectA(() => {
    if (!coordJwt) { setRealNotifs(null); return; }
    const loadNotifs = () => {
      fetch('/api/notifications', { headers: { 'Authorization': `Bearer ${coordJwt}` } })
        .then((r) => r.json())
        .then((data) => { if (Array.isArray(data)) setRealNotifs(data); })
        .catch(() => {});
    };
    loadNotifs();
    const t = setInterval(loadNotifs, 10000);
    return () => clearInterval(t);
  }, [coordJwt]);

  // Pedidos de contacto reais — coordenação vê todos, candidato só os seus
  useEffectA(() => {
    const jwt = coordJwt || candidateJwt;
    if (!jwt) { setRealContactRequests(null); return; }
    const loadContactRequests = () => {
      fetch('/api/contact-requests', { headers: { 'Authorization': `Bearer ${jwt}` } })
        .then((r) => r.json())
        .then((data) => { if (Array.isArray(data)) setRealContactRequests(data); })
        .catch(() => {});
    };
    loadContactRequests();
    const t = setInterval(loadContactRequests, 10000);
    return () => clearInterval(t);
  }, [coordJwt, candidateJwt]);

  // Sincroniza stage com o backend sempre que muda. Uma recusa aqui significa
  // que o estado local e o do servidor divergiram (ex.: transição não permitida)
  // — tem de ficar visível na consola, senão o candidato avança só localmente.
  const warnSync = (what) => (r) => {
    if (!r.ok) r.json().catch(() => ({})).then((d) => console.warn(`[PEDAL] sync de ${what} recusado (${r.status}):`, (d && d.error) || ''));
  };
  useEffectA(() => {
    if (!S.stage || !S.candidateId || !candidateJwt) return;
    const base = `/api/candidates/${S.candidateId}`;
    const hdrs = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${candidateJwt}` };
    fetch(base, { method: 'PATCH', headers: hdrs, body: JSON.stringify({ stage: S.stage }) }).then(warnSync(`stage → ${S.stage}`)).catch(() => {});
    if (S.candidate.periods && S.candidate.periods.length) {
      const perData = window.PEDAL && window.PEDAL.PERIODS;
      const periodsText = S.candidate.periods.map((id) => perData ? ((perData.find((p) => p.id === id) || {}).name || id) : id).join(', ');
      fetch(base, { method: 'PATCH', headers: hdrs, body: JSON.stringify({ periods: periodsText }) }).then(warnSync('periods')).catch(() => {});
    }
    if (S.candidate.localities && S.candidate.localities.length) {
      const locs = realLocalities || (window.PEDAL && window.PEDAL.LOCALITIES);
      const names = S.candidate.localities.map((id) => locs ? ((locs.find((l) => l.id === id) || {}).name || id) : id).join(', ');
      fetch(base, { method: 'PATCH', headers: hdrs, body: JSON.stringify({ locality: names }) }).then(warnSync('locality')).catch(() => {});
    }
    if (S.candidate.availability && S.candidate.availability.length) {
      fetch(base, { method: 'PATCH', headers: hdrs, body: JSON.stringify({ availability: S.candidate.availability }) }).then(warnSync('availability')).catch(() => {});
    }
  }, [S.stage, candidateJwt]);

  // — Fase 4: locais de encontro (API), utilizadores de gestão e perfil da coordenação —
  const addStation = (st) => {
    if (!coordJwt) return;
    fetch('/api/stations', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${coordJwt}` },
      body: JSON.stringify(st),
    }).then((r) => r.json()).then((created) => {
      if (created && created.id) setRealStations((prev) => [...(prev || []), created]);
    }).catch(() => {});
  };
  const updateStation = (id, patch) => {
    if (!coordJwt) return;
    fetch(`/api/stations/${id}`, {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${coordJwt}` },
      body: JSON.stringify(patch),
    }).then((r) => r.json()).then((updated) => {
      if (updated && updated.id) setRealStations((prev) => (prev || []).map((s) => s.id === id ? updated : s));
    }).catch(() => {});
  };
  const removeStation = (id) => {
    if (!coordJwt) return;
    fetch(`/api/stations/${id}`, {
      method: 'DELETE',
      headers: { 'Authorization': `Bearer ${coordJwt}` },
    }).then((r) => { if (r.ok) setRealStations((prev) => (prev || []).filter((s) => s.id !== id)); }).catch(() => {});
  };
  const addMgmtUser = (u) => setS((p) => ({ ...p, mgmtUsers: [...(p.mgmtUsers || []), { id: 'u' + Math.random().toString(36).slice(2, 8), createdAt: new Date().toISOString().slice(0, 10), ...u }] }));
  const removeMgmtUser = (id) => setS((p) => ({ ...p, mgmtUsers: (p.mgmtUsers || []).filter((u) => u.id !== id) }));
  const updateMgmtUser = (id, patch) => setS((p) => ({ ...p, mgmtUsers: (p.mgmtUsers || []).map((u) => u.id === id ? { ...u, ...patch } : u) }));
  const setCoordProfile = (patch) => setCoordProfileRaw((p) => ({ ...(p || {}), ...patch }));
  const saveIntroVideo = (url) => {
    if (!coordJwt) return Promise.resolve({ ok: false, error: 'Sem sessão activa' });
    return fetch('/api/settings/intro_video_url', {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${coordJwt}` },
      body: JSON.stringify({ url }),
    }).then((r) => r.json().then((data) => {
      if (r.ok && data && data.url !== undefined) {
        setIntroVideoUrl(data.url || null);
        return { ok: true };
      }
      return { ok: false, error: (data && data.error) || 'Erro ao guardar' };
    })).catch(() => ({ ok: false, error: 'Erro de rede' }));
  };
  // Grava { url, name } de um documento (RGPD, termos, etc.) em org_settings, sob a
  // sua settingsKey própria (ver PEDAL.CONSENT_DOCUMENTS).
  const saveDocumentUrl = (settingsKey, url, name) => {
    if (!coordJwt) return Promise.resolve({ ok: false, error: 'Sem sessão activa' });
    return fetch(`/api/settings/${settingsKey}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${coordJwt}` },
      body: JSON.stringify({ url, name }),
    }).then((r) => r.json().then((data) => {
      if (r.ok) { setDocumentUrls((p) => ({ ...p, [settingsKey]: (data && data.url) ? data : null })); return { ok: true }; }
      return { ok: false, error: (data && data.error) || 'Erro ao guardar' };
    })).catch(() => ({ ok: false, error: 'Erro de rede' }));
  };
  // Envia um ficheiro para o Supabase Storage (bucket "documents"). "key" agrupa o
  // ficheiro numa subpasta (ex.: "rgpd", "termo_acordo").
  const uploadDocument = (file, key) => {
    if (!coordJwt) return Promise.resolve({ ok: false, error: 'Sem sessão activa' });
    const form = new FormData();
    form.append('file', file);
    form.append('key', key);
    return fetch('/api/documents', {
      method: 'POST',
      headers: { 'Authorization': `Bearer ${coordJwt}` },
      body: form,
    }).then((r) => r.json().then((data) => {
      if (r.ok && data && data.url) return { ok: true, url: data.url, name: data.name, text: data.text };
      return { ok: false, error: (data && data.error) || 'Erro ao enviar o ficheiro' };
    })).catch(() => ({ ok: false, error: 'Erro de rede' }));
  };
  // Envia + grava num só passo, para um documento de PEDAL.CONSENT_DOCUMENTS.
  const uploadAndSaveDocument = (file, doc) => uploadDocument(file, doc.uploadKey).then((res) => {
    if (!res.ok) return res;
    return saveDocumentUrl(doc.settingsKey, res.url, res.name);
  });

  // Base de conhecimento GERAL (não específica de um módulo) — alimenta o fallback de
  // IA do chat principal, ao lado da FAQ fixa do código.
  const saveGeneralKnowledge = (text) => {
    if (!coordJwt) return Promise.resolve({ ok: false, error: 'Sem sessão activa' });
    return fetch('/api/settings/general_knowledge', {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${coordJwt}` },
      body: JSON.stringify({ text }),
    }).then((r) => r.json().then((data) => {
      if (r.ok) { setGeneralKnowledge((data && data.text) || ''); return { ok: true }; }
      return { ok: false, error: (data && data.error) || 'Erro ao guardar' };
    })).catch(() => ({ ok: false, error: 'Erro de rede' }));
  };

  // Base de conhecimento por módulo — "Informação para o agente" e documentos,
  // gravados no backend (antes só ficavam em S.moduleContent, no localStorage) para
  // a IA os poder usar como contexto.
  const saveModuleAgentInfo = (moduleId, text) => {
    if (!coordJwt) return Promise.resolve({ ok: false, error: 'Sem sessão activa' });
    const next = { ...moduleAgentInfo, [moduleId]: text };
    return fetch('/api/settings/module_agentinfo', {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${coordJwt}` },
      body: JSON.stringify(next),
    }).then((r) => r.json().then((data) => {
      if (r.ok) { setModuleAgentInfo(data || {}); return { ok: true }; }
      return { ok: false, error: (data && data.error) || 'Erro ao guardar' };
    })).catch(() => ({ ok: false, error: 'Erro de rede' }));
  };
  const uploadModuleDocument = (moduleId, file) => {
    if (!coordJwt) return Promise.resolve({ ok: false, error: 'Sem sessão activa' });
    return uploadDocument(file, `module_${moduleId}`).then((res) => {
      if (!res.ok) return res;
      const list = [...(moduleDocuments[moduleId] || []), { url: res.url, name: res.name, text: res.text || '' }];
      const next = { ...moduleDocuments, [moduleId]: list };
      return fetch('/api/settings/module_documents', {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${coordJwt}` },
        body: JSON.stringify(next),
      }).then((r) => r.json().then((data) => {
        if (r.ok) { setModuleDocuments(data || {}); return { ok: true }; }
        return { ok: false, error: (data && data.error) || 'Erro ao guardar' };
      })).catch(() => ({ ok: false, error: 'Erro de rede' }));
    });
  };
  const removeModuleDocument = (moduleId, index) => {
    if (!coordJwt) return Promise.resolve({ ok: false, error: 'Sem sessão activa' });
    const list = (moduleDocuments[moduleId] || []).filter((_, i) => i !== index);
    const next = { ...moduleDocuments, [moduleId]: list };
    return fetch('/api/settings/module_documents', {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${coordJwt}` },
      body: JSON.stringify(next),
    }).then((r) => r.json().then((data) => {
      if (r.ok) { setModuleDocuments(data || {}); return { ok: true }; }
      return { ok: false, error: (data && data.error) || 'Erro ao guardar' };
    })).catch(() => ({ ok: false, error: 'Erro de rede' }));
  };

  const saveNeedsSchedule = (schedule) => {
    if (!coordJwt) return Promise.resolve({ ok: false, error: 'Sem sessão activa' });
    return fetch('/api/needs', {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${coordJwt}` },
      body: JSON.stringify(schedule),
    }).then((r) => r.json().then((data) => {
      if (r.ok && data && typeof data === 'object' && !Array.isArray(data) && !data.error) {
        setRealNeeds(data);
        return { ok: true };
      }
      return { ok: false, error: (data && data.error) || 'Erro ao guardar' };
    })).catch(() => ({ ok: false, error: 'Erro de rede' }));
  };
  const addLocality = (name) => {
    if (!coordJwt) return Promise.resolve({ ok: false, error: 'Sem sessão' });
    return fetch('/api/localities', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${coordJwt}` },
      body: JSON.stringify({ name }),
    }).then((r) => r.json().then((d) => {
      if (r.ok) { setRealLocalities((prev) => [...(prev || []), d]); return { ok: true }; }
      return { ok: false, error: d.error || 'Erro ao criar' };
    })).catch(() => ({ ok: false, error: 'Erro de rede' }));
  };
  const removeLocality = (id) => {
    if (!coordJwt) return Promise.resolve({ ok: false, error: 'Sem sessão' });
    return fetch(`/api/localities/${id}`, {
      method: 'DELETE',
      headers: { 'Authorization': `Bearer ${coordJwt}` },
    }).then((r) => {
      if (r.ok) { setRealLocalities((prev) => (prev || []).filter((l) => l.id !== id)); return { ok: true }; }
      return r.json().then((d) => ({ ok: false, error: d.error || 'Erro ao eliminar' }));
    }).catch(() => ({ ok: false, error: 'Erro de rede' }));
  };
  const renameLocality = (id, oldName, newName) => {
    if (!coordJwt) return Promise.resolve({ ok: false, error: 'Sem sessão' });
    return fetch(`/api/localities/${id}`, {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${coordJwt}` },
      body: JSON.stringify({ name: newName }),
    }).then((r) => r.json().then((d) => {
      if (r.ok) {
        setRealLocalities((prev) => (prev || []).map((l) => l.id === id ? { ...l, name: newName } : l));
        setRealCandidates((prev) => prev ? prev.map((c) => ({
          ...c,
          locality: c.locality === oldName ? newName : c.locality,
          localities: c.localities ? c.localities.map((loc) => loc === oldName ? newName : loc) : c.localities,
        })) : prev);
        setRealTrainers((prev) => prev ? prev.map((t) => t.locality === oldName ? { ...t, locality: newName } : t) : prev);
        setRealStations((prev) => prev ? prev.map((s) => s.locality === oldName ? { ...s, locality: newName } : s) : prev);
        return { ok: true };
      }
      return { ok: false, error: d.error || 'Erro ao renomear' };
    })).catch(() => ({ ok: false, error: 'Erro de rede' }));
  };
  const reorderLocalities = (orderedSlugs) => {
    if (!coordJwt) return Promise.resolve({ ok: false, error: 'Sem sessão' });
    setRealLocalities((prev) => {
      if (!prev) return prev;
      const map = Object.fromEntries(prev.map((l) => [l.id, l]));
      return orderedSlugs.map((s) => map[s]).filter(Boolean);
    });
    return fetch('/api/localities/reorder', {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${coordJwt}` },
      body: JSON.stringify({ order: orderedSlugs }),
    }).then((r) => r.json().then((d) => {
      if (r.ok) return { ok: true };
      return { ok: false, error: d.error || 'Erro ao reordenar' };
    })).catch(() => ({ ok: false, error: 'Erro de rede' }));
  };

  const reset = () => {
    // Preservar configuração da coordenação — o reset só reinicia o fluxo do candidato
    const coordData = {
      moduleContent: S.moduleContent || {},
      mgmtUsers: S.mgmtUsers && S.mgmtUsers.length ? S.mgmtUsers : INITIAL.mgmtUsers.map((u) => ({ ...u })),
      coordProfile: { ...INITIAL.coordProfile, ...(S.coordProfile || {}) },
    };
    localStorage.removeItem(STORE_KEY);
    setS({ ...INITIAL, candidate: { ...INITIAL.candidate, localities: [] }, messages: [], onboarding: { done: {}, roleAccepted: false }, chat: { node: 'welcome', interviewStep: 0 }, scheduling: {}, moduleConversations: {}, contactRequests: INITIAL.contactRequests.map((c) => ({ ...c })), account: null, session: { authed: false }, signature: null, termsAccepted: false, ...coordData });
    setResetKey((k) => k + 1);
  };

  // Apagar conta é sempre servidor-primeiro: o reset local sozinho deixava a
  // conta Auth e a ficha na BD intactas, e o candidato voltava a conseguir entrar.
  const deleteAccount = async () => {
    if (!candidateJwt) {
      // Sem conta criada não há nada no servidor — basta o reset local.
      if (!S.account) { reset(); return { ok: true }; }
      return { ok: false, error: 'A tua sessão expirou. Entra novamente para apagar a conta.' };
    }
    try {
      const res = await fetch('/api/candidates/me', {
        method: 'DELETE',
        headers: { 'Authorization': `Bearer ${candidateJwt}` },
      });
      if (!res.ok) {
        const d = await res.json().catch(() => ({}));
        return { ok: false, error: d.error || 'Não foi possível apagar a conta. Tenta novamente.' };
      }
    } catch (_) {
      return { ok: false, error: 'Erro de ligação ao servidor.' };
    }
    setCandidateJwtRaw(null);
    reset();
    return { ok: true };
  };

  const store = { S, addMessage, patchCandidate, setStage, notify, setOnboarding, setChat, up, goTab, reset, deleteAccount, setScheduling, addTrainer, updateTrainer, removeTrainer, addContactRequest, resolveContact, answerContactRequest, addModuleMessage, createAccount, setSession, setModuleContent, addStation, updateStation, removeStation, addMgmtUser, removeMgmtUser, updateMgmtUser, setCoordProfile, saveNeedsSchedule, saveIntroVideo, saveDocumentUrl, uploadDocument, uploadAndSaveDocument, documentUrls, saveModuleAgentInfo, uploadModuleDocument, removeModuleDocument, moduleAgentInfo, moduleDocuments, generalKnowledge, saveGeneralKnowledge, aiEnabled, askAI, addLocality, removeLocality, renameLocality, reorderLocalities, coordJwt, setCoordJwt, clearCoordJwt, coordRole, setCoordRole, coordProfile, setCoordProfile, patchRealCandidate, patchCandidateStage, refreshCandidates, realCandidates, passwordJustChanged, accountJustActivated, realTrainers, realNeeds, realStations, realLocalities, realNotifs, markNotifsRead, realContactRequests, introVideoUrl, candidateJwt, setCandidateJwt: setCandidateJwtRaw, setView, chatLoaded };

  const tone = (t.tone || 'Caloroso').toLowerCase();
  const fs = { Normal: 1, Grande: 1.13, Maior: 1.26 }[t.textSize] || 1;
  const pal = t.palette || TWEAK_DEFAULTS.palette;
  const themeVars = { '--primary': pal[0], '--primary-soft': pal[1], '--primary-deep': pal[2], '--fs': fs };

  const unlocked = S.validated && S.onboarding.roleAccepted;
  const obCount = window.PEDAL.MODULES.filter((m) => S.onboarding.done[m.id]).length;
  const authed = S.session && S.session.authed;
  const hasAccount = !!S.account;
  const formalizePending = S.stage === 'formalizacao';
  const tabs = [
    { id: 'conversa', label: 'Conversa', icon: 'chat' },
    { id: 'formacao', label: 'Formação', icon: 'mortarboard' },
    { id: 'processo', label: 'Processo', icon: 'route' },
  ];
  tabs.push({ id: 'perfil', label: authed ? 'Perfil' : 'Entrar', icon: 'user' });

  return (
    <div className="pedal-stage" style={themeVars}>
      <div className="pedal-topbar">
        <div className="pedal-brandmini"><img src={window.__PEDAL_LOGO} alt="Pedalar Sem Idade Porto" className="pedal-logo" /><span className="pedal-brandsep">·</span><PedalMark size={20} color="var(--primary)" /><span style={{ display: 'flex', flexDirection: 'column', lineHeight: 1.05 }}>PEDAL<em style={{ font: '400 9.5px var(--ui)', fontStyle: 'italic', color: 'var(--ink-soft)', letterSpacing: 0, fontWeight: 400 }}>Direito a vento no cabelo</em></span></div>
      </div>

      {view === 'candidate' ? (
        <div className="pedal-app" style={themeVars}>
          <div className="pedal-viewport">
            <div style={{ display: S.tab === 'conversa' ? 'flex' : 'none', flexDirection: 'column', height: '100%' }}>
              <ChatView key={resetKey} store={store} tone={tone} />
            </div>
            {S.tab === 'formacao' && <FormacaoView store={store} />}
            {S.tab === 'processo' && <ProcessoView store={store} />}
            {S.tab === 'perfil' && <ProfileView store={store} />}
          </div>
          <div className="pedal-tabbar">
            {tabs.map((tb) => (
              <button key={tb.id} className={'pedal-tab' + (S.tab === tb.id ? ' on' : '')} onClick={() => goTab(tb.id)}>
                <span style={{ position: 'relative' }}>
                  <Icon name={tb.icon} size={22} />
                  {tb.id === 'formacao' && unlocked && obCount < window.PEDAL.MODULES.length && <span className="pedal-tabbadge" />}
                  {tb.id === 'conversa' && formalizePending && <span className="pedal-tabbadge" />}
                </span>
                <span>{tb.label}</span>
              </button>
            ))}
          </div>
        </div>
      ) : coordJwt ? (
        <div className="pedal-dashwrap"><Dashboard store={store} /></div>
      ) : coordRestoring ? (
        <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg)', font: '600 14px var(--ui)', color: 'var(--ink-soft)' }}>A repor sessão…</div>
      ) : (
        <CoordLoginScreen store={store} />
      )}

      {view === 'candidate' && <TweaksPanel>
        <TweakSection label="Tom de voz do PEDAL" />
        <TweakRadio label="Tom" value={t.tone} options={['Caloroso', 'Profissional', 'Direto']} onChange={(v) => setTweak('tone', v)} />
        <TweakSection label="Acessibilidade" />
        <TweakRadio label="Tamanho do texto" value={t.textSize} options={['Normal', 'Grande', 'Maior']} onChange={(v) => setTweak('textSize', v)} />
        <TweakSection label="Cor da marca" />
        <TweakColor label="Paleta" value={t.palette} options={[
          ['#ED1C24', '#FDE7E8', '#C4151C'],
          ['#1F7E6D', '#E7F4F1', '#155E51'],
          ['#161616', '#ECECEC', '#000000'],
          ['#3A6EA5', '#E2ECF6', '#264C75'],
        ]} onChange={(v) => setTweak('palette', v)} />
      </TweaksPanel>}
    </div>
  );
}

ReactDOM.createRoot(document.getElementById('root')).render(<App />);
