<!DOCTYPE html>
<html lang="it">
<head>
<!--
╔══════════════════════════════════════════════════════════════════════╗
║  NeosCat AI — Assistente Informativo per Proprietari di Gatti         ║
║  v1.0.66 — Agosto 2026                                                ║
║  © Neos Pet AI — Tutti i diritti riservati                           ║
║                                                                      ║
║  Architettura: PWA single-file (CSS+HTML+JS inline)                  ║
║  Compliance: Art. 348 C.P. + Legge IA 132/2025 + GDPR + ePrivacy     ║
║  Stack: Anthropic Claude (via Worker proxy), localStorage            ║
║  Avatar: SVG animato "Félicette" (no 3D, no dipendenze esterne)          ║
╚══════════════════════════════════════════════════════════════════════╝
-->
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=3.0, user-scalable=yes, viewport-fit=cover">
<meta name="description" content="NeosCat AI: assistente informativo per proprietari di gatti. Risposte personalizzate sul profilo del tuo gatto - razza, eta, peso, patologie - promemoria di salute e schede di razza. Informazione veterinaria, non sostituisce il veterinario.">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#0D9488">
<link rel="manifest" href="manifest.json">
<link rel="apple-touch-icon" href="icons/icon-192x192.png">
<title>NeosCat AI - Assistente informativo per il tuo gatto</title>
<link rel="canonical" href="https://gatto.neospetai.org/">
<meta property="og:type" content="website">
<meta property="og:title" content="NeosCat AI - Assistente informativo per il tuo gatto">
<meta property="og:description" content="NeosCat AI: assistente informativo per proprietari di gatti. Risposte personalizzate sul profilo del tuo gatto - razza, eta, peso, patologie - promemoria di salute e schede di razza. Informazione veterinaria, non sostituisce il veterinario.">
<meta property="og:url" content="https://gatto.neospetai.org/">
<meta property="og:image" content="https://gatto.neospetai.org/icons/icon-512x512.png">
<meta property="og:locale" content="it_IT">
<style>
:root{--primary:#0D9488;--primary-dark:#0F766E;--primary-fill:#0F766E;--primary-fill-active:#115E59;--brand-ink:#0F766E;--primary-light:#CCFBF1;--primary-border:#99F6E4;--accent:#115E59;--danger:#C81E1E;--danger-text:#C81E1E;--success-text:#15803D;--warning:#f59e0b;--success:#22c55e;--gray-50:#f8fafc;--gray-100:#f1f5f9;--gray-200:#e2e8f0;--gray-300:#cbd5e1;--gray-400:#556070;--gray-500:#64748b;--gray-600:#475569;--gray-700:#334155;--gray-800:#1e293b;--gray-900:#0f172a;--ai-bg:#F0FDFA;--ai-border:#99F6E4;--user-bg:#0D9488;--user-color:#fff;--shadow:0 .125rem .5rem rgba(0,0,0,.1);--shadow-lg:0 .625rem 1.875rem rgba(0,0,0,.15)}
[data-theme=dark]{--danger-text:#f87171;--success-text:#4ADE80;--brand-ink:#5EEAD4;--gray-50:#1e293b;--gray-100:#334155;--gray-200:#475569;--gray-300:#64748b;--gray-400:#94a3b8;--gray-500:#cbd5e1;--gray-600:#e2e8f0;--gray-700:#f1f5f9;--gray-800:#f8fafc;--gray-900:#fff;--ai-bg:#04342C;--ai-border:#115E59;--shadow:0 .125rem .5rem rgba(0,0,0,.3);--shadow-lg:0 .625rem 1.875rem rgba(0,0,0,.4)}
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
/* §27: l'altezza GARANTITA (svh), non quella dinamica. Con dvh, appena
   le barre si nascondono il contenitore cresce e cio' che sta in fondo
   finisce sotto la barra di sistema - e non torna, perche' la barra non torna. */
html{height:100vh;height:100svh;overscroll-behavior:none}
body{height:100%;overscroll-behavior:none}
body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Segoe UI',system-ui,sans-serif;background:linear-gradient(135deg,var(--primary),var(--primary-dark));color:var(--gray-900);line-height:1.6;display:flex;justify-content:center;align-items:flex-start;min-height:100%;overflow:hidden;padding:10px;padding-top:max(10px,env(safe-area-inset-top));padding-bottom:max(10px,env(safe-area-inset-bottom));padding-left:max(10px,env(safe-area-inset-left));padding-right:max(10px,env(safe-area-inset-right))}
.app{width:100%;max-width:440px;margin-block:auto;background:#fff;border-radius:24px;box-shadow:var(--shadow-lg);overflow:hidden;height:100%;display:flex;flex-direction:column;position:relative}
[data-theme=dark] .app{background:#0f172a}

/* ═══ ONBOARDING (sostituisce login) ═══ */
.onboarding-screen{padding:20px 24px 16px}
.onboarding-header{text-align:center;margin-bottom:18px;flex-shrink:0}
.onboarding-logo{font-size:2.6rem;margin-bottom:6px}
/* Il grilletto e' il logo, non la riga intera: da blocco a 322px il bersaglio
   sarebbe stato tutta la larghezza, e una pressione prolungata di passaggio
   avrebbe aperto la diagnostica per sbaglio. */
.onboarding-logo{display:inline-block;line-height:1;cursor:default;
  -webkit-tap-highlight-color:transparent}
.onboarding-title{font-size:1.25rem;font-weight:700;color:var(--gray-800);margin-bottom:2px}
.onboarding-subtitle{font-size:.78rem;color:var(--gray-500)}
.onboarding-progress{display:flex;gap:6px;margin-bottom:18px;flex-shrink:0}
.progress-dot{flex:1;height:6px;border-radius:3px;background:var(--gray-200);transition:background .3s}
.progress-dot.active{background:var(--primary)}
.progress-dot.done{background:var(--primary-dark)}
.onboarding-step{display:none;flex-direction:column;gap:10px}
.onboarding-step.visible{display:flex}
.onboarding-step h3{font-size:.95rem;color:var(--gray-700);margin-bottom:4px}
.onboarding-field{width:100%;padding:11px 14px;border-radius:10px;border:1.5px solid var(--gray-200);font-size:.95rem;color:var(--gray-800);background:var(--gray-50);outline:none;font-family:inherit}
.onboarding-field:focus{border-color:var(--primary);background:#fff}
[data-theme=dark] .onboarding-field{background:#1e293b;border-color:#475569;color:#f1f5f9}
.onboarding-label{font-size:.75rem;color:var(--gray-500);margin-bottom:-4px;font-weight:500}
.onboarding-row{display:flex;gap:8px}
.onboarding-row > *{flex:1}
.radio-group{display:flex;gap:8px}
.radio-option{flex:1;padding:10px;border:1.5px solid var(--gray-200);border-radius:10px;text-align:center;cursor:pointer;font-size:.85rem;background:var(--gray-50);transition:all .2s}
.radio-option.selected{border-color:var(--primary);background:var(--primary-light);color:var(--accent);font-weight:600}
[data-theme=dark] .radio-option{background:#1e293b;border-color:#475569;color:#cbd5e1}
[data-theme=dark] .radio-option.selected{background:#04342C;color:#5EEAD4}
.checklist{display:flex;flex-direction:column;gap:6px;max-height:180px;overflow-y:auto;overscroll-behavior:none;padding:4px;border:1.5px solid var(--gray-200);border-radius:10px;background:var(--gray-50)}
[data-theme=dark] .checklist{background:#1e293b;border-color:#475569}
.checklist label{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;cursor:pointer;font-size:.85rem;color:var(--gray-700)}
.checklist label:hover{background:var(--primary-light)}
[data-theme=dark] .checklist label{color:#cbd5e1}
[data-theme=dark] .checklist label:hover{background:#04342C}
.checklist input[type=checkbox]{width:16px;height:16px;accent-color:var(--primary);flex-shrink:0}
.consent-row{display:flex;align-items:flex-start;gap:8px;padding:8px;border-radius:8px;background:var(--gray-50);margin-top:4px}
[data-theme=dark] .consent-row{background:#1e293b}
.consent-row input[type=checkbox]{margin-top:2px;width:16px;height:16px;accent-color:var(--primary);flex-shrink:0}
.consent-row label{font-size:.78rem;color:var(--gray-600);line-height:1.4}
.consent-row a{color:var(--brand-ink);text-decoration:underline}
.onboarding-actions{display:flex;gap:8px;margin-top:14px;flex-shrink:0}
.onboarding-btn{flex:1;padding:12px;border-radius:10px;border:none;font-size:.95rem;font-weight:600;cursor:pointer;font-family:inherit;transition:background .2s}
.onboarding-btn-primary{background:var(--primary-fill);color:#fff}
.onboarding-btn-primary:hover{background:var(--primary-fill-active)}
.onboarding-btn-primary:disabled{background:var(--gray-300);cursor:not-allowed}
.onboarding-btn-secondary{background:var(--gray-100);color:var(--gray-700);flex:1}
.onboarding-btn-secondary:hover{background:var(--gray-200)}
[data-theme=dark] .onboarding-btn-secondary{background:#334155;color:#e2e8f0}
.onboarding-error{color:var(--danger-text);font-size:.78rem;text-align:center;margin-top:6px;display:none}
.onboarding-error.visible{display:block}

/* ═══ CHAT SCREEN ═══ */
/* La chat non contiene piu' header e footer: sono della shell (§10). */
.chat-screen{padding:0;overflow:hidden}
.header{background:#fff;padding:12px 16px;border-bottom:1px solid var(--gray-100);display:flex;justify-content:space-between;align-items:center;flex-shrink:0}
[data-theme=dark] .header{background:#1e293b;border-color:#334155}
.header-left{display:flex;align-items:center;gap:8px}
.header-left .logo{font-size:1.3rem;width:30px;height:30px;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0}
.header-left .logo img{width:30px;height:30px;border-radius:9px;object-fit:cover;display:block;border:1.5px solid var(--primary-border,#99F6E4)}
.header-left h1{font-size:.95rem;color:var(--gray-800);font-weight:700}
.ai-badge{font-size:.6rem;background:var(--primary-fill);color:#fff;padding:2px 6px;border-radius:8px;font-weight:600;letter-spacing:.5px;text-transform:uppercase}
.dog-name-header{font-size:.7rem;color:var(--brand-ink);font-weight:500;position:relative}
.header-title{position:relative}
.dog-name-header.switchable{cursor:pointer;display:inline-flex;align-items:center;gap:4px;
  background:rgba(13,148,136,.10);border:1px solid var(--primary-border,#99F6E4);border-radius:999px;padding:2px 9px;margin-top:2px}
.dog-name-header.switchable::after{content:'▾';font-size:.7rem;opacity:.85}
/* Tooltip che segnala la card (desktop: hover; touch: il pill+freccia rendono evidente che è toccabile) */
.dog-name-header.switchable[data-tip]::before{content:attr(data-tip);position:absolute;top:calc(100% + 7px);left:0;
  white-space:nowrap;background:var(--gray-800,#1f2937);color:#fff;font-size:.66rem;font-weight:500;padding:5px 9px;
  border-radius:8px;box-shadow:0 6px 18px rgba(0,0,0,.22);opacity:0;transform:translateY(-3px);pointer-events:none;
  transition:opacity .15s,transform .15s;z-index:1300}
.dog-name-header.switchable[data-tip]:hover::before,.dog-name-header.switchable.show-tip[data-tip]::before{opacity:1;transform:none}
.dog-name-header.switchable.tip-off::before{display:none}
.dog-item{display:flex;align-items:center;gap:8px;padding:2px}
.dog-pick{flex:1;min-width:0;text-align:left;background:none;border:none;border-radius:10px;padding:9px 11px;cursor:pointer;font:inherit;font-size:.94rem;color:var(--gray-800);display:flex;flex-direction:column;gap:2px}
.dog-pick:hover{background:var(--gray-50)}
[data-theme=dark] .dog-pick{color:#f1f5f9}
[data-theme=dark] .dog-pick:hover{background:#334155}
.dog-item.active .dog-pick{color:var(--primary-dark);font-weight:600}
.dog-pick .dog-breed{font-size:.74rem;color:var(--gray-400);font-weight:400}
/* Foto dell'animale (upload solo Premium) — sul lato destro della riga */
.dog-ava{width:58px;height:58px;flex-shrink:0;border-radius:14px;border:1.5px dashed var(--primary,#0D9488);
  background:var(--gray-50);display:flex;align-items:center;justify-content:center;cursor:pointer;overflow:hidden;
  padding:0;position:relative;color:var(--brand-ink)}
.dog-ava:hover{background:var(--primary-light)}
.dog-ava.has{border-style:solid;border-color:var(--primary-border,#99F6E4)}
.dog-ava img{width:100%;height:100%;object-fit:cover;display:block}
.dog-ava .ava-add{font-size:1.5rem;font-weight:300;line-height:1}
.dog-ava .ava-lock{position:absolute;bottom:2px;right:2px;font-size:.62rem;background:var(--primary-fill);color:#fff;border-radius:6px;padding:0 3px}
.dog-ava .ava-cam{font-size:1.15rem;opacity:.55}
[data-theme=dark] .dog-ava{background:#334155}
/* Lightbox foto del gatto */
.photo-lightbox{position:fixed;inset:0;z-index:3000;display:none;align-items:center;justify-content:center;background:rgba(0,0,0,.82);padding:24px;padding-top:max(24px,env(safe-area-inset-top));padding-bottom:max(24px,env(safe-area-inset-bottom))}
.photo-lightbox.open{display:flex}
.pl-inner{position:relative;display:flex;flex-direction:column;align-items:center;gap:14px;max-width:92vw}
#pl-img{max-width:min(86vw,420px);max-height:64dvh;width:auto;height:auto;border-radius:18px;object-fit:contain;box-shadow:0 20px 60px rgba(0,0,0,.5);background:#fff}
.pl-name{color:#fff;font-weight:700;font-size:1.05rem}
.pl-close{position:absolute;top:-14px;right:-6px;width:38px;height:38px;border-radius:50%;border:none;background:rgba(255,255,255,.92);color:#111;font-size:1.05rem;cursor:pointer;box-shadow:0 4px 14px rgba(0,0,0,.3)}
.pl-actions{display:flex;gap:10px;flex-wrap:wrap;justify-content:center}
.pl-actions button{border:none;border-radius:12px;padding:10px 16px;font-size:.85rem;font-weight:700;cursor:pointer;font-family:inherit}
.pl-btn-primary{background:var(--primary-fill);color:#fff}
.pl-btn-ghost{background:rgba(255,255,255,.15);color:#fff}
.pl-btn-danger{background:rgba(255,255,255,.15);color:#fecaca}
.dog-del{background:none;border:none;cursor:pointer;font-size:.85rem;padding:6px;border-radius:8px;opacity:.6}
.dog-del:hover{opacity:1;background:var(--gray-50)}
.dog-add{width:100%;text-align:left;background:none;border:none;border-top:1px solid var(--gray-100);margin-top:4px;padding:9px 10px;cursor:pointer;font:inherit;font-size:.82rem;color:var(--brand-ink);font-weight:600;display:flex;align-items:center;gap:6px}
.dog-add:hover{background:var(--gray-50)}
[data-theme=dark] .dog-add{border-color:#334155}
.dog-add.disabled{color:var(--gray-400);font-weight:400;cursor:default}
.dog-add .lock{margin-left:auto;font-size:.6rem;background:var(--primary-fill);color:#fff;padding:2px 6px;border-radius:8px;letter-spacing:.5px}
.clinical-ext{margin-top:18px;padding:14px;border:1px solid var(--primary-border,#99F6E4);border-radius:12px;background:#F0FDFA}
[data-theme=dark] .clinical-ext{background:#14302B;border-color:#225F56}
.clinical-ext-head{display:flex;align-items:center;justify-content:space-between;font-weight:600;color:var(--primary-dark,#0F766E);margin-bottom:8px;font-size:.95rem}
.clinical-badge{font-size:.6rem;background:var(--primary-fill);color:#fff;padding:2px 7px;border-radius:8px;letter-spacing:.5px}
.clinical-locked-msg{font-size:.82rem;color:var(--gray-600,#475569);line-height:1.5}
.clinical-ext.locked .clinical-ext-head,.clinical-ext.locked .clinical-locked-msg{cursor:pointer}
.weight-log{display:flex;flex-direction:column;gap:4px;margin-bottom:8px}
.wl-empty{font-size:.8rem;color:var(--gray-400,#94a3b8)}
.wl-row{display:flex;align-items:center;gap:10px;font-size:.85rem;background:#fff;border:1px solid var(--gray-200,#e2e8f0);border-radius:8px;padding:6px 10px}
[data-theme=dark] .wl-row{background:#1e293b;border-color:#475569}
.wl-row span:first-child{flex:1}
.wl-del{background:none;border:none;cursor:pointer;color:var(--gray-400,#94a3b8);font-size:.9rem;padding:2px 6px}
.wl-del:hover{color:#b91c1c}
.weight-log-add{display:flex;gap:6px;align-items:center}
.weight-log-add .onboarding-field{margin:0}
.wl-add-btn{white-space:nowrap;background:var(--primary-fill);color:#fff;border:none;border-radius:8px;padding:8px 12px;cursor:pointer;font-size:.82rem;font-weight:600}
.header-right{display:flex;align-items:center;gap:8px}
.header-btn{width:44px;height:44px;border-radius:50%;border:1px solid var(--gray-200);background:var(--gray-50);color:var(--gray-600);cursor:pointer;font-size:1.15rem;display:flex;align-items:center;justify-content:center;transition:all .2s}
.header-btn:hover{background:var(--gray-100)}
.header-btn:hover{background:var(--gray-100)}
[data-theme=dark] .header-btn{border-color:#475569;background:#334155;color:#e2e8f0}
.header-btn.muted{opacity:.45}
.header-btn.voice-stop{color:#fff;background:var(--danger);border-color:var(--danger);animation:stopPulse 1.4s infinite}
[data-theme=dark] .header-btn.voice-stop{color:#fff;background:var(--danger);border-color:var(--danger)}
@keyframes stopPulse{0%,100%{box-shadow:0 0 0 0 rgba(220,38,38,.45)}50%{box-shadow:0 0 0 6px rgba(220,38,38,0)}}
.avatar-slot.speaking{cursor:pointer}
.chat-container{flex:1;min-height:0;overflow-y:scroll;overscroll-behavior:none;padding:16px 12px 16px 16px;display:flex;flex-direction:column;gap:12px;scroll-behavior:smooth;scrollbar-width:auto;scrollbar-color:var(--gray-400) var(--gray-100)}
.chat-container::-webkit-scrollbar{width:10px}
.chat-container::-webkit-scrollbar-track{background:var(--gray-100);border-radius:5px}
.chat-container::-webkit-scrollbar-thumb{background:var(--gray-400);border-radius:5px;border:2px solid var(--gray-100)}
.chat-container::-webkit-scrollbar-thumb:hover{background:var(--gray-500)}
.message{display:flex;gap:8px;max-width:min(88%,620px);animation:msgIn .3s ease}
.message.ai{align-self:flex-start}
.message.user{align-self:flex-end;flex-direction:row-reverse}
.message-avatar{width:32px;height:32px;border-radius:50%;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:.9rem}
.message.ai .message-avatar{background:var(--primary-light)}
.message.user .message-avatar{background:var(--primary)}
.message-bubble{padding:10px 14px;border-radius:16px;font-size:.9rem;line-height:1.5;word-wrap:break-word}
.message.ai .message-bubble{background:var(--ai-bg);border:1px solid var(--ai-border);color:var(--gray-800);border-top-left-radius:4px}
[data-theme=dark] .message.ai .message-bubble{color:#5EEAD4}
.message.user .message-bubble{background:var(--user-bg);color:var(--user-color);border-top-right-radius:4px}
.typing-indicator{display:none;align-self:flex-start;padding:0 8px}
.typing-indicator.visible{display:flex;align-items:center;gap:8px}
.typing-dots{display:flex;gap:4px}
.typing-dots span{width:8px;height:8px;border-radius:50%;background:var(--gray-400);animation:typingDot 1.4s ease-in-out infinite}
.typing-dots span:nth-child(2){animation-delay:.2s}
.typing-dots span:nth-child(3){animation-delay:.4s}
.typing-label{font-size:.75rem;color:var(--gray-400)}
.welcome-card{background:linear-gradient(135deg,#F0FDFA,#CCFBF1);border:1px solid var(--primary-border);border-radius:16px;padding:20px;text-align:center}
[data-theme=dark] .welcome-card{background:linear-gradient(135deg,#04342C,#134E4A);border-color:#0F766E}
.welcome-card .welcome-icon{font-size:2.5rem;margin-bottom:8px}
.welcome-card h2{font-size:1.05rem;color:var(--gray-800);margin-bottom:6px}
.welcome-card p{font-size:.82rem;color:var(--gray-500);margin-bottom:14px}
.welcome-suggestions{display:flex;flex-direction:column;gap:8px}
.suggestion-btn{background:#fff;border:1px solid var(--gray-200);border-radius:12px;padding:10px 14px;font-size:.82rem;color:var(--gray-700);cursor:pointer;text-align:left;transition:all .2s}
.suggestion-btn:hover{border-color:var(--primary);background:#F0FDFA}
[data-theme=dark] .suggestion-btn{background:#1e293b;border-color:#475569;color:#e2e8f0}

/* ═══ AD BANNER (solo utenti free) ═══ */
.ad-banner{display:none;text-align:center;padding:4px 8px;background:var(--gray-50);border-top:1px solid var(--gray-200);flex-shrink:0}
.ad-banner.visible{display:block}
.ad-container{min-height:50px;display:flex;align-items:center;justify-content:center}
.ad-placeholder{font-size:10px;color:var(--gray-400)}
[data-theme=dark] .ad-banner{background:#1e293b;border-color:#334155}

.input-area{padding:12px 16px;border-top:1px solid var(--gray-100);background:#fff;flex-shrink:0}
[data-theme=dark] .input-area{background:#1e293b;border-color:#334155}
.input-row{display:flex;gap:8px;align-items:flex-end}
.input-field{flex:1;min-height:44px;max-height:120px;border:1.5px solid var(--gray-200);border-radius:22px;padding:10px 16px;font-size:.95rem;color:var(--gray-800);background:var(--gray-50);resize:none;outline:none;font-family:inherit;line-height:1.4;overflow-y:auto}
.input-field:focus{border-color:var(--primary);background:#fff}
[data-theme=dark] .input-field{background:#0f172a;border-color:#475569;color:#f1f5f9}
.input-field::placeholder{color:var(--gray-400)}
.input-field[readonly]{cursor:pointer;caret-color:transparent}
.input-modal{position:fixed;inset:0;background:rgba(15,23,42,.55);backdrop-filter:blur(2px);display:none;align-items:flex-start;justify-content:center;z-index:1000;padding:8dvh 14px max(14px,env(safe-area-inset-bottom))}
.input-modal.open{display:flex}
.input-modal-card{width:100%;max-width:480px;background:#fff;border-radius:18px;box-shadow:0 18px 50px rgba(0,0,0,.35);padding:16px;display:flex;flex-direction:column;gap:10px;animation:imPop .18s ease-out}
[data-theme=dark] .input-modal-card{background:#1e293b}
@keyframes imPop{from{transform:translateY(14px);opacity:.4}to{transform:translateY(0);opacity:1}}
.input-modal-title{font-size:.85rem;font-weight:600;color:var(--gray-500)}
[data-theme=dark] .input-modal-title{color:#94a3b8}
#input-modal-field{width:100%;min-height:163px;max-height:46dvh;border:1.5px solid var(--gray-200);border-radius:12px;padding:12px 14px;font-size:1.05rem;line-height:1.5;color:var(--gray-800);background:var(--gray-50);resize:none;outline:none;font-family:inherit;overflow-y:auto;scrollbar-width:thin;scrollbar-color:transparent transparent}
#input-modal-field:hover,#input-modal-field:focus{scrollbar-color:rgba(100,116,139,.45) transparent}
#input-modal-field::-webkit-scrollbar{width:6px}
#input-modal-field::-webkit-scrollbar-track{background:transparent}
#input-modal-field::-webkit-scrollbar-thumb{background:transparent;border-radius:3px}
#input-modal-field:hover::-webkit-scrollbar-thumb,#input-modal-field:focus::-webkit-scrollbar-thumb{background:rgba(100,116,139,.45)}
[data-theme=dark] #input-modal-field:hover::-webkit-scrollbar-thumb,[data-theme=dark] #input-modal-field:focus::-webkit-scrollbar-thumb{background:rgba(148,163,184,.5)}
#input-modal-field:focus{border-color:var(--primary);background:#fff}
[data-theme=dark] #input-modal-field{background:#0f172a;border-color:#475569;color:#f1f5f9}
.input-modal-actions{display:flex;gap:10px;justify-content:flex-end}
.im-btn{padding:11px 18px;border-radius:12px;border:none;font-size:.95rem;font-weight:600;cursor:pointer;font-family:inherit}
.im-cancel{background:var(--gray-100);color:var(--gray-600)}
[data-theme=dark] .im-cancel{background:#334155;color:#cbd5e1}
.im-send{background:var(--primary-fill);color:#fff;flex:1;max-width:60%}
.im-send:disabled{background:var(--gray-300)}
.im-mic{width:46px;flex:0 0 46px;padding:0;background:var(--primary-light,#CCFBF1);color:var(--primary-dark);border:1px solid var(--primary-border,#99F6E4);font-size:1.15rem;display:flex;align-items:center;justify-content:center}
.im-mic:hover{background:#99F6E4}
.im-mic.listening{background:var(--danger,#ef4444);color:#fff;border-color:var(--danger,#ef4444);animation:pulse 1.5s infinite}
[data-theme=dark] .im-mic{background:#3a2f17;color:#5EEAD4;border-color:#0F766E}
[data-theme=dark] .im-mic.listening{background:var(--danger);color:#fff;border-color:var(--danger)}
.input-modal-title.listening{color:var(--danger,#ef4444)}
.btn-voice,.btn-send{width:44px;height:44px;border-radius:50%;border:none;cursor:pointer;font-size:1.2rem;display:flex;align-items:center;justify-content:center;transition:all .2s;flex-shrink:0}
.suggestion-btn .npi{color:var(--gray-500);margin-right:8px;vertical-align:-.18em}
.btn-voice{background:var(--gray-100);color:var(--gray-600)}
.btn-voice:hover{background:var(--gray-200)}
.btn-voice.listening{background:var(--danger);color:#fff;animation:pulse 1.5s infinite}
[data-theme=dark] .btn-voice{background:#334155;color:#e2e8f0}
.btn-send{background:var(--primary-fill);color:#fff}
.btn-send:hover{background:var(--primary-fill-active)}
.btn-send:disabled{background:var(--gray-300);cursor:not-allowed}
.footer{padding:7px 16px 8px;text-align:center;font-size:.66rem;color:var(--gray-400);flex-shrink:0;border-top:1px solid var(--gray-100);line-height:1.45}
.footer a{color:var(--gray-400);text-decoration:underline}
.footer .linkbtn{background:none;border:0;padding:0;font:inherit;color:var(--gray-400);text-decoration:underline;cursor:pointer;min-height:24px}
[data-theme=dark] .footer{border-color:#334155}
.emergency-banner{display:none;background:var(--danger);color:#fff;padding:12px 16px;text-align:center;font-size:.85rem;font-weight:700;animation:emergencyPulse 2s infinite}
.emergency-banner a{color:#fff;text-decoration:underline}
.emergency-banner.visible{display:block}
.message-counter{font-size:.65rem;color:var(--gray-400);text-align:center;padding:2px 8px}

@keyframes msgIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
@keyframes typingDot{0%,60%,100%{transform:translateY(0);opacity:.4}30%{transform:translateY(-6px);opacity:1}}
@keyframes pulse{0%,100%{box-shadow:0 0 0 0 rgba(239,68,68,.4)}50%{box-shadow:0 0 0 10px rgba(239,68,68,0)}}
@keyframes emergencyPulse{0%,100%{opacity:1}50%{opacity:.85}}
.tablet-extra{display:none}
@media(max-width:375px){.message-bubble{font-size:.85rem;padding:8px 12px}.suggestion-btn{font-size:.78rem;padding:8px 12px}}

/* ═══ TABLET ═══ */
@media(min-width:600px){
.app{max-width:min(88vw,900px);border-radius:28px}
.header{padding:14px 24px}
.header-left h1{font-size:1.1rem}
.disclaimer{font-size:.78rem;padding:10px 24px}
.avatar-strip{padding:14px 24px;gap:16px}
.chat-container{padding:20px 24px;gap:14px}
.message{max-width:min(82%,620px)}
.message-bubble{font-size:1rem;padding:14px 18px;line-height:1.6}
.message-avatar{width:38px;height:38px;font-size:1.1rem}
.welcome-card{padding:28px}
.welcome-card h2{font-size:1.2rem}
.welcome-card p{font-size:.9rem}
.suggestion-btn{font-size:.9rem;padding:12px 16px}
.tablet-extra{display:block}
.input-area{padding:14px 24px}
.input-field{font-size:1rem;min-height:48px;padding:12px 20px}
.btn-voice,.btn-send{width:48px;height:48px;font-size:1.3rem}
.footer{font-size:.7rem;padding:8px 24px 9px}
.onboarding-screen{padding:30px 36px}
.onboarding-logo{font-size:3.2rem}
.onboarding-title{font-size:1.5rem}
.onboarding-field{font-size:1rem;padding:13px 16px}
}

/* Tablet (iPad anche in verticale, 768px+) e desktop: l'app usa più spazio, restando comoda da leggere */
@media(min-width:768px){
.chat-container{padding:24px 32px}
.message{max-width:min(78%,620px)}
.message-bubble{font-size:1.02rem}
.onboarding-screen{padding:40px 56px}
}
@media(min-width:1100px){
.message{max-width:min(72%,620px)}
}
/* ⚠️ Valori della gemella. `align-items:flex-start` con padding e gap piu' larghi
   era un mio ritocco della v1.0.19 mai riallineato: il disclaimer restava
   incollato in alto con uno spazio vuoto sotto, invece di essere centrato
   sull'altezza del ritratto. */
.avatar-strip{display:flex;align-items:center;gap:12px;padding:10px 16px;background:linear-gradient(135deg,#F0FDFA,var(--primary-light));border-bottom:1px solid var(--primary-border);flex-shrink:0}
[data-theme=dark] .avatar-strip{background:linear-gradient(135deg,#04342C,#134E4A);border-color:#0F766E}
.avatar-slot{width:88px;height:88px;border-radius:16px;overflow:hidden;background:#fff;flex-shrink:0;border:2px solid var(--primary-border);display:flex;align-items:center;justify-content:center}
.avatar-slot svg{width:100%;height:100%}
.avatar-slot.speaking{animation:speakPulse 1.5s infinite}
@keyframes speakPulse{0%,100%{box-shadow:0 0 0 0 rgba(13,148,136,.35)}50%{box-shadow:0 0 0 8px rgba(13,148,136,0)}}
.btn-voice-toggle{width:34px;height:34px;border-radius:50%;border:1px solid var(--primary-border);background:#fff;color:var(--primary-dark);cursor:pointer;font-size:1rem;display:flex;align-items:center;justify-content:center;flex-shrink:0;align-self:center;transition:opacity .2s}
.btn-voice-toggle.muted{opacity:.45}
[data-theme=dark] .btn-voice-toggle{background:#334155;color:#5EEAD4;border-color:#0F766E}
/* §0.1 — il vincolo resta: lo stato passa da «In ascolto…» a «Sta parlando…
   (tocca per fermare)», e senza una larghezza il testo piu' lungo allarga
   l'elemento e mangia lo spazio del vicino. Qui pero' la colonna occupa lo
   spazio RESIDUO e il testo va a capo dentro il suo spazio: flex:1 con
   min-width:0 fa lo stesso mestiere di una larghezza fissa, senza sprecarla. */
/* §0.1 — width fissa: lo stato passa da «In ascolto…» a «Sta parlando…
   (tocca per fermare)», e senza larghezza il testo piu' lungo allarga
   l'elemento e mangia lo spazio del disclaimer accanto. */
.avatar-col{display:flex;flex-direction:column;align-items:center;gap:5px;
  flex-shrink:0;width:88px}
.avatar-name{font-size:.8rem;font-weight:700;color:var(--gray-800);text-align:center;line-height:1.2}
.avatar-status{display:block;font-size:.7rem;color:var(--gray-500);font-weight:400}
.avatar-info{flex:1;min-width:0;display:flex;flex-direction:column;justify-content:center;gap:3px}
.avatar-disclaimer{font-size:.78rem;color:#115E59;line-height:1.4;margin-top:3px}
.avatar-disclaimer strong{color:#115E59}
[data-theme=dark] .avatar-disclaimer,[data-theme=dark] .avatar-disclaimer strong{color:#5EEAD4}
.avatar-disclaimer{font-size:.78rem;color:#115E59;line-height:1.4;margin-top:3px}
.avatar-disclaimer strong{color:#115E59}
[data-theme=dark] .avatar-name{color:#f1f5f9}
[data-theme=dark] .avatar-disclaimer,[data-theme=dark] .avatar-disclaimer strong{color:#5EEAD4}

/* Animazioni del gattino */
@keyframes dog-breathe{0%,100%{transform:scaleY(1);transform-origin:center bottom}50%{transform:scaleY(1.02);transform-origin:center bottom}}
.dog-body{animation:dog-breathe 3.5s ease-in-out infinite;transform-origin:120px 165px}
@keyframes dog-tail-wag{0%,100%{transform:rotate(-30deg)}50%{transform:rotate(30deg)}}
.dog-tail{animation:dog-tail-wag .7s ease-in-out infinite;transform-origin:165px 130px}
.happy .dog-tail{animation-duration:.35s}
@keyframes dog-blink{0%,92%,100%{transform:scaleY(0)}94%,98%{transform:scaleY(1)}}
.dog-eyelid{animation:dog-blink 4s ease-in-out infinite;transform-origin:center;transform-box:fill-box}
.dog-eyelid.right{animation-delay:.05s}
.dog-mouth-open{opacity:0}
.dog-mouth-closed{opacity:1}
.happy .dog-mouth-closed{opacity:0}
@keyframes dog-mouth-toggle-closed{0%,49%,100%{opacity:1}50%,99%{opacity:0}}
@keyframes dog-mouth-toggle-open{0%,49%,100%{opacity:0}50%,99%{opacity:1}}
.speaking .dog-mouth-closed{animation:dog-mouth-toggle-closed .4s steps(2,end) infinite}
.speaking .dog-mouth-open{animation:dog-mouth-toggle-open .4s steps(2,end) infinite}
.dog-tongue{opacity:0;transition:opacity .3s}
.happy .dog-tongue{opacity:1;animation:dog-tongue-pant .5s ease-in-out infinite}
@keyframes dog-tongue-pant{0%,100%{transform:translateY(0)}50%{transform:translateY(2px)}}
@keyframes dog-ear-twitch{0%,88%,100%{transform:rotate(0)}91%,93%{transform:rotate(-4deg)}}
.dog-ear-left{animation:dog-ear-twitch 6s ease-in-out infinite;transform-origin:80px 75px}
.dog-ear-right{animation:dog-ear-twitch 7s ease-in-out infinite 1.5s;transform-origin:160px 75px}
@keyframes dog-eye-look{0%,100%{transform:translate(0,0)}33%{transform:translate(1px,-1px)}66%{transform:translate(-1px,1px)}}
.dog-eye-highlight{animation:dog-eye-look 5s ease-in-out infinite}
/* Animazioni di Félicette (gatto) */
.cat-body{animation:dog-breathe 3.6s ease-in-out infinite;transform-origin:256px 417px}
.cat-ear-left{animation:dog-ear-twitch 6s ease-in-out infinite;transform-origin:196px 182px}
.cat-ear-right{animation:dog-ear-twitch 7s ease-in-out infinite 1.5s;transform-origin:316px 182px}
.cat-eyelid{transform:scaleY(0);transform-origin:center;transform-box:fill-box;animation:dog-blink 4.5s ease-in-out infinite}
.cat-eyelid.right{animation-delay:.06s}
.cat-eye-highlight{animation:dog-eye-look 5s ease-in-out infinite}
.cat-mouth-open{opacity:0}
.cat-mouth-closed{opacity:1}
.speaking .cat-mouth-closed{animation:dog-mouth-toggle-closed .4s steps(2,end) infinite}
.speaking .cat-mouth-open{animation:dog-mouth-toggle-open .4s steps(2,end) infinite}
@keyframes cat-whisker{0%,90%,100%{transform:rotate(0)}93%,96%{transform:rotate(-2.5deg)}}
.cat-whiskers-l{animation:cat-whisker 7s ease-in-out infinite;transform-origin:196px 340px}
.cat-whiskers-r{animation:cat-whisker 8.5s ease-in-out infinite 2s;transform-origin:316px 340px}

/* ═══ MODALE UPGRADE PREMIUM ═══ */
.upgrade-modal{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:1000;display:none;align-items:center;overflow:hidden;justify-content:center;padding:16px;
  padding-top:max(16px,env(safe-area-inset-top));padding-bottom:max(16px,env(safe-area-inset-bottom))}
.upgrade-modal.visible{display:flex}
.upgrade-modal-content{background:#fff;border-radius:20px;padding:28px;max-width:min(92vw,360px);max-height:100%;overflow-y:auto;overscroll-behavior:none;text-align:center}
[data-theme=dark] .upgrade-modal-content{background:#1e293b;color:#f1f5f9}
.upgrade-modal-content .upgrade-perks{list-style:none;margin:0 0 14px;padding:0;text-align:left;
  display:flex;flex-direction:column;gap:8px}
.upgrade-perks li{font-size:.82rem;line-height:1.45;color:var(--gray-700);
  padding-left:26px;position:relative}
.upgrade-perks li>span:first-child{position:absolute;left:0;top:0;font-size:.95rem;line-height:1.4}
.upgrade-perks b{color:var(--gray-800)}
.upgrade-choose{font-size:.82rem;color:var(--gray-600);margin:0 0 10px}
.upgrade-modal-content h3{margin-bottom:8px;color:var(--primary-dark);font-size:1.2rem}
.upgrade-modal-content .upgrade-icon{font-size:2.5rem;margin-bottom:12px}
.upgrade-modal-content p{font-size:.85rem;color:var(--gray-500);margin-bottom:16px;line-height:1.5}
.upgrade-modal-content .price{font-size:1.5rem;font-weight:700;margin-bottom:16px;color:var(--gray-800)}
.upgrade-modal-content .price-period{font-size:.8rem;font-weight:400;color:var(--gray-500)}
.upgrade-btn-primary{width:100%;padding:14px;border-radius:12px;background:var(--primary-fill);color:#fff;border:none;font-size:1rem;font-weight:600;cursor:pointer;margin-bottom:8px}
.upgrade-btn-primary:hover{background:var(--primary-fill-active)}
.upgrade-btn-primary:disabled{background:var(--gray-300,#cbd5e1);cursor:not-allowed}
.consent-recesso{display:flex;gap:8px;align-items:flex-start;text-align:left;font-size:.72rem;color:var(--gray-500);line-height:1.4;margin-bottom:14px}
.consent-recesso input{margin-top:2px;flex-shrink:0;width:16px;height:16px;accent-color:var(--primary)}
.manage-sub-link{display:none;margin-top:4px}
.manage-sub-link .linkbtn{cursor:pointer}
.free-gate{display:flex;flex-direction:column;align-items:center;gap:8px}
.free-upgrade-btn{width:100%;padding:13px 16px;border:0;border-radius:12px;background:linear-gradient(135deg,var(--primary-fill),var(--primary-fill-active));color:#fff;font-weight:700;font-size:.95rem;cursor:pointer}
.free-code-toggle{display:inline-block;margin-top:10px;font-size:13px;background:none;border:none;padding:4px;font-family:inherit;min-height:24px;color:var(--brand-ink);cursor:pointer;text-decoration:underline}
[data-theme=dark] .free-code-toggle{color:var(--brand-ink)}
.free-code-btn{padding:10px 18px;border:0;border-radius:10px;background:var(--primary-fill);color:#fff;font-weight:600;cursor:pointer}
.free-code-btn:disabled{opacity:.6;cursor:default}
.free-code-msg{flex-basis:100%;font-size:13px;min-height:18px;text-align:center}
.free-code-msg.ok{color:var(--success-text)}
.free-code-msg.err{color:var(--danger-text)}
.upgrade-btn-secondary{width:100%;padding:10px;border-radius:12px;background:none;border:1px solid var(--gray-200);font-size:.85rem;cursor:pointer;color:var(--gray-500);font-family:inherit}
/* ═══ Selettore piano (mensile / annuale) nel modale upgrade ═══ */
.plan-select{display:flex;gap:10px;margin-bottom:16px}
.plan-card{flex:1;position:relative;background:var(--gray-50,#f8fafc);border:2px solid var(--gray-200,#e2e8f0);border-radius:14px;padding:14px 10px 12px;cursor:pointer;text-align:center;font-family:inherit;transition:border-color .15s,background .15s}
.plan-card:hover{border-color:var(--primary-border,#99F6E4)}
.plan-card.selected{border-color:var(--primary);background:var(--primary-light,#CCFBF1)}
.plan-card .plan-name{display:block;font-size:.8rem;font-weight:600;color:var(--gray-600,#475569);margin-bottom:4px}
.plan-card.selected .plan-name{color:var(--primary-dark,#0F766E)}
.plan-card .plan-amt{display:block;font-size:1.35rem;font-weight:700;color:var(--gray-800,#1e293b)}
.plan-card .plan-amt small{font-size:.7rem;font-weight:400;color:var(--gray-500,#64748b)}
.plan-card .plan-badge{position:absolute;top:-9px;left:50%;transform:translateX(-50%);white-space:nowrap;font-size:.6rem;font-weight:700;background:var(--primary-fill);color:#fff;padding:2px 8px;border-radius:8px;letter-spacing:.3px}
.plan-card .plan-eq{display:block;font-size:.66rem;color:var(--gray-500,#64748b);margin-top:5px;line-height:1.2}
.plan-card.selected .plan-eq{color:var(--primary-dark,#0F766E)}
[data-theme=dark] .plan-card .plan-amt{color:#f1f5f9}
[data-theme=dark] .plan-card.selected .plan-name{color:#2DD4BF}
[data-theme=dark] .plan-card{background:#0f172a;border-color:#475569}
[data-theme=dark] .plan-card.selected{background:#14302B;border-color:#2DD4BF}
[data-theme=dark] .plan-card .plan-amt{color:#f1f5f9}
[data-theme=dark] .plan-card.selected .plan-name{color:#2DD4BF}

/* Riga nome Félicette + bottone voce sulla stessa linea; il disclaimer sotto usa tutta la larghezza */
.avatar-name-row{display:flex;align-items:center;gap:8px}
.avatar-name-row .avatar-name{flex:1;min-width:0}

/* Landscape su telefono: scrolling ESTERNO. La card (.app) scorre come un unico
   blocco (overflow-y:auto) e la chat NON si comprime (flex:1 0 auto): così il
   contenuto ha respiro e si scorre tutta la "pagina" invece di restare schiacciata
   nella poca altezza. Niente compressione aggressiva: avatar e "Félicette" tornano a
   misura normale; solo l'avatar leggermente ridotto e il banner ads nascosto. */
@media(orientation:landscape) and (max-height:680px){
.app{overflow-y:auto;overscroll-behavior:none}
.chat-container{flex:1 0 auto;overflow-y:visible}
.avatar-slot{width:60px;height:60px}
/* ⚠️ La gemella NON riduce .avatar-col in landscape: solo il ritratto. Ridurre
   anche la colonna comprimeva lo stato «Sta parlando… (tocca per fermare)» in
   60px, dove va a capo tre volte — ed e' il §0.1 che rientra dalla finestra. */
.avatar-strip{padding:8px 16px;gap:10px}
.ad-banner{display:none}
}

/* ═══ iOS: nessuno zoom automatico al fuoco dei campi (§0.16) ═══
   Safari ingrandisce la pagina da solo quando un campo con font < 16px prende il
   fuoco, e da quando lo zoom e' riabilitato (§1) quel comportamento non e' piu'
   bloccato: la pagina si allarga, si ridispone, e oscilla. 16px e' la soglia
   esatta sotto la quale scatta.

   ⚠️ Si alza il font SOLO su WebKit touch, non per tutti: su Android e desktop la
   resa resta quella della gemella, e non serve.
   ⚠️ NON si torna a `maximum-scale=1`: bloccherebbe anche lo zoom a due dita,
   che e' un requisito WCAG 1.4.4. La soglia si rispetta, non si aggira. */
@supports (-webkit-touch-callout: none) {
  .onboarding-field, .onboarding-field-sm, .input-field, #free-code-input, .rem-field,
  #input-modal-field, .code-modal input, .pron-row input, .dev-content input,
  input[type="text"], input[type="email"], input[type="number"], input[type="tel"],
  input[type="date"], input[type="time"], input[type="password"], input[type="search"],
  #rip-input,
  select, textarea { font-size: 16px }
}

/* ═══ DIMENSIONE DEL TESTO (nota NeosDog v1.31.0, da LireaGut) ═══
   ⚠️⚠️ TRE ESCLUSIONI, e contano piu' delle inclusioni:
     1. i CAMPI di scrittura NON si toccano. Su iOS il blocco
        `@supports (-webkit-touch-callout: none)` li forza a 16px perche' Safari non
        ingrandisca la pagina da se' al fuoco — e' lo «yo-yo» del §0.16. Alzarli lo
        riaprirebbe dalla parte opposta: sopra i 16px il comportamento non scatta,
        ma la larghezza di layout cambia comunque a ogni fuoco.
     2. il PIE' DI PAGINA non si tocca: e' gia' al limite, una riga sola a 320px con
        9px di margine, e la' ci sono i link legali.
     3. le misure in `px` fisse restano in `px`: si scala solo `font-size`, e solo su
        elementi che si adattano in altezza.
   ⚠️ Questa preferenza NON sostituisce lo zoom a due dita, che resta attivo per
   WCAG 1.4.4 (§1): sono due cose diverse — una preferenza che resta e un
   ingrandimento momentaneo. */
body.testo-grande .message-bubble{font-size:1.02rem;line-height:1.55}
body.testo-grande .welcome-card p{font-size:.94rem}
body.testo-grande .welcome-card h2{font-size:1.12rem}
body.testo-grande .breed-note,
body.testo-grande .breed-meta{font-size:.82rem}
body.testo-grande .breed-doc p,
body.testo-grande .breed-doc li{font-size:.95rem;line-height:1.6}
body.testo-grande .disclaimer{font-size:.8rem}
body.testo-grande .rem-when{font-size:.95rem}
body.testo-grande .rem-sub{font-size:.84rem}
body.testo-grande .fun-quote{font-size:1.06rem}
body.testo-grande .fun-sub{font-size:.9rem}
body.testo-grande .set-lab{font-size:.98rem}
body.testo-grande .set-sub{font-size:.82rem}
body.testo-grande .set-val{font-size:.9rem}
body.testo-grande .home-item-lab{font-size:1rem}
body.testo-grande .home-item-sub{font-size:.84rem}
body.testo-grande .upgrade-modal-content p,
body.testo-grande .upgrade-modal-content li{font-size:.94rem}
/* ═══ NIENTE ZOOM AL DOPPIO TOCCO SUI COMANDI (§2, nota NeosDog 09/09) ═══
   Due tocchi ravvicinati su un pulsante ingrandivano la pagina invece di premerlo
   due volte: `touch-action` non era dichiarato da nessuna parte.
   ⚠️ I CAMPI DI TESTO restano esclusi di proposito: lì il doppio tocco seleziona la
   parola, ed è un gesto che serve.
   ⚠️ Lo zoom a DUE DITA resta abilitato (WCAG 1.4.4, §1): si toglie il gesto
   sbagliato sui comandi, non la capacità di ingrandire.
   ⚠️ Ogni classe è stata verificata nel file: NeosDog segnala che nella loro prima
   stesura 2 selettori su 17 non esistevano, e la regola sembrava coprire tutto. */
button, a, label, [role="button"], div[onclick], span[onclick],
.set-row,
.home-item,
.dev-btn,
.del-opt,
.rip-btn,
.rip-annulla,
.breed-gen-btn,
.upgrade-btn-secondary,
.upgrade-btn-primary,
.radio-option,
.rem-del,
.rem-add-btn,
.plan-card,
.fun-btn,
.vetrina-link,
.home-ava,
.set-go,
.suggestion-btn { touch-action: manipulation }
/* ⚠️ e i campi tornano ad `auto`, in caso una regola sopra li abbia presi */
input, textarea, select { touch-action: auto }
/* Icone npi- (Neos Pet Icons): viewBox 24x24, tutte in currentColor, cosi'
   l'icona eredita il colore del testo e segue il tema senza varianti. */
.npi{width:1em;height:1em;display:inline-block;vertical-align:-.125em;flex-shrink:0;
  pointer-events:none}
.npi-lg{width:1.35em;height:1.35em}
.npi-xl{width:2.6em;height:2.6em}


/* ═══ MENU SVILUPPATORE ═══
   Cinque tocchi sul logo. Il logo è l'unico elemento presente in OGNI fase — benvenuto,
   onboarding e app — mentre il piè di pagina, dov'era prima la diagnostica del gioco,
   può risultare tagliato proprio quando serve indagare. */
.code-modal{display:none;position:fixed;inset:0;z-index:2600;background:rgba(0,0,0,.55);
  align-items:center;overflow:hidden;justify-content:center;padding:16px}
.code-modal.open{display:flex}
.code-modal-content{background:var(--card,#fff);border-radius:18px;padding:22px 20px;
  max-width:360px;width:100%;box-shadow:var(--shadow-lg);text-align:center;max-height:calc(100dvh - 32px - env(safe-area-inset-top) - env(safe-area-inset-bottom));overflow-y:auto}
[data-theme=dark] .code-modal-content{background:var(--gray-100)}
.code-modal h3{margin:0 0 4px;font-size:1.05rem;color:var(--gray-800)}
.code-modal p{font-size:.82rem;color:var(--gray-600);margin:0 0 14px;line-height:1.45}
.code-modal input{width:100%;padding:12px 14px;border:1px solid var(--gray-300);border-radius:11px;
  font-size:1rem;text-align:center;letter-spacing:.08em;text-transform:uppercase;
  font-family:inherit;background:#fff;color:var(--gray-900)}
[data-theme=dark] .code-modal input{background:var(--gray-50);border-color:var(--gray-300)}
.code-modal .cm-go{width:100%;margin-top:10px;background:var(--primary-fill);color:#fff;border:none;
  border-radius:11px;padding:13px;font-size:.92rem;font-weight:700;cursor:pointer;font-family:inherit}
.code-modal .cm-go:active{background:var(--primary-fill-active)}
.code-modal .cm-go:disabled{opacity:.6;cursor:default}
.code-modal .cm-close{width:100%;margin-top:8px;background:none;border:1px solid var(--gray-200);
  border-radius:11px;padding:11px;font-size:.86rem;color:var(--gray-700);cursor:pointer;font-family:inherit}
.rip-modal{display:none;position:fixed;inset:0;z-index:2800;background:rgba(0,0,0,.55);
  align-items:center;justify-content:center;padding:18px}
.rip-modal.open{display:flex}
.rip-content{background:var(--card,#fff);border-radius:18px;padding:20px;max-width:400px;width:100%;
  max-height:calc(100dvh - 36px);overflow-y:auto;overscroll-behavior:none;box-shadow:var(--shadow-lg)}
[data-theme=dark] .rip-content{background:var(--gray-100)}
.rip-content h3{margin:0 0 8px;font-size:1.02rem;color:var(--gray-800)}
.rip-content p{font-size:.82rem;color:var(--gray-600);line-height:1.5;margin:0 0 12px}
/* ⚠️ `word-break:break-all` con un corpo fisso mandava a capo l'ULTIMO carattere da
   solo — il posto peggiore, perche' chi trascrive a mano sbaglia proprio la fine, e
   questo codice e' l'unica via di recupero dell'abbonamento. Un UUID e' 36 caratteri:
   il corpo si adatta alla finestra invece di spezzare. Il minimo di 10px e' calcolato,
   non scelto: su 320px lo spazio utile e' 228px e il codice a 10px ne occupa 219. */
.rip-codice{font-family:ui-monospace,Menlo,Consolas,monospace;
  /* ⚠️ UNA riga, come nella gemella. La nostra misura dice che due righe spezzate ai
     trattini darebbero 15,4px invece di 10,0px a 320px — piu' leggibile — ma due app
     gemelle non possono rendere lo STESSO codice in due modi diversi, e la scelta va
     fatta insieme. La misura e' nella nota di ritorno; se la adottano, si cambia in
     entrambe. `clamp` invece di un corpo fisso, `nowrap` invece di `break-all`: e'
     quello che evita il carattere orfano. */
  font-size:clamp(10px,2.9vw,14px);white-space:nowrap;letter-spacing:0;
  text-align:center;user-select:all;overflow-x:auto;overscroll-behavior:none;
  /* ⚠️ `padding:11px` e `text-align` erano DUPLICATI qui sotto e sovrascrivevano quelli
     dichiarati sopra: gli 8px laterali servono, altrimenti il codice a corpo minimo
     tocca i bordi. Le proprieta' stanno dichiarate una volta sola. */
  background:var(--gray-50);border:1px solid var(--gray-200);border-radius:10px;
  padding:12px 8px;color:var(--gray-800);margin-bottom:10px}
.rip-btn{display:block;width:100%;border-radius:11px;padding:12px;font-family:inherit;font-size:.88rem;
  font-weight:700;cursor:pointer;border:none;background:var(--primary-fill);color:#fff;margin-bottom:9px}
.rip-btn:active{background:var(--primary-fill-active)}
.rip-btn:disabled{opacity:.6}
.rip-annulla{display:block;width:100%;background:none;border:1px solid var(--gray-200);border-radius:11px;
  padding:11px;font-size:.86rem;font-weight:600;color:var(--gray-700);cursor:pointer;font-family:inherit}
.rip-modal input{width:100%;padding:12px;border:1px solid var(--gray-300);border-radius:10px;
  font-family:ui-monospace,Menlo,Consolas,monospace;font-size:.82rem;text-align:center;margin-bottom:10px;
  background:#fff;color:var(--gray-900)}
[data-theme=dark] .rip-modal input{background:var(--gray-50);border-color:var(--gray-300)}
.rip-msg{font-size:.8rem;text-align:center;margin-bottom:10px;min-height:1.2em;color:var(--gray-600)}
.rip-msg.ok{color:#15803d;font-weight:600}
.rip-msg.err{color:var(--danger-text);font-weight:600}
.rip-aiuto{font-size:.78rem;color:var(--gray-600);line-height:1.5;margin:10px 0 0;text-align:center}
.rip-aiuto b{color:var(--gray-700)}
.rip-casi{margin:8px 0 12px;padding-left:20px;font-size:.8rem;color:var(--gray-600);line-height:1.5}
.rip-casi li{margin-bottom:5px}
.rip-casi li::marker{color:var(--gray-400)}
.rip-avviso{background:#fffbeb;border:1px solid #fde68a;border-radius:12px;padding:11px 13px;
  margin:0 0 12px;font-size:.78rem;line-height:1.5;color:#78350f}
[data-theme=dark] .rip-avviso{background:rgba(180,83,9,.16);border-color:#92400e;color:#fde68a}
.rip-avviso b{display:block;margin-bottom:2px}
.del-modal{display:none;position:fixed;inset:0;z-index:2700;background:rgba(0,0,0,.55);
  align-items:center;justify-content:center;padding:20px}
.del-modal.open{display:flex}
.del-content{background:var(--card,#fff);border-radius:18px;padding:22px 20px;max-width:380px;width:100%;
  box-shadow:var(--shadow-lg);max-height:calc(100dvh - 40px);overflow-y:auto}
[data-theme=dark] .del-content{background:var(--gray-100)}
.del-content h3{margin:0 0 6px;font-size:1.05rem;color:var(--gray-800)}
.del-content p{font-size:.84rem;color:var(--gray-600);margin:0 0 14px;line-height:1.5}
.del-opt{display:block;width:100%;text-align:left;border-radius:12px;padding:13px 15px;margin-bottom:10px;
  font-family:inherit;font-size:.88rem;font-weight:600;cursor:pointer;border:1px solid var(--gray-200);
  background:var(--gray-50);color:var(--gray-800)}
.del-opt small{display:block;font-weight:400;font-size:.74rem;color:var(--gray-600);margin-top:3px;line-height:1.45}
.del-opt:active{background:var(--gray-100)}
.del-opt.pericolo{border-color:#f3c2c2;background:#fff5f5;color:var(--danger-text)}
[data-theme=dark] .del-opt.pericolo{background:rgba(200,30,30,.18);border-color:#7f1d1d}
.del-opt.pericolo small{color:var(--gray-600)}
.del-annulla{display:block;width:100%;background:none;border:1px solid var(--gray-200);border-radius:12px;
  padding:12px;font-size:.88rem;font-weight:600;color:var(--gray-700);cursor:pointer;font-family:inherit}
.pron-toggle{background:none;border:none;padding:2px 0 0;margin:-2px 0 0;font-family:inherit;
  font-size:.72rem;color:var(--brand-ink);text-decoration:underline;cursor:pointer;align-self:flex-start}
.pron-box{display:none;flex-direction:column;gap:6px;margin-top:6px;padding:10px 12px;
  background:var(--gray-50);border:1px solid var(--gray-200);border-radius:11px}
.pron-box.open{display:flex}
.pron-hint{font-size:.72rem;color:var(--gray-600);line-height:1.45}
.pron-row{display:flex;gap:8px}
.pron-row input{flex:1;min-width:0}
.pron-try{flex-shrink:0;background:var(--gray-100);border:1px solid var(--gray-200);border-radius:9px;
  padding:0 14px;font-size:.8rem;font-weight:600;color:var(--gray-700);cursor:pointer;font-family:inherit;
  display:flex;align-items:center;gap:5px}
.pron-try:active{background:var(--gray-200)}
.dev-modal{display:none;position:fixed;inset:0;z-index:2900;background:rgba(0,0,0,.6);
  align-items:center;justify-content:center;padding:16px}
.dev-modal.open{display:flex}
.dev-content{background:var(--card,#fff);border-radius:16px;padding:18px;max-width:520px;width:100%;
  max-height:calc(100dvh - 32px);overflow-y:auto;box-shadow:var(--shadow-lg)}
[data-theme=dark] .dev-content{background:var(--gray-100)}
.dev-content h3{margin:0 0 2px;font-size:1rem;color:var(--gray-800)}
.dev-content .dev-sub{font-size:.74rem;color:var(--gray-600);margin:0 0 14px}
.dev-btn{display:block;width:100%;text-align:left;border:1px solid var(--gray-200);border-radius:11px;
  padding:12px 14px;margin-bottom:9px;font-family:inherit;font-size:.86rem;font-weight:600;
  background:var(--gray-50);color:var(--gray-800);cursor:pointer}
.dev-btn small{display:block;font-weight:400;font-size:.72rem;color:var(--gray-600);margin-top:2px}
.dev-btn:active{background:var(--gray-100)}
.dev-btn.pericolo{border-color:#f3c2c2;background:#fff5f5;color:var(--danger-text)}
[data-theme=dark] .dev-btn.pericolo{background:rgba(200,30,30,.18);border-color:#7f1d1d}
.dev-btn.pericolo small{color:var(--gray-600)}
.dev-out{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:.66rem;line-height:1.55;
  white-space:pre-wrap;word-break:break-word;background:var(--gray-50);border:1px solid var(--gray-200);
  border-radius:10px;padding:10px;margin:8px 0;max-height:46dvh;overflow-y:auto;color:var(--gray-800)}
/* ⚠️ §27.1: era 46vh, arrivato col menu sviluppatore portato dalla gemella. Con `vh`
   la finestra della diagnostica si allunga oltre lo schermo proprio mentre si sta
   indagando un problema di geometria: si rischia di confondere lo strumento con il
   difetto. Trovato verificando le affermazioni della nota di ritorno, non da un
   presidio: nessuno dei nostri guardava dentro il codice portato da loro. */
.dev-chiudi{display:block;width:100%;background:none;border:1px solid var(--gray-200);border-radius:11px;
  padding:11px;font-size:.86rem;font-weight:600;color:var(--gray-700);cursor:pointer;font-family:inherit}

/* ═══════════════════════════════════════════════════════════════════════
   SHELL PERSISTENTE (§10)
   header · disclaimer · banner d'emergenza · footer stanno FUORI dalle
   schermate. Le schermate vivono dentro <main class="screens">, che e'
   anche il punto di riferimento principale della pagina (§2).
   ═══════════════════════════════════════════════════════════════════════ */
.screens{flex:1;min-height:0;display:flex;flex-direction:column;position:relative}
.screen{flex:1;min-height:0;display:flex;flex-direction:column;overflow-y:auto;overscroll-behavior:none}
.screen.hidden{display:none}
.header.hidden{display:none}

/* Header contestuale ---------------------------------------------------
   Nelle schermate figlie il TITOLO SOSTITUISCE IL MARCHIO: l'utente sa
   gia' in che app e', e cosi' l'header non cresce. I comandi della chat
   restano solo in chat. */
.header-back{width:44px;height:44px;border-radius:50%;border:1px solid var(--gray-200);
  background:var(--gray-50);color:var(--gray-600);cursor:pointer;font-size:1.3rem;
  display:flex;align-items:center;justify-content:center;flex-shrink:0;padding:0}
.header-back:active{background:var(--gray-100)}
.header-screen-title{display:block;font-size:.95rem;font-weight:700;color:var(--gray-800);
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.header-screen-sub{display:block;font-size:.72rem;color:var(--gray-500);margin-top:1px;
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.header-chat-title{display:flex;flex-direction:column;align-items:flex-start;gap:0;min-width:0;
  border:0;background:none;padding:0;font:inherit;text-align:left;cursor:pointer;max-width:100%}
.header-chat-title .hct-name{font-size:.95rem;font-weight:700;color:var(--gray-800);
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
.header-chat-title .hct-breed{font-size:.72rem;color:var(--gray-500);margin-top:1px;
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
.hct-caret{color:var(--gray-400);font-size:.62em;margin-left:3px;vertical-align:middle}
.header-logo-ico{color:var(--brand-ink);font-size:1.3rem;width:30px;height:30px;flex-shrink:0;
  display:inline-flex;align-items:center;justify-content:center;overflow:hidden}
/* ⚠️ §31 — ogni contenitore che ospita un'icona deve avere MISURA esplicita:
   un <img> senza vincolo nasce alla sua dimensione naturale, e sul tablet
   la foto del gatto occupava mezza schermata. */
.header-logo-ico img{width:30px;height:30px;border-radius:9px;object-fit:cover;display:block;
  border:1.5px solid var(--primary-border)}
.header-left .logo{color:var(--brand-ink);font-size:1.3rem;width:30px;height:30px;
  display:inline-flex;align-items:center;justify-content:center;flex-shrink:0}

/* Disclaimer persistente (Art. 348 C.P.) -------------------------------
   Prima viveva dentro la strip dell'avatar, cioe' dentro la chat: durante
   tutto l'onboarding non era visibile. */
.disclaimer{background:linear-gradient(135deg,#CCFBF1,#99F6E4);border-bottom:1px solid #F59E0B;
  padding:8px 16px;font-size:.7rem;color:#115E59;text-align:center;line-height:1.4;flex-shrink:0}
[data-theme=dark] .disclaimer{background:linear-gradient(135deg,#04342C,#134E4A);
  color:#5EEAD4;border-color:#0F766E}

/* Benvenuto — valori della gemella (.wel-*). */
.welcome-screen{padding:20px 20px 16px;gap:0}
.welcome-screen > :first-child{margin-top:auto}
.welcome-screen > :last-child{margin-bottom:auto}
.ws-logo{font-size:3rem;line-height:1;color:var(--brand-ink);text-align:center;
  -webkit-tap-highlight-color:transparent;-webkit-user-select:none;user-select:none}
.ws-title{font-size:1.3rem;font-weight:700;color:var(--gray-800);margin-top:6px;text-align:center}
.ws-sub{font-size:.86rem;color:var(--gray-600);margin-top:4px;line-height:1.45;
  text-align:center;margin-bottom:18px}
.ws-list{display:flex;flex-direction:column;gap:12px;margin-bottom:18px}
.ws-item{display:flex;align-items:flex-start;gap:12px;text-align:left}
/* ⚠️ Nella gemella l'icona del benvenuto rende 16.8px: il contenitore e' a
   1.35rem ma l'SVG non lo eredita come da noi. Misura fissata esplicitamente
   invece che dedotta dal font, cosi' non dipende dall'annidamento (§31). */
.ws-item .ws-ico{font-size:1.35rem;line-height:1.2;flex-shrink:0;width:28px;text-align:center;
  color:var(--brand-ink);display:flex;align-items:center;justify-content:center}
.ws-item .ws-ico .npi{width:16.8px;height:16.8px}
.ws-item-txt{flex:1;min-width:0;display:block}
.ws-item-lab{display:block;font-size:.92rem;color:var(--gray-800);font-weight:700}
.ws-item-desc{display:block;font-size:.78rem;color:var(--gray-600);line-height:1.45;margin-top:1px}
.ws-cta{width:100%;background:var(--primary-fill);color:#fff;border:none;border-radius:12px;
  padding:15px;font:inherit;font-size:1rem;font-weight:700;cursor:pointer}
.ws-cta:active{background:var(--primary-fill-active)}
.ws-foot{font-size:.72rem;color:var(--gray-600);line-height:1.5;text-align:center;margin-top:12px}

/* ═══ "COSA DICE …?" — mini-gioco audio ═══
   Tutto sul dispositivo: nessuna chiamata al Worker, nessun audio salvato o inviato.
   Stile volutamente DIVERSO dalla chat: deve leggersi come gioco a colpo d'occhio,
   perché il confine con la parte sanitaria non deve mai sfumare. */
.fun-screen{flex:1;display:flex;flex-direction:column;min-height:0;overflow-y:auto;padding:16px}
.fun-screen.hidden{display:none}
.fun-tag{display:inline-flex;align-items:center;gap:6px;align-self:center;font-size:.68rem;
  font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:#5b21b6;
  background:#ede9fe;border:1px solid #ddd6fe;border-radius:999px;padding:4px 12px;margin-bottom:14px}
[data-theme=dark] .fun-tag{background:#2e1065;border-color:#5b21b6;color:#c4b5fd}
.fun-stage{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;
  text-align:center;gap:12px;padding:8px 0 16px}
.fun-title{font-size:1.05rem;font-weight:700;color:var(--gray-800)}
.fun-sub{font-size:.82rem;color:var(--gray-600);line-height:1.5;max-width:300px}
.fun-mic{width:110px;height:110px;border-radius:50%;border:none;cursor:pointer;font-family:inherit;
  background:linear-gradient(135deg,#7c3aed,#5b21b6);color:#fff;display:flex;align-items:center;
  justify-content:center;font-size:2.4rem;box-shadow:0 6px 20px rgba(91,33,182,.32)}
.fun-mic:active{transform:scale(.96)}
.fun-mic:disabled{opacity:.55;cursor:default}
.fun-mic.rec{animation:funPulse 1.1s ease-in-out infinite}
@keyframes funPulse{0%,100%{box-shadow:0 0 0 0 rgba(124,58,237,.45)}50%{box-shadow:0 0 0 18px rgba(124,58,237,0)}}
.fun-wave{display:flex;align-items:flex-end;justify-content:center;gap:4px;height:44px}
.fun-wave i{width:5px;background:#7c3aed;border-radius:3px;height:6px;transition:height .08s linear}
.fun-count{font-size:1.6rem;font-weight:700;color:#5b21b6;font-variant-numeric:tabular-nums}
[data-theme=dark] .fun-count{color:#c4b5fd}
.fun-card{background:linear-gradient(135deg,#f5f3ff,#ede9fe);border:1px solid #ddd6fe;border-radius:18px;
  padding:20px 18px;text-align:center;width:100%}
[data-theme=dark] .fun-card{background:linear-gradient(135deg,#2e1065,#3b0764);border-color:#5b21b6}
.fun-card-who{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:#6d28d9;margin-bottom:8px}
[data-theme=dark] .fun-card-who{color:#c4b5fd}
.fun-quote{font-size:1.12rem;line-height:1.45;font-weight:600;color:var(--gray-800)}
.fun-actions{display:flex;gap:10px;margin-top:16px;width:100%}
.fun-btn{flex:1;background:var(--gray-50);border:1px solid var(--gray-200);border-radius:12px;padding:12px;
  font-size:.86rem;font-weight:600;color:var(--gray-700);cursor:pointer;font-family:inherit;
  display:flex;align-items:center;justify-content:center;gap:6px}
.fun-btn:active{background:var(--gray-100)}
.fun-btn.primary{background:#7c3aed;border-color:#7c3aed;color:#fff}
.fun-btn.primary:active{background:#5b21b6}
.fun-note{font-size:.72rem;color:var(--gray-600);line-height:1.5;text-align:center;
  border-top:1px solid var(--gray-200);padding-top:12px;margin-top:16px}
.fun-left{font-size:.74rem;color:var(--gray-600);text-align:center;margin-top:10px}
.fun-diag{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:.64rem;line-height:1.5;
  color:var(--gray-600);background:var(--gray-50);border:1px dashed var(--gray-300);
  border-radius:10px;padding:8px 10px;margin-top:10px;text-align:left;word-break:break-word}
.fun-err{font-size:.82rem;color:var(--danger-text);text-align:center;margin-top:10px;line-height:1.45}

/* Home a menu — valori della gemella (.home-*). */
.home-screen{padding:16px 16px 20px;gap:0}
.home-head{text-align:center;margin-bottom:14px;display:block}
.home-ava{font-size:2.4rem;line-height:1;color:var(--brand-ink);background:none;border:0;
  padding:0;cursor:pointer;display:inline-flex}
.home-ava img{width:64px;height:64px;border-radius:18px;object-fit:cover;
  border:2px solid var(--primary-border);display:inline-block}
.home-name{font-size:1.05rem;font-weight:700;color:var(--gray-800);margin-top:4px}
.home-ask{font-size:.78rem;color:var(--gray-500);margin-top:2px}
.home-pill{display:inline-flex;align-items:center;gap:6px;margin-top:8px;font-size:.72rem;
  font-weight:600;background:var(--primary-light);color:var(--accent);
  border:1px solid var(--primary-border);border-radius:999px;padding:4px 12px;line-height:1.3}
[data-theme=dark] .home-pill{background:rgba(4,52,44,.55);color:#5EEAD4;border-color:#0F766E}
.home-list{display:flex;flex-direction:column;gap:10px}
.home-item{display:flex;align-items:center;gap:12px;width:100%;text-align:left;
  background:var(--gray-50);border:1px solid var(--gray-200);border-radius:14px;
  padding:14px 16px;cursor:pointer;font-size:.92rem;font-weight:600;color:var(--gray-800)}
.home-item:active{background:var(--gray-100)}
.home-item-ico{font-size:1.25rem;flex-shrink:0;color:var(--brand-ink);display:flex}
.home-item-txt{flex:1;min-width:0;display:block}
.home-item-lab{display:block}
.home-item-sub{display:block;font-size:.72rem;font-weight:400;color:var(--gray-500);margin-top:2px}
.home-badge{font-size:.62rem;font-weight:700;background:var(--accent);color:#fff;
  border-radius:8px;padding:2px 7px;letter-spacing:.3px;flex-shrink:0}

/* Vetrina Premium (§13) — valori della gemella (.home-showcase / .hs-*). */
.vetrina{background:linear-gradient(135deg,#F0FDFA,var(--primary-light));
  border:1px solid var(--primary-border);border-radius:16px;padding:16px;margin-bottom:12px}
[data-theme=dark] .vetrina{background:linear-gradient(135deg,#04342C,rgba(4,52,44,.55));border-color:#0F766E}
.vetrina-nome{font-size:1.02rem;font-weight:700;color:var(--accent)}
[data-theme=dark] .vetrina-nome{color:#5EEAD4}
.vetrina-prezzo{font-size:.8rem;color:var(--gray-700);margin-top:2px}
.vetrina-list{margin:12px 0 0;display:flex;flex-direction:column;gap:7px}
.vetrina-riga{display:flex;gap:8px;align-items:flex-start;font-size:.82rem;line-height:1.4;
  color:var(--gray-700)}
.vetrina-riga .npi{color:var(--gray-500);font-size:.9rem;flex-shrink:0;margin-top:2px}
.vetrina-riga strong{color:var(--gray-800);font-weight:700}
[data-theme=dark] .vetrina-riga{color:#e2e8f0}
[data-theme=dark] .vetrina-riga strong{color:#f8fafc}
.vetrina-cta{display:block;width:100%;margin-top:14px;background:var(--primary-fill);color:#fff;
  border:none;border-radius:12px;padding:13px;font:inherit;font-size:.92rem;font-weight:700;cursor:pointer}
.vetrina-cta:active{background:var(--primary-fill-active)}
.vetrina-link{display:block;width:100%;background:none;border:none;margin-top:8px;font:inherit;
  font-size:.78rem;color:var(--accent);text-decoration:underline;cursor:pointer;padding:4px}
[data-theme=dark] .vetrina-link{color:#5EEAD4}
.vetrina-link .npi{margin-right:4px;vertical-align:-.14em}

/* I miei gatti — valori della gemella (.dogs-screen / .dog-*). */
.pets-screen{padding:16px;gap:12px}
.pets-list{display:flex;flex-direction:column;gap:12px}
.pet-row{display:flex;align-items:center;gap:12px;width:100%;background:var(--gray-50);
  border:1px solid var(--gray-200);border-radius:14px;padding:12px 14px;font:inherit;text-align:left}
[data-theme=dark] .pet-row{background:#1e293b;border-color:#334155}
.pet-row.attivo{border-color:var(--primary-border);background:var(--primary-light)}
[data-theme=dark] .pet-row.attivo{background:rgba(4,52,44,.5);border-color:#0F766E}
.pet-txt{display:block;flex:1;min-width:0;cursor:pointer;padding:2px 0}
.pet-flag{display:block;color:var(--brand-ink);font-size:.9rem;line-height:1}
.pet-name{display:block;font-size:1rem;font-weight:700;color:var(--gray-800);line-height:1.3}
.pet-row.attivo .pet-name{color:var(--primary-dark)}
.pet-sub{display:block;font-size:.78rem;color:var(--gray-600);font-weight:400;
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.pet-photo{width:56px;height:56px;flex-shrink:0;border-radius:14px;
  border:1.5px dashed var(--primary);background:none;color:var(--brand-ink);font-size:1.25rem;
  display:flex;align-items:center;justify-content:center;cursor:pointer;overflow:hidden;padding:0}
.pet-photo.has{border-style:solid;border-color:var(--primary-border)}
.pet-photo img{width:100%;height:100%;object-fit:cover;display:block}
.pet-del{width:40px;height:40px;flex-shrink:0;border:1px solid var(--gray-200);background:#fff;
  border-radius:8px;color:var(--gray-600);font-size:1rem;opacity:.75;
  display:flex;align-items:center;justify-content:center;cursor:pointer}
[data-theme=dark] .pet-del{background:#334155;border-color:#475569;color:#e2e8f0}
.pet-add{display:flex;align-items:center;justify-content:center;gap:8px;width:100%;
  background:var(--gray-50);border:1px dashed var(--gray-300);border-radius:14px;padding:14px;
  font:inherit;font-size:.92rem;font-weight:600;color:var(--brand-ink);cursor:pointer}
[data-theme=dark] .pet-add{background:#1e293b;border-color:#475569}
.pets-note{font-size:.78rem;color:var(--gray-600);line-height:1.45;text-align:center;padding:0 8px}

/* Impostazioni — valori della gemella (.acc-*). */
.settings-screen{padding:16px 16px 20px;gap:0}
/* ⚠️ Il gap va su chi contiene DAVVERO le sezioni: su .settings-screen
   separava un figlio solo, cioe' niente. */
#settings-body{display:flex;flex-direction:column;gap:16px}
.set-plan{background:linear-gradient(135deg,#F0FDFA,var(--primary-light));
  border:1px solid var(--primary-border);border-radius:14px;padding:14px 16px}
[data-theme=dark] .set-plan{background:linear-gradient(135deg,#04342C,rgba(4,52,44,.55));border-color:#0F766E}
.set-plan-name{font-size:1rem;font-weight:700;color:var(--accent)}
[data-theme=dark] .set-plan-name{color:#5EEAD4}
.set-plan-sub{font-size:.78rem;color:var(--gray-600);margin-top:3px}
.set-plan-btn{display:block;width:100%;margin-top:10px;background:var(--primary-fill);color:#fff;
  border:none;border-radius:10px;padding:11px;font:inherit;font-size:.85rem;font-weight:700;cursor:pointer}
.set-plan-btn:active{background:var(--primary-fill-active)}
.set-title{font-size:.68rem;font-weight:700;text-transform:uppercase;letter-spacing:.05em;
  color:var(--gray-600);padding:0 4px 6px}
.set-group{border:1px solid var(--gray-200);border-radius:14px;overflow:hidden;background:var(--gray-50)}
[data-theme=dark] .set-group{background:#1e293b;border-color:#334155}
.set-row{display:flex;align-items:center;gap:10px;width:100%;text-align:left;background:none;
  border:none;border-bottom:1px solid var(--gray-200);padding:13px 14px;font:inherit;
  font-size:.88rem;color:var(--gray-800);cursor:pointer}
[data-theme=dark] .set-row{border-color:#334155}
.set-row:last-child{border-bottom:none}
.set-row:active{background:var(--gray-100)}
.set-row-txt{flex:1;min-width:0;display:block}
.set-lab{display:block;font-weight:600}
.set-sub{display:block;font-size:.72rem;font-weight:400;color:var(--gray-600);margin-top:2px}
.set-val{font-size:.8rem;color:var(--gray-600);flex-shrink:0;margin-left:4px}
/* ⚠️ Le righe con un VALORE a destra hanno bisogno di piu' respiro di quelle con
   la sola freccia: il `gap:10px` di `.set-row` basta a un sottotitolo corto, non a
   uno che riempie la riga. Senza questo, alla prossima frase piu' lunga il testo
   torna a filo del valore. */
.set-row-txt{padding-right:6px}
.set-go{flex-shrink:0;color:var(--gray-400);font-size:.9rem}
.set-row.link .set-lab{text-decoration:underline}
.set-row.danger{color:var(--danger-text);font-weight:600}
.set-row.danger .set-lab{color:var(--danger-text)}

/* Scheda della razza — regole della gemella (.breed-*). */
.breed-screen{padding:16px}
.breed-empty{text-align:center;padding:24px 8px}
.breed-empty-icon{font-size:2.4rem;color:var(--brand-ink)}
.breed-empty h2{font-size:1.05rem;color:var(--gray-800);margin:8px 0 4px}
.breed-empty p{font-size:.84rem;color:var(--gray-600);line-height:1.5;margin-bottom:16px}
.breed-gen-btn{background:var(--primary-fill);color:#fff;border:none;border-radius:12px;
  padding:13px 22px;font-size:.9rem;font-weight:700;cursor:pointer}
.breed-gen-btn:active{background:var(--primary-fill-active)}
.breed-gen-btn:disabled{opacity:.6;cursor:not-allowed}
.breed-doc{background:var(--gray-50);border:1px solid var(--gray-200);border-radius:14px;
  padding:16px;font-size:.88rem;line-height:1.6;color:var(--gray-800);white-space:normal}
.breed-meta{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:.72rem;
  color:var(--gray-600);margin-bottom:10px}
.breed-regen{background:none;border:1px solid var(--gray-200);border-radius:9px;padding:6px 12px;
  font-size:.75rem;font-weight:600;color:var(--gray-700);cursor:pointer}
.breed-regen:disabled{opacity:.5;cursor:not-allowed}
.breed-note{font-size:.72rem;color:var(--gray-600);line-height:1.5;margin-top:12px;
  border-top:1px solid var(--gray-200);padding-top:10px}
.breed-err{font-size:.82rem;color:var(--danger-text);margin-top:10px}


/* ═══ PROMEMORIA (Premium): lista in-app + notifiche native ═══ */
.rem-modal{position:fixed;inset:0;background:rgba(15,23,42,.55);backdrop-filter:blur(2px);display:none;align-items:flex-start;justify-content:center;z-index:1000;padding:6dvh 14px max(14px,env(safe-area-inset-bottom))}
.rem-modal.open{display:flex}
.rem-modal-card{width:100%;max-width:480px;max-height:88dvh;background:#fff;border-radius:18px;box-shadow:0 18px 50px rgba(0,0,0,.35);padding:16px;display:flex;flex-direction:column;gap:12px;animation:imPop .18s ease-out;overflow:hidden}
[data-theme=dark] .rem-modal-card{background:#1e293b;color:#f1f5f9}
.rem-modal-head{display:flex;align-items:center;justify-content:space-between}
.rem-modal-title{font-size:1.05rem;font-weight:700;color:var(--primary-dark)}
[data-theme=dark] .rem-modal-title{color:#2DD4BF}
.rem-close{background:none;border:none;cursor:pointer;font-size:1.1rem;color:var(--gray-400,#94a3b8);padding:4px 8px}
.rem-close:hover{color:var(--gray-700,#334155)}
.rem-native-note{font-size:.78rem;line-height:1.45;padding:9px 12px;border-radius:10px;background:var(--primary-light,#CCFBF1);color:var(--accent,#115E59);border:1px solid var(--primary-border,#99F6E4)}
[data-theme=dark] .rem-native-note{background:#14302B;border-color:#225F56;color:#2DD4BF}
.rem-native-note.off{background:var(--danger-bg,#fef2f2);border-color:var(--danger-border,#fecaca);color:#b91c1c}
[data-theme=dark] .rem-native-note.off{background:#3a1d1d;border-color:#7f1d1d;color:#fca5a5}
.rem-note-btn{margin-top:6px;background:var(--primary-fill);color:#fff;border:none;border-radius:8px;padding:5px 11px;cursor:pointer;font-size:.74rem;font-weight:600;font-family:inherit}
.rem-list{display:flex;flex-direction:column;gap:7px;overflow-y:auto;flex:1;min-height:30px}
.rem-empty{font-size:.84rem;color:var(--gray-400,#94a3b8);text-align:center;padding:14px 4px}
.rem-row{display:flex;align-items:center;gap:10px;background:var(--gray-50,#f8fafc);border:1px solid var(--gray-200,#e2e8f0);border-radius:11px;padding:9px 11px}
[data-theme=dark] .rem-row{background:#0f172a;border-color:#475569}
.rem-row .rem-ic{font-size:1.15rem;flex-shrink:0}
.rem-row .rem-body{flex:1;min-width:0}
.rem-row .rem-label{font-size:.88rem;font-weight:600;color:var(--gray-800,#1e293b)}
[data-theme=dark] .rem-row .rem-label{color:#f1f5f9}
.rem-row .rem-sub{font-size:.76rem;color:var(--gray-500,#64748b);margin-top:1px}
.rem-when{font-size:.7rem;font-weight:600;padding:2px 8px;border-radius:8px;background:var(--gray-200,#e2e8f0);color:var(--gray-600,#475569);white-space:nowrap;flex-shrink:0}
.rem-when.soon{background:var(--primary-light,#CCFBF1);color:var(--accent,#115E59)}
.rem-when.past{background:#fee2e2;color:#b91c1c}
[data-theme=dark] .rem-when{background:#334155;color:#cbd5e1}
[data-theme=dark] .rem-when.soon{background:#225F56;color:#2DD4BF}
[data-theme=dark] .rem-when.past{background:#7f1d1d;color:#fca5a5}
.rem-del{background:none;border:none;cursor:pointer;color:var(--gray-400,#94a3b8);font-size:.95rem;padding:2px 6px;flex-shrink:0}
.rem-del:hover{color:#b91c1c}
.rem-add{display:flex;flex-direction:column;gap:7px;border-top:1px solid var(--gray-100,#f1f5f9);padding-top:11px}
[data-theme=dark] .rem-add{border-color:#334155}
.rem-field{width:100%;border:1.5px solid var(--gray-200,#e2e8f0);border-radius:10px;padding:9px 11px;font-size:.9rem;color:var(--gray-800,#1e293b);background:var(--gray-50,#f8fafc);outline:none;font-family:inherit}
.rem-field:focus{border-color:var(--primary);background:#fff}
[data-theme=dark] .rem-field{background:#0f172a;border-color:#475569;color:#f1f5f9}
.rem-add-row{display:flex;gap:7px}
.rem-add-row .rem-field{flex:1}
.rem-add-btn{background:var(--primary-fill);color:#fff;border:none;border-radius:10px;padding:11px 14px;cursor:pointer;font-size:.92rem;font-weight:600;font-family:inherit}
.rem-add-btn:hover{background:var(--primary-fill-active)}
.rem-err{font-size:.78rem;color:#b91c1c;display:none}
.rem-err.show{display:block}
</style>
</head>
<body>
<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true" focusable="false">
<!-- npi-dog e npi-cat: Lucide (ISC License, © Lucide Icons and Contributors)
     npi-paw: Tabler Icons (MIT License, © 2020-2026 Paweł Kuna)
     Le altre derivano dallo sprite NeosBowel o sono originali.
     npi-cat non è usato qui: è il gemello per NeosCat, tenuto per il trasferimento. -->
<symbol id="npi-arrow-left" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M11 6l-6 6 6 6"/></g></symbol>
<symbol id="npi-arrow-right" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></g></symbol>
<symbol id="npi-x" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6l12 12M18 6L6 18"/></g></symbol>
<symbol id="npi-check" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 12.5l5 5 10-11"/></g></symbol>
<symbol id="npi-warn" viewBox="0 0 24 24"><g fill="currentColor" opacity=".2" stroke="none"><path d="M12 3.5l9.3 16a1 1 0 0 1-.87 1.5H3.57a1 1 0 0 1-.87-1.5z"/></g><g fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3.5l9.3 16a1 1 0 0 1-.87 1.5H3.57a1 1 0 0 1-.87-1.5z"/><path d="M12 9.5v4.5"/><circle cx="12" cy="17.2" r=".4" fill="currentColor" stroke="none"/></g></symbol>
<symbol id="npi-info" viewBox="0 0 24 24"><g fill="currentColor" opacity=".2" stroke="none"><circle cx="12" cy="12" r="9"/></g><g fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 11v5.5"/><circle cx="12" cy="7.8" r=".4" fill="currentColor" stroke="none"/></g></symbol>
<symbol id="npi-bell" viewBox="0 0 24 24"><g fill="currentColor" opacity=".2" stroke="none"><path d="M18 15.5H6c1.2-1.2 1.8-2.4 1.8-4.5V9.2A4.2 4.2 0 0 1 12 5a4.2 4.2 0 0 1 4.2 4.2V11c0 2.1.6 3.3 1.8 4.5z"/></g><g fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M18 15.5H6c1.2-1.2 1.8-2.4 1.8-4.5V9.2A4.2 4.2 0 0 1 12 5a4.2 4.2 0 0 1 4.2 4.2V11c0 2.1.6 3.3 1.8 4.5z"/><path d="M10 18.5a2 2 0 0 0 4 0"/></g></symbol>
<symbol id="npi-lock" viewBox="0 0 24 24"><g fill="currentColor" opacity=".2" stroke="none"><rect x="5.5" y="10.5" width="13" height="9.5" rx="2.5"/></g><g fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="5.5" y="10.5" width="13" height="9.5" rx="2.5"/><path d="M8.5 10.5V8a3.5 3.5 0 0 1 7 0v2.5"/></g></symbol>
<symbol id="npi-settings" viewBox="0 0 24 24"><g fill="currentColor" opacity=".2" stroke="none"><circle cx="12" cy="12" r="3.2"/><path d="M19 12a7 7 0 0 0-.15-1.44l2-1.55-2-3.46-2.37.95a7 7 0 0 0-2.5-1.45L13.6 2.5h-3.2l-.38 2.55a7 7 0 0 0-2.5 1.45l-2.37-.95-2 3.46 2 1.55A7 7 0 0 0 5 12c0 .49.05.97.15 1.44l-2 1.55 2 3.46 2.37-.95a7 7 0 0 0 2.5 1.45l.38 2.55h3.2l.38-2.55a7 7 0 0 0 2.5-1.45l2.37.95 2-3.46-2-1.55c.1-.47.15-.95.15-1.44z"/></g><g fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3.2"/><path d="M19 12a7 7 0 0 0-.15-1.44l2-1.55-2-3.46-2.37.95a7 7 0 0 0-2.5-1.45L13.6 2.5h-3.2l-.38 2.55a7 7 0 0 0-2.5 1.45l-2.37-.95-2 3.46 2 1.55A7 7 0 0 0 5 12c0 .49.05.97.15 1.44l-2 1.55 2 3.46 2.37-.95a7 7 0 0 0 2.5 1.45l.38 2.55h3.2l.38-2.55a7 7 0 0 0 2.5-1.45l2.37.95 2-3.46-2-1.55c.1-.47.15-.95.15-1.44z"/></g></symbol>
<symbol id="npi-chat" viewBox="0 0 24 24"><g fill="currentColor" opacity=".2" stroke="none"><path d="M21 12a8 8 0 0 1-8 8H5l-2 2V12a8 8 0 0 1 8-8h2a8 8 0 0 1 8 8z"/></g><g fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a8 8 0 0 1-8 8H5l-2 2V12a8 8 0 0 1 8-8h2a8 8 0 0 1 8 8z"/></g></symbol>
<symbol id="npi-user" viewBox="0 0 24 24"><g fill="currentColor" opacity=".2" stroke="none"><circle cx="12" cy="8" r="3.6"/></g><g fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="3.6"/><path d="M5 20.5c1.2-3.4 3.8-5 7-5s5.8 1.6 7 5"/></g></symbol>
<symbol id="npi-food" viewBox="0 0 24 24"><g fill="currentColor" opacity=".2" stroke="none"><path d="M12 8c-.5-2.5-2.2-4-4.6-4C4.8 4 3 6.2 3 9.2 3 14 8 19.5 12 20.5 16 19.5 21 14 21 9.2 21 6.2 19.2 4 16.6 4 14.2 4 12.5 5.5 12 8z"/></g><g fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8c-.5-2.5-2.2-4-4.6-4C4.8 4 3 6.2 3 9.2 3 14 8 19.5 12 20.5 16 19.5 21 14 21 9.2 21 6.2 19.2 4 16.6 4 14.2 4 12.5 5.5 12 8z"/><path d="M13.5 4.5c.6-1.1 1.6-1.9 3-2"/></g></symbol>
<symbol id="npi-activity" viewBox="0 0 24 24"><g fill="currentColor" opacity=".2" stroke="none"><path d="M20.8 12.7a5.6 5.6 0 0 0-8-7.9L12 5.6l-.8-.8a5.6 5.6 0 0 0-8 7.9L12 21z"/></g><g fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M20.8 12.7a5.6 5.6 0 0 0-8-7.9L12 5.6l-.8-.8a5.6 5.6 0 0 0-8 7.9L12 21z"/><path d="M7 12h3l1.5-3 2 5L15 12h2"/></g></symbol>
<symbol id="npi-refresh" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 8.5A8 8 0 0 1 19 9.5M19.5 15.5A8 8 0 0 1 5 14.5"/><path d="M19 4.5v5h-5M5 19.5v-5h5"/></g></symbol>
<symbol id="npi-speaker" viewBox="0 0 24 24"><g fill="currentColor" opacity=".2" stroke="none"><path d="M11 5L6 9H3v6h3l5 4z"/></g><g fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5L6 9H3v6h3l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/></g></symbol>
<symbol id="npi-brain" viewBox="0 0 24 24"><g fill="currentColor" opacity=".2" stroke="none"><path d="M9.5 4A3.5 3.5 0 0 0 6 7.5c-2 .6-3 2-3 4 0 1.6.8 2.9 2 3.6-.2 2.5 1.4 4.4 4.5 4.4 1 0 1.8-.3 2.5-.9V5.2C11.4 4.4 10.5 4 9.5 4z"/><path d="M14.5 4A3.5 3.5 0 0 1 18 7.5c2 .6 3 2 3 4 0 1.6-.8 2.9-2 3.6.2 2.5-1.4 4.4-4.5 4.4-1 0-1.8-.3-2.5-.9V5.2c.6-.8 1.5-1.2 2.5-1.2z"/></g><g fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9.5 4A3.5 3.5 0 0 0 6 7.5c-2 .6-3 2-3 4 0 1.6.8 2.9 2 3.6-.2 2.5 1.4 4.4 4.5 4.4 1 0 1.8-.3 2.5-.9V5.2C11.4 4.4 10.5 4 9.5 4z"/><path d="M14.5 4A3.5 3.5 0 0 1 18 7.5c2 .6 3 2 3 4 0 1.6-.8 2.9-2 3.6.2 2.5-1.4 4.4-4.5 4.4-1 0-1.8-.3-2.5-.9V5.2c.6-.8 1.5-1.2 2.5-1.2z"/></g></symbol>
<symbol id="npi-stethoscope" viewBox="0 0 24 24"><g fill="currentColor" opacity=".2" stroke="none"><circle cx="19" cy="11.5" r="2.2"/></g><g fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3.5v5a5 5 0 0 0 10 0v-5"/><path d="M10 13.5V16a4.5 4.5 0 0 0 9 0v-2.2"/><circle cx="19" cy="11.5" r="2.2"/></g></symbol>
<symbol id="npi-lab" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3h6M10 3v6.2L4.8 18a2.4 2.4 0 0 0 2.1 3.6h10.2a2.4 2.4 0 0 0 2.1-3.6L14 9.2V3"/><path d="M7.5 15h9"/></g></symbol>
<symbol id="npi-pill" viewBox="0 0 24 24"><g fill="currentColor" opacity=".2" stroke="none"><rect x="3.5" y="8.5" width="17" height="7" rx="3.5" transform="rotate(-40 12 12)"/></g><g fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="8.5" width="17" height="7" rx="3.5" transform="rotate(-40 12 12)"/><path d="M8.6 8.1l6.8 7.8"/></g></symbol>
<symbol id="npi-x-circle" viewBox="0 0 24 24"><g fill="currentColor" opacity=".2" stroke="none"><circle cx="12" cy="12" r="9"/></g><g fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M9 9l6 6M15 9l-6 6"/></g></symbol>
<symbol id="npi-dog" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M11.25 16.25h1.5L12 17z" /> <path d="M16 14v.5" /> <path d="M4.42 11.247A13.152 13.152 0 0 0 4 14.556C4 18.728 7.582 21 12 21s8-2.272 8-6.444a11.702 11.702 0 0 0-.493-3.309" /> <path d="M8 14v.5" /> <path d="M8.5 8.5c-.384 1.05-1.083 2.028-2.344 2.5-1.931.722-3.576-.297-3.656-1-.113-.994 1.177-6.53 4-7 1.923-.321 3.651.845 3.651 2.235A7.497 7.497 0 0 1 14 5.277c0-1.39 1.844-2.598 3.767-2.277 2.823.47 4.113 6.006 4 7-.08.703-1.725 1.722-3.656 1-1.261-.472-1.855-1.45-2.239-2.5" /></g></symbol>
<symbol id="npi-cat" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5c.67 0 1.35.09 2 .26 1.78-2 5.03-2.84 6.42-2.26 1.4.58-.42 7-.42 7 .57 1.07 1 2.24 1 3.44C21 17.9 16.97 21 12 21s-9-3-9-7.56c0-1.25.5-2.4 1-3.44 0 0-1.89-6.42-.5-7 1.39-.58 4.72.23 6.5 2.23A9.04 9.04 0 0 1 12 5Z" /> <path d="M8 14v.5" /> <path d="M16 14v.5" /> <path d="M11.25 16.25h1.5L12 17l-.75-.75Z" /></g></symbol>
<symbol id="npi-paw" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 13.5c-1.1 -2 -1.441 -2.5 -2.7 -2.5c-1.259 0 -1.736 .755 -2.836 2.747c-.942 1.703 -2.846 1.845 -3.321 3.291c-.097 .265 -.145 .677 -.143 .962c0 1.176 .787 2 1.8 2c1.259 0 3 -1 4.5 -1s3.241 1 4.5 1c1.013 0 1.8 -.823 1.8 -2c0 -.285 -.049 -.697 -.146 -.962c-.475 -1.451 -2.512 -1.835 -3.454 -3.538" /> <path d="M20.188 8.082a1.039 1.039 0 0 0 -.406 -.082h-.015c-.735 .012 -1.56 .75 -1.993 1.866c-.519 1.335 -.28 2.7 .538 3.052c.129 .055 .267 .082 .406 .082c.739 0 1.575 -.742 2.011 -1.866c.516 -1.335 .273 -2.7 -.54 -3.052l-.001 0" /> <path d="M9.474 9c.055 0 .109 0 .163 -.011c.944 -.128 1.533 -1.346 1.32 -2.722c-.203 -1.297 -1.047 -2.267 -1.932 -2.267c-.055 0 -.109 0 -.163 .011c-.944 .128 -1.533 1.346 -1.32 2.722c.204 1.293 1.048 2.267 1.933 2.267" /> <path d="M16.456 6.733c.214 -1.376 -.375 -2.594 -1.32 -2.722a1.164 1.164 0 0 0 -.162 -.011c-.885 0 -1.728 .97 -1.93 2.267c-.214 1.376 .375 2.594 1.32 2.722c.054 .007 .108 .011 .162 .011c.885 0 1.73 -.974 1.93 -2.267" /> <path d="M5.69 12.918c.816 -.352 1.054 -1.719 .536 -3.052c-.436 -1.124 -1.271 -1.866 -2.009 -1.866c-.14 0 -.277 .027 -.407 .082c-.816 .352 -1.054 1.719 -.536 3.052c.436 1.124 1.271 1.866 2.009 1.866c.14 0 .277 -.027 .407 -.082" /></g></symbol>
<symbol id="npi-key" viewBox="0 0 24 24"><g fill="currentColor" opacity=".2" stroke="none"><circle cx="8" cy="8" r="4.2"/></g><g fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="4.2"/><path d="M11 11l8 8M16.5 15.5l-2 2M19 13l-2 2"/></g></symbol>
<symbol id="npi-mic" viewBox="0 0 24 24"><g fill="currentColor" opacity=".2" stroke="none"><rect x="9.2" y="2.8" width="5.6" height="10.4" rx="2.8"/></g><g fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="9.2" y="2.8" width="5.6" height="10.4" rx="2.8"/><path d="M5.6 11.4a6.4 6.4 0 0 0 12.8 0M12 17.8v3.4"/></g></symbol>
<symbol id="npi-stop" viewBox="0 0 24 24"><g fill="currentColor" opacity=".2" stroke="none"><rect x="6" y="6" width="12" height="12" rx="2.4"/></g><g fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="6" width="12" height="12" rx="2.4"/></g></symbol>
<symbol id="npi-trash" viewBox="0 0 24 24"><g fill="currentColor" opacity=".2" stroke="none"><path d="M6 7h12l-1 12.2a1.4 1.4 0 0 1-1.4 1.3H8.4A1.4 1.4 0 0 1 7 19.2z"/></g><g fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 7h15M6 7l1 12.2a1.4 1.4 0 0 0 1.4 1.3h7.2a1.4 1.4 0 0 0 1.4-1.3L18 7"/><path d="M9.5 7V4.9c0-.6.5-1.1 1.1-1.1h2.8c.6 0 1.1.5 1.1 1.1V7"/><path d="M10.4 11v5.6M13.6 11v5.6"/></g></symbol>
<symbol id="npi-pencil" viewBox="0 0 24 24"><g fill="currentColor" opacity=".2" stroke="none"><path d="M14.9 4.9 19.1 9 9.6 18.5l-5 .9.9-5z"/></g><g fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14.9 4.9 19.1 9 9.6 18.5l-5 .9.9-5z"/><path d="M13.2 6.6 17.4 10.8"/></g></symbol>
<symbol id="npi-camera" viewBox="0 0 24 24"><g fill="currentColor" opacity=".2" stroke="none"><rect x="2.8" y="7" width="18.4" height="13" rx="2.6"/></g><g fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M2.8 9.6A2.6 2.6 0 0 1 5.4 7h1.9l1.5-2.4h6.4L16.7 7h1.9A2.6 2.6 0 0 1 21.2 9.6v7.8A2.6 2.6 0 0 1 18.6 20H5.4a2.6 2.6 0 0 1-2.6-2.6z"/><circle cx="12" cy="13.4" r="3.6"/></g></symbol>
<symbol id="npi-plus" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></g></symbol>
<symbol id="npi-star" viewBox="0 0 24 24"><g fill="currentColor" opacity=".2" stroke="none"><path d="m12 3.4 2.7 5.5 6 .9-4.35 4.25 1.03 6-5.38-2.83-5.38 2.83 1.03-6L3.3 9.8l6-.9z"/></g><g fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m12 3.4 2.7 5.5 6 .9-4.35 4.25 1.03 6-5.38-2.83-5.38 2.83 1.03-6L3.3 9.8l6-.9z"/></g></symbol>
<symbol id="npi-clipboard" viewBox="0 0 24 24"><g fill="currentColor" opacity=".2" stroke="none"><rect x="4.6" y="4.4" width="14.8" height="16.2" rx="2.2"/></g><g fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9.2 4.4H6.8a2.2 2.2 0 0 0-2.2 2.2v11.8a2.2 2.2 0 0 0 2.2 2.2h10.4a2.2 2.2 0 0 0 2.2-2.2V6.6a2.2 2.2 0 0 0-2.2-2.2h-2.4"/><rect x="9.2" y="2.6" width="5.6" height="3.6" rx="1.2"/><path d="M8.4 11.2h7.2M8.4 15h4.8"/></g></symbol>
<symbol id="npi-syringe" viewBox="0 0 24 24"><g fill="currentColor" opacity=".2" stroke="none"><path d="m12.3 6.9 4.8 4.8-6.1 6.1-4.8-4.8z"/></g><g fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m12.3 6.9 4.8 4.8-6.1 6.1-4.8-4.8z"/><path d="M14.6 4.6 19.4 9.4M17 2.9l4.1 4.1M6.2 13l-3.3 3.3v4.8h4.8L11 17.8"/><path d="M9.6 9.6l4.8 4.8"/></g></symbol>
<symbol id="npi-speaker-off" viewBox="0 0 24 24"><g fill="currentColor" opacity=".2" stroke="none"><path d="M11 5 6 9H3v6h3l5 4z"/></g><g fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H3v6h3l5 4z"/><path d="M16.5 9.5 21 14M21 9.5 16.5 14"/></g></symbol>
<symbol id="npi-bell-off" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M8.2 6.1A4.2 4.2 0 0 1 16.2 9.2V11c0 1.5.3 2.6.9 3.5"/><path d="M6.4 9.9V11c0 2.1-.6 3.3-1.8 4.5h10.2"/><path d="M10 18.5a2 2 0 0 0 4 0"/><path d="M3.5 3.5l17 17"/></g></symbol>
<symbol id="npi-bug" viewBox="0 0 24 24"><g fill="currentColor" opacity=".2" stroke="none"><path d="M7.6 10.5a4.4 4.4 0 0 1 8.8 0v3.9a4.4 4.4 0 0 1-8.8 0z"/></g><g fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M7.6 10.5a4.4 4.4 0 0 1 8.8 0v3.9a4.4 4.4 0 0 1-8.8 0z"/><path d="M9.2 7.4a3 3 0 0 1 5.6 0"/><path d="M7.6 12H4M20 12h-3.6M7.9 8.4 5.2 6.3M18.8 6.3l-2.7 2.1M7.9 16.4l-2.7 2.1M18.8 18.5l-2.7-2.1"/></g></symbol>
<symbol id="npi-pin" viewBox="0 0 24 24"><g fill="currentColor" opacity=".2" stroke="none"><path d="M12 20.5s6.4-6 6.4-10.4a6.4 6.4 0 0 0-12.8 0C5.6 14.5 12 20.5 12 20.5z"/></g><g fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20.5s6.4-6 6.4-10.4a6.4 6.4 0 0 0-12.8 0C5.6 14.5 12 20.5 12 20.5z"/><circle cx="12" cy="10" r="2.4"/></g></symbol>
<symbol id="npi-tooth" viewBox="0 0 24 24"><g fill="currentColor" opacity=".2" stroke="none"><path d="M12 4.2c1.6 0 2.2.8 4 .8 2.2 0 3.4 1.6 3.4 4 0 3-1 4.6-1.6 7.6-.4 2-1 3.6-2.2 3.6-1.4 0-1.5-2-2-4.4-.3-1.4-.6-2.2-1.6-2.2s-1.3.8-1.6 2.2c-.5 2.4-.6 4.4-2 4.4-1.2 0-1.8-1.6-2.2-3.6C5.6 13.6 4.6 12 4.6 9c0-2.4 1.2-4 3.4-4 1.8 0 2.4-.8 4-.8z"/></g><g fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4.2c1.6 0 2.2.8 4 .8 2.2 0 3.4 1.6 3.4 4 0 3-1 4.6-1.6 7.6-.4 2-1 3.6-2.2 3.6-1.4 0-1.5-2-2-4.4-.3-1.4-.6-2.2-1.6-2.2s-1.3.8-1.6 2.2c-.5 2.4-.6 4.4-2 4.4-1.2 0-1.8-1.6-2.2-3.6C5.6 13.6 4.6 12 4.6 9c0-2.4 1.2-4 3.4-4 1.8 0 2.4-.8 4-.8z"/></g></symbol>
<symbol id="npi-bath" viewBox="0 0 24 24"><g fill="currentColor" opacity=".2" stroke="none"><path d="M3.4 11.6h17.2v2.8a4.6 4.6 0 0 1-4.6 4.6H8a4.6 4.6 0 0 1-4.6-4.6z"/></g><g fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3.4 11.6h17.2v2.8a4.6 4.6 0 0 1-4.6 4.6H8a4.6 4.6 0 0 1-4.6-4.6z"/><path d="M6 11.6V5.4A2 2 0 0 1 8 3.4c1 0 1.7.7 1.9 1.6"/><path d="M6.4 19v1.6M17.6 19v1.6"/></g></symbol>

<symbol id="npi-moon" viewBox="0 0 24 24"><g fill="currentColor" opacity=".2" stroke="none"><path d="M20 14.5A8.5 8.5 0 0 1 9.5 4 8.5 8.5 0 1 0 20 14.5z"/></g><g fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M20 14.5A8.5 8.5 0 0 1 9.5 4 8.5 8.5 0 1 0 20 14.5z"/></g></symbol>
<symbol id="npi-sun" viewBox="0 0 24 24"><g fill="currentColor" opacity=".2" stroke="none"><circle cx="12" cy="12" r="4.2"/></g><g fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4.2"/><path d="M12 2.6v2.3M12 19.1v2.3M4.2 4.2l1.6 1.6M18.2 18.2l1.6 1.6M2.6 12h2.3M19.1 12h2.3M4.2 19.8l1.6-1.6M18.2 5.8l1.6-1.6"/></g></symbol>
<symbol id="npi-play" viewBox="0 0 24 24"><g fill="currentColor" opacity=".2" stroke="none"><path d="M8.4 5.6 18.6 12 8.4 18.4z"/></g><g fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M8.4 5.6 18.6 12 8.4 18.4z"/></g></symbol>
</svg>

<!-- ═══ TEMPLATE SVG DEL GATTINO FÉLICETTE ═══ -->
<template id="dog-template">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" fill="#0D9488"/>
<g class="cat-body">
  <g class="cat-ear-left">
    <path d="M140,200 C120,158 110,122 116,98 Q120,86 134,96 C165,118 210,150 236,176 Z" fill="#ffffff"/>
    <path d="M152,192 C136,160 130,132 134,114 Q138,106 148,114 C168,132 198,152 218,174 Z" fill="#CCFBF1"/>
  </g>
  <g class="cat-ear-right">
    <path d="M372,200 C392,158 402,122 396,98 Q392,86 378,96 C347,118 302,150 276,176 Z" fill="#ffffff"/>
    <path d="M360,192 C376,160 382,132 378,114 Q374,106 364,114 C344,132 314,152 294,174 Z" fill="#CCFBF1"/>
  </g>
  <circle cx="256" cy="285" r="132" fill="#ffffff"/>
  <circle cx="198" cy="322" r="17" fill="#F0997B" opacity="0.35"/>
  <circle cx="314" cy="322" r="17" fill="#F0997B" opacity="0.35"/>
  <ellipse cx="212" cy="286" rx="32" ry="37" fill="#04342C"/>
  <ellipse cx="300" cy="286" rx="32" ry="37" fill="#04342C"/>
  <g class="cat-eye-highlight">
    <circle cx="203" cy="276" r="10" fill="#fff"/><circle cx="291" cy="276" r="10" fill="#fff"/>
    <circle cx="220" cy="298" r="5" fill="#fff"/><circle cx="308" cy="298" r="5" fill="#fff"/>
  </g>
  <ellipse class="cat-eyelid" cx="212" cy="286" rx="34" ry="39" fill="#ffffff"/>
  <ellipse class="cat-eyelid right" cx="300" cy="286" rx="34" ry="39" fill="#ffffff"/>
  <path d="M240,324 Q256,319 272,324 Q274,336 256,348 Q238,336 240,324 Z" fill="#F0997B"/>
  <g class="cat-mouth-closed">
    <path d="M256,348 Q256,366 234,368" fill="none" stroke="#085041" stroke-width="5" stroke-linecap="round"/>
    <path d="M256,348 Q256,366 278,368" fill="none" stroke="#085041" stroke-width="5" stroke-linecap="round"/>
  </g>
  <ellipse class="cat-mouth-open" cx="256" cy="360" rx="11" ry="8" fill="#085041"/>
  <g class="cat-whiskers-l">
    <path d="M196,330 L98,316" stroke="#fff" stroke-width="4" stroke-linecap="round"/>
    <path d="M194,340 L92,342" stroke="#fff" stroke-width="4" stroke-linecap="round"/>
    <path d="M196,350 L100,366" stroke="#fff" stroke-width="4" stroke-linecap="round"/>
  </g>
  <g class="cat-whiskers-r">
    <path d="M316,330 L414,316" stroke="#fff" stroke-width="4" stroke-linecap="round"/>
    <path d="M318,340 L420,342" stroke="#fff" stroke-width="4" stroke-linecap="round"/>
    <path d="M316,350 L412,366" stroke="#fff" stroke-width="4" stroke-linecap="round"/>
  </g>
</g>
</svg>
</template>

<div class="app" id="app">

<!-- ═══ SHELL PERSISTENTE (§10): header, disclaimer, banner ed footer
     vivono FUORI dalle schermate. Il disclaimer, prima dentro la strip
     dell'avatar e quindi solo in chat, ora e' visibile anche durante
     l'onboarding - cioe' proprio dove si inseriscono patologie,
     farmaci e vaccinazioni. ═══ -->
<div class="header" id="app-header"></div>

<div class="emergency-banner" id="emergency-banner">
    🚨 <strong>EMERGENZA VETERINARIA</strong> — Contatta subito la
    <a href="https://www.google.com/maps/search/clinica+veterinaria+emergenza" target="_blank">clinica veterinaria più vicina</a>
  </div>

<main class="screens" id="screens">

  <!-- ═══ BENVENUTO — mostra cosa fa l'app PRIMA di chiedere dati ═══
       ⚠️ Le voci sono TESTO, non bottoni: zero campi, zero navigazione,
       un solo comando. Si vede una volta sola (seen_welcome). -->
  <section class="screen welcome-screen hidden" id="screen-welcome">
    <div class="ws-logo" onclick="devTocco(event)"><svg class="npi npi-xl" aria-hidden="true" focusable="false"><use href="#npi-cat"/></svg></div>
    <div class="ws-title">Benvenuto in NeosCat AI</div>
    <div class="ws-sub">Informazioni sulla salute del tuo gatto, calibrate sul suo profilo.</div>
    <div class="ws-list">
      <div class="ws-item" data-fn="chat">
        <span class="ws-ico"><svg class="npi" aria-hidden="true" focusable="false"><use href="#npi-chat"/></svg></span>
        <span class="ws-item-txt"><span class="ws-item-lab">Chiedi a Félicette</span>
        <span class="ws-item-desc">Alimentazione, vaccinazioni, comportamento, segnali da non ignorare — con le risposte riferite al tuo gatto, non a un gatto generico.</span></span>
      </div>
      <div class="ws-item" data-fn="razza">
        <span class="ws-ico"><svg class="npi" aria-hidden="true" focusable="false"><use href="#npi-clipboard"/></svg></span>
        <span class="ws-item-txt"><span class="ws-item-lab">Scheda della razza</span>
        <span class="ws-item-desc">Predisposizioni di salute, alimentazione, attività fisica e aspettativa di vita della razza.</span></span>
      </div>
      <div class="ws-item" data-fn="promemoria">
        <span class="ws-ico"><svg class="npi" aria-hidden="true" focusable="false"><use href="#npi-bell"/></svg></span>
        <span class="ws-item-txt"><span class="ws-item-lab">Promemoria salute</span>
        <span class="ws-item-desc">Richiami vaccinali, antiparassitari e visite, senza doverli ricordare a memoria.</span></span>
      </div>
      <div class="ws-item" data-fn="gioco">
        <span class="ws-ico"><svg class="npi" aria-hidden="true" focusable="false"><use href="#npi-play"/></svg></span>
        <span class="ws-item-txt"><span class="ws-item-lab">Cosa dice il tuo gatto?</span>
        <span class="ws-item-desc">Un gioco: registri un miagolio e scopri la «traduzione». Solo per divertimento, mai una diagnosi.</span></span>
      </div>
      <div class="ws-item" data-fn="multigatto">
        <span class="ws-ico"><svg class="npi" aria-hidden="true" focusable="false"><use href="#npi-paw"/></svg></span>
        <span class="ws-item-txt"><span class="ws-item-lab">Più gatti, profili distinti</span>
        <span class="ws-item-desc">Ogni gatto ha i suoi dati e la sua cronologia: le risposte non si mescolano.</span></span>
      </div>
    </div>
    <button class="ws-cta" onclick="chiudiBenvenuto()">Iniziamo — crea il profilo del tuo gatto</button>
    <div class="ws-foot">Bastano due minuti. Alcune funzioni richiedono l'abbonamento Premium.<br>
      NeosCat AI è un assistente informativo e <strong>non sostituisce</strong> il medico veterinario.</div>
  </section>

  <!-- ═══ HOME — il menu che apre l'app ═══ -->
  <section class="screen home-screen hidden" id="screen-home">
    <div class="home-head" id="home-head"></div>
    <div id="home-vetrina"></div>
    <div class="home-list" id="home-list"></div>
  </section>

  <!-- ═══ COSA DICE …? — mini-gioco audio, tutto sul dispositivo ═══
       ⚠️ Nessuna frase puo' indicare stress, dolore o patologia: sarebbe
       un atto diagnostico (art. 348 C.P.). Le traduzioni sono solo allegre. -->
  <section class="screen fun-screen hidden" id="screen-fun">
    <span class="fun-tag"><svg class="npi" aria-hidden="true" focusable="false"><use href="#npi-play"/></svg> Solo per divertimento</span>
    <div id="fun-body"></div>
    <p class="fun-note">Non è un'analisi del benessere né una diagnosi: è un gioco.
    Se noti versi insoliti e ripetuti, o sei preoccupato per il tuo gatto, sentine il veterinario.<br>
    L'audio resta sul telefono: viene ascoltato al volo e non è né salvato né inviato da nessuna parte.</p>
  </section>

  <!-- ═══ SCHEDA DELLA RAZZA ═══
       Nel modello e' una schermata dedicata: dice SUBITO cosa si ottiene e
       offre un solo comando. Cosi' il badge PREMIUM della Home non e' una
       promessa fatta e ritirata un tocco dopo (§0.10). -->
  <section class="screen breed-screen hidden" id="screen-breed">
    <div id="breed-body"></div>
  </section>

  <!-- ═══ I MIEI GATTI — la tendina promossa a schermata (§0.3) ═══ -->
  <section class="screen pets-screen hidden" id="screen-pets">
    <div class="pets-list" id="pets-list"></div>
    <button class="pet-add" onclick="addDog()"><svg class="npi" aria-hidden="true"
      focusable="false"><use href="#npi-plus"/></svg> Aggiungi un gatto</button>
    <div class="pets-note" id="pets-note"></div>
    <input type="file" id="pet-photo-input" accept="image/*" hidden>
  </section>

  <!-- ═══ IMPOSTAZIONI E ABBONAMENTO ═══ -->
  <section class="screen settings-screen hidden" id="screen-settings">
    <div id="settings-body"></div>
  </section>


  <section class="screen onboarding-screen hidden" id="screen-onboarding">
  <div class="onboarding-header">
    <div class="onboarding-logo" id="onboarding-logo" onclick="devTocco(event)"><svg class="npi npi-xl" aria-hidden="true" focusable="false"><use href="#npi-cat"/></svg></div>
    <div class="onboarding-title">Benvenuto in NeosCat AI</div>
    <div class="onboarding-subtitle">Crea il profilo del tuo gatto in 3 semplici passi</div>
  </div>
  <div class="onboarding-progress">
    <div class="progress-dot active" id="dot-1"></div>
    <div class="progress-dot" id="dot-2"></div>
    <div class="progress-dot" id="dot-3"></div>
  </div>

  <!-- STEP 1: Dati proprietario -->
  <div class="onboarding-step visible" id="step-1">
    <h3><svg class="npi" aria-hidden="true" focusable="false"><use href="#npi-user"/></svg> I tuoi dati</h3>
    <label class="onboarding-label">Nome</label>
    <input class="onboarding-field" id="owner-name" type="text" placeholder="Il tuo nome">
    <label class="onboarding-label">Email</label>
    <input class="onboarding-field" id="owner-email" type="email" placeholder="email@esempio.it">
    <div class="consent-row">
      <input type="checkbox" id="consent-age">
      <label for="consent-age">Confermo di avere almeno <strong>14 anni</strong> (obbligo Legge IA 132/2025)</label>
    </div>
    <div class="consent-row">
      <input type="checkbox" id="consent-privacy">
      <label for="consent-privacy">Accetto la <a href="privacy.html" target="_blank">Privacy Policy</a> e i <a href="terms.html" target="_blank">Termini di Servizio</a></label>
    </div>
  </div>

  <!-- STEP 2: Profilo gatto -->
  <div class="onboarding-step" id="step-2">
    <h3><svg class="npi" aria-hidden="true" focusable="false"><use href="#npi-cat"/></svg> Il tuo gatto</h3>
    <label class="onboarding-label">Nome del gatto</label>
    <input class="onboarding-field" id="dog-name" type="text" placeholder="Es. Micio">
    <button type="button" class="pron-toggle" id="pron-toggle" onclick="togglePronBox()">La pronuncia non è giusta?</button>
    <div class="pron-box" id="pron-box">
      <div class="pron-hint">Se Félicette sbaglia l'accento, riscrivi qui il nome <b>come va pronunciato</b>.
      Il modo che funziona meglio è <b>separare le sillabe con uno spazio</b>: <b>Atti la</b>,
      <b>Ni ki ta</b>. In alternativa prova l'accento (<b>Àttila</b>), ma non tutti i telefoni
      lo rispettano.<br>Tocca <b>Prova</b> per sentire il risultato: sullo schermo resta sempre
      il nome che hai scritto sopra.</div>
      <div class="pron-row">
        <input class="onboarding-field" id="dog-name-pron" type="text" placeholder="Es. Atti la" autocomplete="off">
        <button type="button" class="pron-try" onclick="tryPronunciation()" aria-label="Ascolta la pronuncia">Prova</button>
      </div>
    </div>
    <label class="onboarding-label">Razza</label>
    <select class="onboarding-field" id="dog-breed">
      <option value="">Seleziona razza...</option>
      <option>Europeo (Soriano)</option>
      <option>Meticcio</option>
      <option>Persiano</option>
      <option>Maine Coon</option>
      <option>Siamese</option>
      <option>British Shorthair</option>
      <option>Certosino</option>
      <option>Sacro di Birmania</option>
      <option>Ragdoll</option>
      <option>Norvegese delle Foreste</option>
      <option>Siberiano</option>
      <option>Bengala</option>
      <option>Sphynx</option>
      <option>Scottish Fold</option>
      <option>Abissino</option>
      <option>Blu di Russia</option>
      <option>Exotic Shorthair</option>
      <option>Devon Rex</option>
      <option>Cornish Rex</option>
      <option>Orientale a pelo corto</option>
      <option>Burmese</option>
      <option>Bombay</option>
      <option>Ragamuffin</option>
      <option>Turco Van</option>
      <option>Turco d'Angora</option>
      <option>Somalo</option>
      <option>American Shorthair</option>
      <option>Manx</option>
      <option>Tonkinese</option>
      <option>Altra razza (specifica)</option>
    </select>
    <input class="onboarding-field" id="dog-breed-other" type="text" placeholder="Specifica razza" style="display:none">
    <div class="onboarding-row">
      <div>
        <label class="onboarding-label">Età (anni)</label>
        <input class="onboarding-field" id="dog-age-years" type="number" min="0" max="25" placeholder="0">
      </div>
      <div>
        <label class="onboarding-label">Mesi</label>
        <input class="onboarding-field" id="dog-age-months" type="number" min="0" max="11" placeholder="0">
      </div>
      <div>
        <label class="onboarding-label">Peso (kg)</label>
        <input class="onboarding-field" id="dog-weight" type="number" min="0.1" max="100" step="0.1" placeholder="es. 4">
      </div>
    </div>
    <label class="onboarding-label">Sesso</label>
    <div class="radio-group">
      <div class="radio-option" data-field="dog-sex" data-value="M">♂ Maschio</div>
      <div class="radio-option" data-field="dog-sex" data-value="F">♀ Femmina</div>
    </div>
    <label class="onboarding-label" style="margin-top:6px">Sterilizzato/castrato</label>
    <div class="radio-group">
      <div class="radio-option" data-field="dog-neutered" data-value="Sì">Sì</div>
      <div class="radio-option" data-field="dog-neutered" data-value="No">No</div>
    </div>
  </div>

  <!-- STEP 3: Salute e alimentazione -->
  <div class="onboarding-step" id="step-3">
    <h3><svg class="npi" aria-hidden="true" focusable="false"><use href="#npi-stethoscope"/></svg> Salute e alimentazione</h3>
    <label class="onboarding-label">Patologie note (puoi selezionarne più di una)</label>
    <div class="checklist" id="conditions-list">
      <label><input type="checkbox" value="Nessuna patologia nota" data-exclusive="true"> Nessuna patologia nota</label>
      <label><input type="checkbox" value="Insufficienza renale cronica"> Insufficienza renale cronica</label>
      <label><input type="checkbox" value="Ipertiroidismo"> Ipertiroidismo</label>
      <label><input type="checkbox" value="Diabete"> Diabete</label>
      <label><input type="checkbox" value="Cardiopatia (es. HCM)"> Cardiopatia (es. HCM)</label>
      <label><input type="checkbox" value="Problemi urinari (FLUTD/cistite)"> Problemi urinari (FLUTD/cistite)</label>
      <label><input type="checkbox" value="Asma felina"> Asma felina</label>
      <label><input type="checkbox" value="Allergie / dermatiti"> Allergie / dermatiti</label>
      <label><input type="checkbox" value="Problemi dentali / gengivite"> Problemi dentali / gengivite</label>
      <label><input type="checkbox" value="Artrosi"> Artrosi</label>
    </div>
    <input class="onboarding-field" id="conditions-other" type="text" placeholder="Altre patologie (opzionale)">
    <label class="onboarding-label">Farmaci in corso (opzionale)</label>
    <input class="onboarding-field" id="dog-meds" type="text" placeholder="Es. nome farmaco">
    <label class="onboarding-label">Vaccinazioni effettuate</label>
    <div class="checklist" id="vaccines-list">
      <label><input type="checkbox" value="Trivalente (FVRCP)"> Trivalente (FVRCP)</label>
      <label><input type="checkbox" value="FeLV (leucemia felina)"> FeLV (leucemia felina)</label>
      <label><input type="checkbox" value="Rabbia"> Rabbia</label>
      <label><input type="checkbox" value="Non so / da verificare"> Non so / da verificare</label>
    </div>
    <label class="onboarding-label">Tipo di alimentazione</label>
    <select class="onboarding-field" id="dog-diet">
      <option value="">Seleziona...</option>
      <option>Crocchette</option><option>Umido</option><option>BARF</option><option>Casalinga</option><option>Mista</option>
    </select>
    <label class="onboarding-label">Livello di attività</label>
    <select class="onboarding-field" id="dog-activity">
      <option value="">Seleziona...</option>
      <option>Sedentario</option><option>Moderato</option><option>Attivo</option><option>Molto attivo</option>
    </select>

    <div class="clinical-ext" id="clinical-ext">
      <div class="clinical-ext-head" onclick="clinicalUpgrade()">
        <span><svg class="npi" aria-hidden="true" focusable="false"><use href="#npi-lab"/></svg> Profilo clinico esteso</span>
        <span class="clinical-badge" id="clinical-badge">PREMIUM</span>
      </div>
      <div class="clinical-locked-msg" id="clinical-locked-msg" onclick="clinicalUpgrade()">
        Disponibile con NeosCat Premium: aggiungi allergie, storia clinica e andamento del peso, così le risposte tengono conto del quadro completo del tuo gatto.
      </div>
      <div class="clinical-fields" id="clinical-fields">
        <label class="onboarding-label">Allergie note (opzionale)</label>
        <input class="onboarding-field" id="dog-allergies" type="text" placeholder="Es. pollo, pollini, puntura di insetti">
        <label class="onboarding-label">Storia clinica / note (opzionale)</label>
        <textarea class="onboarding-field" id="dog-clinical-notes" rows="3" placeholder="Es. operata di cisti nel 2023; otiti ricorrenti in estate"></textarea>
        <label class="onboarding-label">Andamento del peso (opzionale)</label>
        <div class="weight-log" id="weight-log-list"></div>
        <div class="weight-log-add">
          <input class="onboarding-field" id="wl-date" type="date">
          <input class="onboarding-field" id="wl-kg" type="number" min="0.1" max="100" step="0.1" placeholder="kg">
          <button type="button" class="wl-add-btn" onclick="addWeightEntry()">+ Aggiungi</button>
        </div>
      </div>
    </div>
  </div>

  <div class="onboarding-error" id="onboarding-error"></div>
  <div class="onboarding-actions">
    <button class="onboarding-btn onboarding-btn-secondary" id="btn-back" onclick="prevStep()" style="display:none">Indietro</button>
    <button class="onboarding-btn onboarding-btn-primary" id="btn-next" onclick="nextStep()">Avanti</button>
  </div>
  </section>

  <!-- La chat non e' piu' l'app: e' una delle voci del menu. -->
  <section class="screen chat-screen hidden" id="screen-chat">
  <!-- Strip di Félicette. Il disclaimer Art. 348 C.P. NON e' piu' qui:
       e' una barra della shell, visibile anche durante l'onboarding. -->
  <div class="avatar-strip">
    <div class="avatar-col">
      <div class="avatar-slot idle" id="avatar-slot" onclick="onAvatarTap()" role="button"
           tabindex="0" aria-label="Tocca per interrompere la voce"></div>
      <div class="avatar-name">Félicette <span class="avatar-status" id="avatar-status">In ascolto...</span></div>
    </div>
    <div class="avatar-info">
      <div class="avatar-disclaimer">
        <strong><svg class="npi" aria-hidden="true" focusable="false"><use href="#npi-info"/></svg> Assistente informativo AI</strong> — Risposte generate da intelligenza artificiale a fini informativi. Non sostituiscono un medico veterinario iscritto all'Albo.
      </div>
    </div>
  </div>



  <div class="chat-container" id="chat-container">
    <div class="welcome-card" id="welcome-card">
      <h2 id="welcome-title">Ciao! Sono qui per aiutarti</h2>
      <p>Conosco il profilo del tuo gatto e posso rispondere in modo personalizzato. Non sostituisco il tuo veterinario.</p>
      <div class="welcome-suggestions">
        <button class="suggestion-btn" data-faq="food" onclick="sendSuggestion(this)"><svg class="npi" aria-hidden="true" focusable="false"><use href="#npi-food"/></svg> Cosa può mangiare il mio gatto?</button>
        <button class="suggestion-btn" data-faq="vaccines" onclick="sendSuggestion(this)"><svg class="npi" aria-hidden="true" focusable="false"><use href="#npi-syringe"/></svg> Quando fare i richiami vaccinali?</button>
        <button class="suggestion-btn" data-faq="activity" onclick="sendSuggestion(this)"><svg class="npi" aria-hidden="true" focusable="false"><use href="#npi-activity"/></svg> Quanta attività fisica gli serve?</button>
        <button class="suggestion-btn" data-faq="alarms" onclick="sendSuggestion(this)"><svg class="npi" aria-hidden="true" focusable="false"><use href="#npi-warn"/></svg> Segnali di allarme da non ignorare</button>
        <button class="suggestion-btn tablet-extra" data-faq="bath" onclick="sendSuggestion(this)"><svg class="npi" aria-hidden="true" focusable="false"><use href="#npi-bath"/></svg> Ogni quanto fare il bagno?</button>
        <button class="suggestion-btn tablet-extra" data-faq="teeth" onclick="sendSuggestion(this)"><svg class="npi" aria-hidden="true" focusable="false"><use href="#npi-tooth"/></svg> Come tenere puliti i denti?</button>
      </div>
    </div>
  </div>

  <div class="typing-indicator" id="typing-indicator">
    <div style="width:32px;height:32px;border-radius:50%;background:var(--primary-light);display:flex;align-items:center;justify-content:center;font-size:.9rem">🐱</div>
    <div class="typing-dots"><span></span><span></span><span></span></div>
    <span class="typing-label">sta scrivendo...</span>
  </div>

  <!-- Banner pubblicitario (solo utenti free) -->
  <div class="ad-banner" id="ad-banner">
    <div class="ad-container" id="ad-container">
      <span class="ad-placeholder">Pubblicità</span>
    </div>
  </div>

  <div class="message-counter" id="message-counter"></div>

  <div class="input-area">
    <div class="input-row" id="input-row">
      <button class="btn-voice" id="btn-voice" onclick="startVoiceInput()" title="Fai la domanda a voce" aria-label="Detta la tua domanda"><svg class="npi" aria-hidden="true" focusable="false"><use href="#npi-mic"/></svg></button>
      <textarea class="input-field" id="input-field" placeholder="Scrivi la tua domanda…" rows="1" readonly onclick="openInputModal()" onfocus="this.blur();openInputModal()"></textarea>
      <button class="btn-send" id="btn-send" onclick="sendMessage()" aria-label="Invia la domanda"><svg class="npi" aria-hidden="true" focusable="false"><use href="#npi-arrow-right"/></svg></button>
    </div>
    <div class="free-gate" id="free-gate" style="display:none">
      <button class="free-upgrade-btn" onclick="showUpgradePrompt()"><svg class="npi" aria-hidden="true" focusable="false"><use href="#npi-lock"/></svg> Passa a Premium per chiedere del tuo gatto</button>
      <button type="button" class="free-code-toggle" id="free-code-toggle" onclick="openCodeModal()"><svg class="npi" aria-hidden="true" focusable="false"><use href="#npi-key"/></svg> Ho un codice di attivazione</button>

    </div>
  </div>
  </section>

</main>

<!-- ═══ FOOTER PERSISTENTE ═══ -->
  <div class="footer">
    NeosCat AI v<span id="app-version">1.0.66</span> — Assistente informativo, non consulenza veterinaria
    <br><a href="privacy.html">Privacy Policy</a> · <a href="terms.html">Termini di Servizio</a>
    <span class="manage-sub-link" id="manage-sub-link" hidden></span>
  </div>
</div>

<!-- I sovrapposti stanno FUORI da .app: sono strati a se', non contenuto. -->
  <!-- Modale input full-screen (su mobile il campo inline è scomodo: il tap apre questo) -->
  <div class="input-modal" id="input-modal" onclick="if(event.target===this)closeInputModal()">
    <div class="input-modal-card">
      <div class="input-modal-title" id="input-modal-title"><svg class="npi" aria-hidden="true" focusable="false"><use href="#npi-pencil"/></svg> La tua domanda</div>
      <textarea id="input-modal-field" placeholder="Scrivi qui la tua domanda sul tuo gatto..." onkeydown="handleModalKeyDown(event)"></textarea>
      <div class="input-modal-actions">
        <button class="im-btn im-mic" id="input-modal-mic" onclick="toggleModalVoice()" title="Detta a voce" aria-label="Detta a voce"><svg class="npi" aria-hidden="true" focusable="false"><use href="#npi-mic"/></svg></button>
        <button class="im-btn im-cancel" onclick="closeInputModal()">Annulla</button>
        <button class="im-btn im-send" id="input-modal-send" onclick="sendFromModal()">Invia <svg class="npi" aria-hidden="true" focusable="false"><use href="#npi-arrow-right"/></svg></button>
      </div>
    </div>
  </div>

<!-- ═══ MODALE UPGRADE PREMIUM ═══ -->
<div class="upgrade-modal" id="upgrade-modal">
  <div class="upgrade-modal-content">
    <div class="upgrade-icon"><svg class="npi npi-xl" aria-hidden="true" focusable="false"><use href="#npi-cat"/></svg></div>
    <h3>NeosCat Premium</h3>
    <ul class="upgrade-perks" id="upgrade-list"></ul>
    <p class="upgrade-choose">Scegli il piano:</p>
    <div class="plan-select" id="plan-select">
      <button type="button" class="plan-card" id="plan-premium" onclick="selectPlan('premium')">
        <span class="plan-name">Premium</span>
        <span class="plan-amt"><span id="price-premium">30</span>€<small>/anno</small></span>
        <span class="plan-eq">≈5 risposte<br>al giorno</span>
      </button>
      <button type="button" class="plan-card selected" id="plan-premium-plus" onclick="selectPlan('premium_plus')">
        <span class="plan-badge" id="plan-plus-badge">Consigliato</span>
        <span class="plan-name">Premium Plus</span>
        <span class="plan-amt"><span id="price-premium-plus">40</span>€<small>/anno</small></span>
        <span class="plan-eq">≈10 risposte<br>al giorno</span>
      </button>
    </div>
    <!-- ⚠️ Questo era l'UNICO punto da cui si può pagare che non offriva il codice di
         attivazione: c'era in Home, in chat, in Impostazioni e nella vetrina, e mancava
         proprio qui. Chi arrivava con un codice in mano non lo trovava, proseguiva su
         Stripe, e là l'unica cosa che assomigliava a un codice era il campo dei coupon
         — che non accetta i nostri. Risultato: pagava una cosa che aveva già. -->
    <button class="upgrade-btn-secondary" id="upgrade-code-link" onclick="codiceDalPaywall()"
            style="margin:0 0 12px">Ho un codice di attivazione</button>
    <label class="consent-recesso">
      <input type="checkbox" id="consent-recesso" onchange="toggleCheckoutBtn()">
      <span>Acconsento all'attivazione immediata del servizio e prendo atto che, una volta iniziato l'utilizzo, perderò il diritto di recesso di 14 giorni (art. 59 Codice del Consumo).</span>
    </label>
    <button class="upgrade-btn-primary" onclick="startPremiumCheckout()" disabled>Passa a Premium</button>
    <button class="upgrade-btn-secondary" onclick="closeUpgradeModal()">Non ora</button>
    <button class="upgrade-btn-secondary" onclick="restorePremium()" style="margin-top:4px">Ho già pagato — ripristina</button>
  </div>
</div>

<!-- ═══ MODALE PROMEMORIA (Premium) — lista in-app + notifiche native ═══ -->
<div class="rem-modal" id="rem-modal" onclick="if(event.target===this)closeReminders()">
  <div class="rem-modal-card">
    <div class="rem-modal-head">
      <span class="rem-modal-title"><svg class="npi" aria-hidden="true" focusable="false"><use href="#npi-bell"/></svg> Promemoria <span id="rem-dog-name"></span></span>
      <button class="rem-close" onclick="closeReminders()" title="Chiudi" aria-label="Chiudi i promemoria"><svg class="npi" aria-hidden="true" focusable="false"><use href="#npi-x"/></svg></button>
    </div>
    <div class="rem-native-note" id="rem-native-note"></div>
    <div class="rem-list" id="rem-list"></div>
    <div class="rem-add">
      <select class="rem-field" id="rem-type">
        <option value="vaccino">Vaccino / richiamo</option>
        <option value="antiparassitario">Antiparassitario</option>
        <option value="visita">Visita di controllo</option>
        <option value="farmaco">Promemoria farmaco</option>
        <option value="altro">Altro</option>
      </select>
      <input class="rem-field" id="rem-title" type="text" maxlength="60" placeholder="Nota (opzionale, es. «richiamo polivalente»)">
      <div class="rem-add-row">
        <input class="rem-field" id="rem-date" type="date">
        <input class="rem-field" id="rem-time" type="time" value="09:00">
      </div>
      <div class="rem-err" id="rem-err"></div>
      <button class="rem-add-btn" onclick="addReminder()"><svg class="npi" aria-hidden="true" focusable="false"><use href="#npi-plus"/></svg> Aggiungi promemoria</button>
    </div>
  </div>
</div>

<script>
// ═════════════════════════════════════════════════════════════════════
// NeosCat AI v1.0.66 — © Neos Pet AI
// Architettura modulare: cambiando solo APP_CONFIG si crea NeosCat/NeosHorse
// ═════════════════════════════════════════════════════════════════════

// ═══ APP CONFIG (cambiare solo questo blocco per Cat/Horse) ═══
const APP_CONFIG = {
  name: 'NeosCat AI',
  species: 'gatto',
  speciesPlural: 'gatti',
  version: '1.0.66',
  brand: 'Neos Pet AI',
  icon: '🐱',
  aiIcon: '🐱',
  themeColor: '#0D9488',
  storagePrefix: 'ncat_',
  freeQuota30d: 3,                     // risposte gratuite ogni 30 giorni (il limite vero è sul Worker)
  premiumQuotaBase: 150,               // risposte Premium / 30gg (display; limite vero sul Worker)
  premiumQuotaPlus: 300,               // risposte Premium Plus / 30gg (display; limite vero sul Worker)
  premiumQuota30d: 300,                // ripiego di display (tier alto), usato solo finché /quota non risponde
  maxConversationHistory: 20,
  maxDogsFree: 1,                      // gatti nel profilo per il piano gratuito
  maxDogsPremium: 5,                   // gatti nel profilo per il Premium
  maxStoredMessages: 40,               // messaggi max salvati per gatto (memoria Premium; localStorage ~5MB)
  maxRemindersPerDog: 30,              // promemoria max per gatto (Premium); evita code di notifiche troppo lunghe
  premiumPriceYear: '30',              // prezzo ANNUALE Premium mostrato (€/anno). Price vero nel Worker (PRICE_PREMIUM_YEAR)
  premiumPlusPriceYear: '40',          // prezzo ANNUALE Premium Plus mostrato (€/anno). Price vero nel Worker (PRICE_PLUS_YEAR)
  playProductId: 'neoscat_premium',    // ID abbonamento su Google Play (deve combaciare col Play Console)
  playPackage: 'org.neospetai.neoscat',// package dell'app nativa (per il link gestione abbonamento)
  adUnitBanner: 'ca-app-pub-xxx/yyy',  // TODO: AdMob banner unit reale (solo a store pubblicato)
  adUnitInterstitial: 'ca-app-pub-xxx/zzz', // TODO: AdMob interstitial unit reale (solo a store pubblicato)
  adsTestMode: true,        // true = ID di test ufficiali Google (sviluppo). Mettere false SOLO con app pubblicata e ID reali in console.
  adInterstitialEvery: 2,   // un interstitial ogni N messaggi (free, solo nativo). Con free a 3/30gg, "2" garantisce 1 impression.
  adsTestDevices: [],       // hash dei device di test (compaiono nel logcat AdMob al primo avvio)
  supportEmail: 'supporto@neospetai.org',  // ⚠️ DA ATTIVARE: la casella deve esistere ed
                            // essere presidiata PRIMA della pubblicazione. È l'unica via di
                            // recupero per chi ha pagato su Stripe e ha perso il codice.
  devForcePremium: false,   // ⚠️ TEST: true = forza isPremium()=true per collaudare le funzioni Premium. RIMETTERE false prima dell'AAB per lo store!
};
if (APP_CONFIG.devForcePremium) console.warn('⚠️ devForcePremium ATTIVO — Premium SIMULATO. Rimettere false prima della pubblicazione.');

// ═══ ANTHROPIC API CONFIG ═══
// ⚠️ v1.0.65 — Il MODELLO lo decide il Worker: lasciarlo scegliere al client
// significava lasciare al chiamante la scelta di quanto farci spendere.
const ANTHROPIC_MAX_TOKENS = 600;
// La scheda razza è più lunga di una risposta di chat: budget di token dedicato
// (sotto il tetto di 1500 del Worker) così non si tronca, senza gonfiare le chat normali.
const BREED_SHEET_MAX_TOKENS = 1400;
// IMPORTANTE: la chiave API non sta nel client. Va configurata nel Worker.
// Crea un Cloudflare Worker dedicato per NeosCat (separato da NeosHeart).
const API_PROXY_URL = 'https://api-cat.neospetai.org'; // dominio custom del Worker NeosCat

// Rilevamento app nativa (Capacitor): sul nativo il Premium via Stripe è nascosto
// (gli store richiedono i pagamenti in-app, es. Google Play Billing — fase successiva).
const IS_NATIVE = !!(window.Capacitor && window.Capacitor.isNativePlatform && window.Capacitor.isNativePlatform());

// ═══ SYSTEM PROMPT VETERINARIO (compliance Art. 348 C.P. + Legge IA 132/2025) ═══
// ⚠️ v1.0.65 — SYSTEM_PROMPT_BASE è stato SPOSTATO NEL WORKER (rilievo A-1).
// Qui era leggibile da chiunque aprisse il sorgente della pagina, ma soprattutto era
// inutile come presidio: il Worker usava `body.system || ''`, cioè accettava qualunque
// prompt il chiamante gli mandasse. Ora la parte invariante vive sul server e non è
// sostituibile; da qui parte solo l'anagrafica del gatto, che deve restare dinamica
// perché sta nel localStorage. Vedi buildProfileBlock().

// ═══ STATE ═══
let conversationHistory = [];
let isWaitingResponse = false;
// Stato quota (finestra mobile 30 giorni). FONTE DI VERITÀ = Worker; qui solo cache per il display.
// quota = { used, cap, premium, resetAt }
let quota = (function(){
  try { return JSON.parse(localStorage.getItem(APP_CONFIG.storagePrefix+'quota')||'null') || {}; }
  catch(e){ return {}; }
})();
let localMsgCount = 0; // contatore di sessione, solo per la cadenza degli interstitial (non è la quota)
let adsReady = false;        // SDK AdMob inizializzato (solo nativo, solo free)
let interstitialReady = false; // un interstitial è pre-caricato e pronto a essere mostrato
let recognition = null;
let isListening = false;
let onboardingStep = 1;
const onboardingData = {};
let isEditingProfile = false; // true quando si modifica un profilo esistente dalla chat
let isAddingDog = false;      // true quando si sta aggiungendo un NUOVO gatto (multi-gatto)
// Output vocale (Félicette parla via Web Speech API)
let avatarVoiceMuted = localStorage.getItem(APP_CONFIG.storagePrefix+'voice_muted') === 'true';
let avatarSpeaking = false;
let ttsVoices = [];

// ═══ ID ANONIMO DEL CLIENT (identità senza account, per legare l'abbonamento) ═══
let clientId = localStorage.getItem(APP_CONFIG.storagePrefix+'client_id');
if (!clientId) {
  clientId = (self.crypto && crypto.randomUUID)
    ? crypto.randomUUID()
    : 'c_' + Date.now() + '_' + Math.random().toString(36).slice(2);
  localStorage.setItem(APP_CONFIG.storagePrefix+'client_id', clientId);
}

// ═══ ID DISPOSITIVO PERSISTENTE (per la quota; sopravvive alla reinstallazione su nativo) ═══
// Su Android nativo usa l'ID di sistema (ANDROID_ID via @capacitor/device): resta lo stesso
// dopo disinstalla+reinstalla → la quota gratuita NON si azzera (anti-furbi). Sul web (PWA,
// canale Premium) non esiste un ID hardware persistente: si usa il clientId come ripiego.
let deviceId = clientId;
async function resolveDeviceId() {
  try {
    const Dev = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Device;
    if (IS_NATIVE && Dev && Dev.getId) {
      const r = await Dev.getId();
      const id = (r && (r.identifier || r.uuid)) || '';   // v5+: identifier · v4-: uuid
      if (id) deviceId = 'dev_' + id;
    }
  } catch (e) { /* resta il ripiego clientId */ }
}

// ═══ ONBOARDING ═══
function nextStep() {
  hideError();
  salvaBozza();
  if (!validateStep(onboardingStep)) return;
  if (onboardingStep < 3) {
    saveStepData(onboardingStep);
    onboardingStep++;
    updateOnboardingUI();
  } else {
    completeOnboarding();
  }
}

function prevStep() {
  salvaBozza();
  hideError();
  if (onboardingStep === 1 && (isEditingProfile || isAddingDog)) {
    cancelEdit();
    return;
  }
  if (onboardingStep > 1) {
    onboardingStep--;
    updateOnboardingUI();
  }
}

function updateOnboardingUI() {
  document.querySelectorAll('.onboarding-step').forEach(s => s.classList.remove('visible'));
  document.getElementById('step-'+onboardingStep).classList.add('visible');
  for (let i = 1; i <= 3; i++) {
    const dot = document.getElementById('dot-'+i);
    dot.classList.remove('active','done');
    if (i < onboardingStep) dot.classList.add('done');
    else if (i === onboardingStep) dot.classList.add('active');
  }
  // Pulsante indietro/annulla
  const backBtn = document.getElementById('btn-back');
  if (onboardingStep > 1) {
    backBtn.style.display = 'block';
    backBtn.textContent = 'Indietro';
  } else if (isEditingProfile || isAddingDog) {
    backBtn.style.display = 'block';
    backBtn.textContent = 'Annulla';
  } else {
    backBtn.style.display = 'none';
  }
  // Pulsante avanti/salva
  const nextBtn = document.getElementById('btn-next');
  if (onboardingStep === 3) {
    nextBtn.textContent = isEditingProfile ? 'Salva' : (isAddingDog ? 'Aggiungi il gatto' : 'Inizia');
    applyClinicalGating();
    renderWeightLog();
  } else {
    nextBtn.textContent = 'Avanti';
  }
}

function validateStep(step) {
  if (step === 1) {
    const name = document.getElementById('owner-name').value.trim();
    const email = document.getElementById('owner-email').value.trim();
    const ageOk = document.getElementById('consent-age').checked;
    const privacyOk = document.getElementById('consent-privacy').checked;
    if (!name) return showError('Inserisci il tuo nome');
    if (!email || !/.+@.+\..+/.test(email)) return showError('Inserisci un\'email valida');
    if (!ageOk) return showError('Devi avere almeno 14 anni per usare questa app');
    if (!privacyOk) return showError('Devi accettare Privacy Policy e Termini di Servizio');
    return true;
  }
  if (step === 2) {
    const dogName = document.getElementById('dog-name').value.trim();
    const breed = document.getElementById('dog-breed').value;
    const breedOther = document.getElementById('dog-breed-other').value.trim();
    if (!dogName) return showError('Inserisci il nome del gatto');
    if (!breed) return showError('Seleziona la razza');
    if (breed === 'altra' && !breedOther) return showError('Specifica la razza');
    return true;
  }
  return true; // step 3 facoltativo
}

// ═══ BOZZA DELL'ONBOARDING ═══
// onboardingData vive in memoria: qualunque cosa ricarichi la pagina — il ritorno dal
// pagamento Stripe, una chiamata in arrivo, il sistema che scarica l'app dalla memoria —
// cancellava tutto quello che l'utente aveva scritto, riportandolo al primo passo.
function bozzaKey() { return APP_CONFIG.storagePrefix + 'onb_draft'; }

function salvaBozza() {
  if (!isEditingProfile && !isAddingDog && hasPet()) return;   // non è una compilazione
  try {
    localStorage.setItem(bozzaKey(), JSON.stringify({
      step: onboardingStep, dati: onboardingData,
      editing: isEditingProfile, aggiungendo: isAddingDog,
      ritorno: onboardingReturn, at: Date.now()
    }));
  } catch (e) { /* memoria piena: si perde la bozza, non i dati salvati */ }
}

let bozzaTimer = null;
function scartaBozza() {
  /* ⚠️ Il salvataggio e' DIFFERITO di 400 ms: un timer ancora in volo riscriveva
     la bozza DOPO lo scarto, resuscitando una modifica appena annullata. Chi
     scarta deve anche fermare chi sta per scrivere. */
  clearTimeout(bozzaTimer);
  try { localStorage.removeItem(bozzaKey()); } catch (e) {}
}

// ⚠️ Salvare solo al cambio di passo non basta: lo step 3 è l'ULTIMO, quindi da lì non
// si cambia più passo e quello che l'utente scrive non veniva mai messo nella bozza.
// Si salva quindi a ogni modifica dei campi, con una pausa per non scrivere su disco
// a ogni singolo tasto.
function salvaBozzaDifferita() {
  clearTimeout(bozzaTimer);
  bozzaTimer = setTimeout(() => {
    try { saveStepData(onboardingStep); } catch (e) {}
    salvaBozza();
  }, 400);
}
document.addEventListener('DOMContentLoaded', () => {
  const sc = document.getElementById('screen-onboarding');
  if (!sc) return;
  ['input', 'change'].forEach(ev => sc.addEventListener(ev, () => {
    if (currentScreen === 'onboarding') salvaBozzaDifferita();
  }, true));   // in cattura: prende anche i campi ricostruiti dopo un ridisegno
});

// Ripristina una compilazione interrotta. Restituisce true se ha ripreso qualcosa.
/* L'intestazione dell'onboarding dipende da COSA si sta facendo: nuova compilazione,
   modifica di un profilo, aggiunta di un animale. Sta in una funzione sola, così
   ogni strada che entra in compilazione la ottiene giusta. */
function aggiornaIntestazioneOnboarding() {
  const t = document.querySelector('.onboarding-title');
  const s = document.querySelector('.onboarding-subtitle');
  if (!t) return;
  if (isEditingProfile) {
    t.textContent = 'Modifica il profilo';
    if (s) s.textContent = 'Aggiorna i dati del tuo gatto e salva';
  } else if (isAddingDog) {
    t.textContent = 'Aggiungi un gatto';
    if (s) s.textContent = 'Inserisci i dati del nuovo gatto';
  } else {
    t.textContent = 'Benvenuto in NeosCat AI';
    if (s) s.textContent = 'Crea il profilo del tuo gatto in 3 semplici passi';
  }
}

function ripristinaBozza() {
  let b = null;
  try { b = JSON.parse(localStorage.getItem(bozzaKey()) || 'null'); } catch (e) { return false; }
  if (!b || !b.dati) return false;
  // Una bozza di ieri non serve a nessuno: dopo 24 ore si butta.
  if (!b.at || Date.now() - b.at > 24 * 60 * 60 * 1000) { scartaBozza(); return false; }
  const campi = Object.keys(b.dati).filter(k => b.dati[k] !== '' && b.dati[k] != null);
  if (!campi.length) { scartaBozza(); return false; }
  // «Aggiungi gatto» parte da zero: senza svuotare, i campi non presenti nella bozza
  // resterebbero quelli del gatto attivo, mescolando due animali.
  if (b.aggiungendo) Object.keys(onboardingData).forEach(k => delete onboardingData[k]);
  Object.assign(onboardingData, b.dati);
  isEditingProfile = !!b.editing;
  isAddingDog = !!b.aggiungendo;
  onboardingReturn = b.ritorno || 'home';
  onboardingStep = Math.min(3, Math.max(1, b.step || 1));
  /* ⚠️ Titolo e sottotitolo li impostano i TRE punti che entrano in compilazione
     (editProfile, addDog, exitEditMode) e nessun altro. Ripristinando una bozza si
     rientra in compilazione SENZA passare da lì: i flag tornavano giusti e
     l'intestazione restava «Benvenuto», dicendo all'utente che stava ricominciando
     da zero mentre stava modificando. Trovato eseguendo la prova D2 del piano.
     E' il §19.2 in un'altra veste: uno stato con più consumatori, aggiornato solo
     da alcune delle strade che lo cambiano. */
  aggiornaIntestazioneOnboarding();
  console.log('[Onboarding] ripresa compilazione interrotta allo step', onboardingStep);
  return true;
}

function saveStepData(step) {
  if (step === 1) {
    onboardingData.ownerName = document.getElementById('owner-name').value.trim();
    onboardingData.ownerEmail = document.getElementById('owner-email').value.trim();
  } else if (step === 2) {
    onboardingData.dogName = document.getElementById('dog-name').value.trim();
    // Variante fonetica: solo per la voce. Se coincide col nome non serve conservarla.
    const pron = (document.getElementById('dog-name-pron').value || '').trim();
    onboardingData.namePhonetic = (pron && pron !== onboardingData.dogName) ? pron : '';
    let breed = document.getElementById('dog-breed').value;
    if (breed === 'altra') breed = document.getElementById('dog-breed-other').value.trim();
    onboardingData.breed = breed;
    onboardingData.ageYears = parseInt(document.getElementById('dog-age-years').value)||0;
    onboardingData.ageMonths = parseInt(document.getElementById('dog-age-months').value)||0;
    onboardingData.weight = parseFloat(document.getElementById('dog-weight').value)||0;
    onboardingData.sex = document.querySelector('.radio-option.selected[data-field="dog-sex"]')?.dataset.value||'';
    onboardingData.neutered = document.querySelector('.radio-option.selected[data-field="dog-neutered"]')?.dataset.value||'';
  } else if (step === 3) {
    // ⚠️ Prima questi campi venivano letti SOLO in completeOnboarding: la bozza non li
    // vedeva mai, e lo step 3 — essendo l'ultimo — tornava vuoto dopo un ricaricamento.
    const conditions = Array.from(document.querySelectorAll('#conditions-list input:checked')).map(c => c.value);
    const conditionsOther = document.getElementById('conditions-other').value.trim();
    if (conditionsOther) conditions.push(conditionsOther);
    onboardingData.conditions = conditions;
    onboardingData.conditionsOther = conditionsOther;
    onboardingData.medications = document.getElementById('dog-meds').value.trim();
    onboardingData.vaccines = Array.from(document.querySelectorAll('#vaccines-list input:checked')).map(c => c.value);
    onboardingData.diet = document.getElementById('dog-diet').value;
    onboardingData.activity = document.getElementById('dog-activity').value;
    onboardingData.allergies = document.getElementById('dog-allergies').value.trim();
    onboardingData.clinicalNotes = document.getElementById('dog-clinical-notes').value.trim();
  }
}

function completeOnboarding() {
  /* ⚠️ §24 — la lettura dello step 3 sta in saveStepData(), UNA sola volta:
     due letture degli stessi campi sono due cose da tenere allineate, ed e'
     cosi' che la bozza nasceva monca. */
  saveStepData(3);
  // Step 3 data
  // Profilo clinico esteso (Premium). Il log peso è già mantenuto in onboardingData.
  // Preserva la data di creazione, registra l'ultima modifica
  if (!onboardingData.createdAt) onboardingData.createdAt = new Date().toISOString();
  onboardingData.updatedAt = new Date().toISOString();
  /* ⚠️ §11 — il rilevamento va fatto PRIMA del salvataggio: dopo, hasPet()
     e' sempre vero e la condizione non scatta mai. Il difetto sarebbe
     invisibile, perche' il comportamento resterebbe quello di prima. */
  scartaBozza();                       // compilazione conclusa
  const primoAnimale = !hasPet();
  persistActiveDog();
  exitEditMode();
  aggiornaTestiChat();
  /* Con la Home come schermata d'avvio, chi finisce l'onboarding troverebbe
     un menu con voci bloccate proprio quando ha appena inserito i dati e
     vuole vedere l'app funzionare. Il primo gatto in assoluto porta in chat;
     la Home resta sotto nello stack e si scopre con la freccia. */
  if (primoAnimale) { navStack = ['home']; renderScreen('chat'); }
  else go(onboardingReturn);
}

function showError(msg) {
  const el = document.getElementById('onboarding-error');
  el.textContent = '⚠️ ' + msg;
  el.classList.add('visible');
  return false;
}
function hideError() {
  document.getElementById('onboarding-error').classList.remove('visible');
}

// Gestione select "altra razza" e radio buttons
document.addEventListener('DOMContentLoaded', () => {
  document.getElementById('dog-breed').addEventListener('change', (e) => {
    document.getElementById('dog-breed-other').style.display = e.target.value === 'altra' ? 'block' : 'none';
  });
  // Radio buttons custom
  document.querySelectorAll('.radio-option').forEach(opt => {
    opt.addEventListener('click', () => {
      const field = opt.dataset.field;
      document.querySelectorAll(`.radio-option[data-field="${field}"]`).forEach(o => o.classList.remove('selected'));
      opt.classList.add('selected');
      /* ⚠️ §24 — questi pulsanti sono <div>, non <input>: NON emettono `input` ne'
         `change`, e il salvataggio della bozza ascolta quei due eventi. Toccando
         «Sì» non si salvava niente, e in PWA — dove la pagina si ricarica davvero —
         la scelta si perdeva. In nativa il difetto c'era ma non si vedeva, perche'
         `onboardingData` resta in memoria e la classe `.selected` resta nel DOM. */
      if (typeof salvaBozzaDifferita === 'function') salvaBozzaDifferita();
    });
  });
  // Checklist mutuo esclusivo "Nessuna patologia"
  const noneCheckbox = document.querySelector('#conditions-list input[data-exclusive="true"]');
  if (noneCheckbox) {
    noneCheckbox.addEventListener('change', () => {
      if (noneCheckbox.checked) {
        document.querySelectorAll('#conditions-list input').forEach(c => { if (c !== noneCheckbox) c.checked = false; });
      }
    });
    document.querySelectorAll('#conditions-list input:not([data-exclusive])').forEach(c => {
      c.addEventListener('change', () => { if (c.checked) noneCheckbox.checked = false; });
    });
  }
  // Auto-login: se profilo già presente, vai direttamente alla chat
  tryAutoLogin();
  // Allinea il numero di versione mostrato nel footer ad APP_CONFIG
  { const v = document.getElementById('app-version'); if (v) v.textContent = APP_CONFIG.version; }
  // Metriche: segnala l'apertura dell'app (utente attivo)
  sendEvent('open');
  // Stripe: allinea lo stato Premium e gestisci l'eventuale ritorno dal checkout
  refreshPremiumStatus();
  /* ⚠️ PRIMA del primo disegno: chiamandola dopo, la schermata compare in misura
     normale e cresce un istante piu' tardi. */
  applicaMisuraTesto();
  chiediArchivioPersistente();
  /* ⚠️ Riallineamento al RITORNO IN PRIMO PIANO: revoca e scadenza si riflettono
     senza dover chiudere e riaprire da zero. Con l'anti-rimbalzo, perche' su
     Android `visibilitychange` e `focus` arrivano spesso in coppia. */
  let ultimoRiallineamento = 0;
  const riallinea = () => {
    if (document.visibilityState !== 'visible') return;
    const ora = Date.now();
    if (ora - ultimoRiallineamento < 5000) return;
    ultimoRiallineamento = ora;
    refreshPremiumStatus();
    if (typeof refreshQuota === 'function') refreshQuota();
  };
  document.addEventListener('visibilitychange', riallinea);
  window.addEventListener('focus', riallinea);
  /* ⚠️ E il ricontrollo PERIODICO, che e' il pezzo che mancava del tutto: senza,
     una revoca non si nota finche' l'app resta aperta, e il «entro ~90 secondi»
     del collaudo era una promessa senza meccanismo.
     Solo a schermo visibile: in background non si consuma rete ne' batteria.
     Il server resta comunque autoritativo — il Premium revocato smette di
     funzionare all'istante lato Worker; questo allinea soltanto l'interfaccia. */
  setInterval(() => {
    if (document.visibilityState === 'visible') refreshPremiumStatus();
  }, 60000);

  // ID dispositivo persistente, poi quota residua dal Worker (fonte di verità)
  resolveDeviceId().then(refreshQuota);
  handleCheckoutReturn();
  handleCodeDeepLink(); // riscatto automatico del codice da QR/link (?code=…), solo web
  // Google Play Billing (solo nativo): il plugin è pronto dopo 'deviceready';
  // il fallback temporizzato copre il caso in cui l'evento sia già passato.
  if (IS_NATIVE) {
    document.addEventListener('deviceready', initPlayBilling, false);
    setTimeout(initPlayBilling, 3000);
    initNativeTTS();
    initReminderNotifications(); // canale notifiche + ri-programma i promemoria futuri
  }
  // Inserisci il gattino Félicette nello slot avatar
  insertFélicetteAvatar();
  // Applica la preferenza salvata della voce
  if (avatarVoiceMuted) {
    const vbtn = document.getElementById('btn-voice-toggle');
    if (vbtn) { vbtn.innerHTML = icon('speaker-off'); vbtn.classList.add('muted'); }
  }
});

// ═══ MULTI-GATTO: profili in array + gatto attivo ═══════════════════════
// Fonte di verità: ncat_profiles (array) + ncat_active_dog (id).
// ncat_profile (singolo) viene mantenuto come MIRROR del gatto attivo, così
// buildProfileBlock() e ogni altro lettore legacy continuano a funzionare.
function newDogId() { return 'dog_' + Date.now().toString(36) + Math.random().toString(36).slice(2,6); }

function getProfiles() {
  let arr = null;
  try { arr = JSON.parse(localStorage.getItem(APP_CONFIG.storagePrefix+'profiles')||'null'); } catch(e){}
  if (Array.isArray(arr)) return arr;
  // Migrazione una-tantum dal vecchio profilo singolo
  let legacy = null;
  try { legacy = JSON.parse(localStorage.getItem(APP_CONFIG.storagePrefix+'profile')||'null'); } catch(e){}
  if (legacy && typeof legacy === 'object') {
    if (!legacy.id) legacy.id = newDogId();
    arr = [legacy];
    localStorage.setItem(APP_CONFIG.storagePrefix+'profiles', JSON.stringify(arr));
    localStorage.setItem(APP_CONFIG.storagePrefix+'active_dog', legacy.id);
    return arr;
  }
  return [];
}
function saveProfiles(arr) { localStorage.setItem(APP_CONFIG.storagePrefix+'profiles', JSON.stringify(arr)); }
function getActiveDogId() {
  const id = localStorage.getItem(APP_CONFIG.storagePrefix+'active_dog');
  const profiles = getProfiles();
  if (id && profiles.some(d => d.id === id)) return id;
  return profiles.length ? profiles[0].id : null;
}
function getActiveDog() {
  const profiles = getProfiles();
  const id = getActiveDogId();
  return profiles.find(d => d.id === id) || profiles[0] || null;
}
// Allinea il mirror (ncat_profile) al gatto attivo
function mirrorActiveDog() {
  const d = getActiveDog();
  if (d) localStorage.setItem(APP_CONFIG.storagePrefix+'profile', JSON.stringify(d));
  else localStorage.removeItem(APP_CONFIG.storagePrefix+'profile');
}
// Carica il gatto attivo nella copia di lavoro onboardingData (usata da header/UI/edit)
function loadActiveDogIntoOnboarding() {
  const d = getActiveDog();
  Object.keys(onboardingData).forEach(k => delete onboardingData[k]);
  if (d) Object.assign(onboardingData, d);
}
// Salva onboardingData (gatto in focus) nell'array: insert se nuovo, update se esistente
function persistActiveDog() {
  if (!onboardingData.id) onboardingData.id = newDogId();
  const profiles = getProfiles();
  const idx = profiles.findIndex(d => d.id === onboardingData.id);
  const copy = JSON.parse(JSON.stringify(onboardingData));
  if (idx >= 0) profiles[idx] = copy; else profiles.push(copy);
  saveProfiles(profiles);
  localStorage.setItem(APP_CONFIG.storagePrefix+'active_dog', onboardingData.id);
  mirrorActiveDog();
}

function tryAutoLogin() {
  const profiles = getProfiles(); // esegue la migrazione se serve
  installaIndietroDiSistema();
  if (profiles.length) {
    loadActiveDogIntoOnboarding();
    mirrorActiveDog();
    restoreActiveDogConversation(); // memoria Premium: cronologia del gatto attivo
    if (ripristinaBozza()) {        // §24: modifica o aggiunta interrotta a metà
      /* ⚠️ La catena va ricostruita PER INTERO. Con `[onboardingReturn]` lo stack
         era ['settings'], e «Annulla» — tagliando lo stack fino a 'settings',
         come impone il §0.5 — lo svuotava: da Impostazioni la freccia non aveva
         più dove tornare. La catena vera e' home → settings → onboarding. */
      navStack = catenaFino(onboardingReturn);
      renderScreen('onboarding');
      prefillOnboarding();
      updateOnboardingUI();
    } else {
      go('home');                   // §11: l'avvio va nella Home
    }
  } else if (ripristinaBozza()) {
    /* §24 — compilazione interrotta: ritorno da un pagamento, app scaricata dalla
       memoria, chiamata in arrivo. Si riprende dal passo in cui si era, con i campi
       gia' compilati. E' il caso B5 del collaudo: pagare a meta' onboarding. */
    navStack = [];
    renderScreen('onboarding');
    prefillOnboarding();
    updateOnboardingUI();
  } else {
    // §10: benvenuto prima dello step 1 - si vede una volta sola
    go(haVistoBenvenuto() ? 'onboarding' : 'welcome');
  }
}

/* ⚠️ §0.7 — questa funzione serviva per TRE cose diverse: avvio, fine
   onboarding e annullamento, e tutte finivano in chat. Eredita' di quando la
   chat ERA l'app. Ora NON naviga: allinea soltanto i testi della chat, e
   chi deve spostarsi chiama go(). */
function aggiornaTestiChat() {
  // Mostra il nome del gatto (dal profilo onboarding) nel titolo di benvenuto
  if (onboardingData.dogName) {
    // Accessi difensivi: la card di benvenuto viene ricostruita da resetWelcome()
    // e un nodo mancante non deve piu' interrompere il resto della funzione
    // (switcher, annunci, contatore stavano DOPO questa riga).
    const nh = document.getElementById('dog-name-header');
    if (nh) nh.textContent = onboardingData.dogName;
    const wt = document.getElementById('welcome-title');
    if (wt) wt.textContent = `Ciao! Sono qui per aiutarti con ${onboardingData.dogName}`;
  }
  renderDogSwitcher();
  initAds();
  updateMessageCounter();
}

// ═══ MODIFICA PROFILO (riapre l'onboarding pre-compilato) ═══
function editProfile() {
  isEditingProfile = true;
  prefillOnboarding();
  onboardingStep = 1;
  onboardingReturn = currentScreen || 'home';   // §11: si torna da dove si e' entrati
  go('onboarding');
  aggiornaIntestazioneOnboarding();
  hideError();
  updateOnboardingUI();
}

function cancelEdit() {
  // Ripristina i dati originali dal localStorage (annulla le modifiche in memoria)
  const saved = localStorage.getItem(APP_CONFIG.storagePrefix+'profile');
  if (saved) {
    try {
      const p = JSON.parse(saved);
      Object.keys(onboardingData).forEach(k => delete onboardingData[k]);
      Object.assign(onboardingData, p);
    } catch(e) { console.warn('[Edit] parse error', e); }
  }
  scartaBozza();
  exitEditMode();
  /* ⚠️ §11 — «Annulla» e' un comando dell'ONBOARDING: se lo si preme altrove
     non deve spostare l'utente, e prima lo faceva usando un'origine rimasta
     in memoria. */
  if (currentScreen === 'onboarding') go(onboardingReturn);
}

function exitEditMode() {
  isEditingProfile = false;
  isAddingDog = false;
  aggiornaIntestazioneOnboarding();
}

// Ricostruisce la welcome card (usata da nuova chat e cambio gatto)
function resetWelcome() {
  const dogName = onboardingData.dogName || '';
  document.getElementById('chat-container').innerHTML = `
    <div class="welcome-card" id="welcome-card">
      <h2 id="welcome-title">Ciao! Sono qui per aiutarti${dogName ? ' con ' + dogName : ''}</h2>
      <p>Conosco il profilo del tuo gatto e posso rispondere in modo personalizzato. Non sostituisco il tuo veterinario.</p>
      <div class="welcome-suggestions">
        <button class="suggestion-btn" data-faq="food" onclick="sendSuggestion(this)"><svg class="npi" aria-hidden="true" focusable="false"><use href="#npi-food"/></svg> Cosa può mangiare il mio gatto?</button>
        <button class="suggestion-btn" data-faq="vaccines" onclick="sendSuggestion(this)"><svg class="npi" aria-hidden="true" focusable="false"><use href="#npi-syringe"/></svg> Quando fare i richiami vaccinali?</button>
        <button class="suggestion-btn" data-faq="activity" onclick="sendSuggestion(this)"><svg class="npi" aria-hidden="true" focusable="false"><use href="#npi-activity"/></svg> Quanta attività fisica gli serve?</button>
        <button class="suggestion-btn" data-faq="alarms" onclick="sendSuggestion(this)"><svg class="npi" aria-hidden="true" focusable="false"><use href="#npi-warn"/></svg> Segnali di allarme da non ignorare</button>
      </div>
    </div>`;
  document.getElementById('emergency-banner').classList.remove('visible');
  setAvatarState('idle');
  updateMessageCounter(); // aggiorna i tag "Premium" dei pulsanti appena ricostruiti
}

// ═══ MEMORIA TRA SESSIONI (Premium): cronologia per singolo gatto ═══════
// Chiave: ncat_history_<dogId>. Persiste SOLO per i Premium; per i free la
// conversazione resta in memoria e riparte a ogni sessione (com'era prima).
function loadDogHistory(dogId) {
  if (!isPremium() || !dogId) return [];
  try {
    const h = JSON.parse(localStorage.getItem(APP_CONFIG.storagePrefix+'history_'+dogId)||'null');
    return Array.isArray(h) ? h : [];
  } catch(e){ return []; }
}
function saveDogHistory(dogId) {
  if (!isPremium() || !dogId) return;
  const cap = APP_CONFIG.maxStoredMessages || 40;
  const trimmed = conversationHistory.slice(-cap); // tronca le conversazioni più vecchie
  try { localStorage.setItem(APP_CONFIG.storagePrefix+'history_'+dogId, JSON.stringify(trimmed)); }
  catch(e){ console.warn('[Memoria] salvataggio cronologia fallito (quota localStorage?):', e); }
}
function clearDogHistory(dogId) {
  if (dogId) localStorage.removeItem(APP_CONFIG.storagePrefix+'history_'+dogId);
}
// Ridisegna in chat i messaggi di una cronologia salvata
function renderConversation(hist) {
  const c = document.getElementById('chat-container');
  c.innerHTML = '';
  hist.forEach(m => addMessage(m.content, m.role === 'user' ? 'user' : 'ai'));
}
// Ripristina la conversazione del gatto attivo (Premium) o mostra la welcome
function restoreActiveDogConversation() {
  const hist = loadDogHistory(getActiveDogId());
  conversationHistory = hist.slice();
  if (conversationHistory.length) renderConversation(conversationHistory);
  else resetWelcome();
}

// ═══ MULTI-GATTO: selettore in header, cambio, aggiunta, cancellazione ═══
function dogLimit() { return isPremium() ? APP_CONFIG.maxDogsPremium : APP_CONFIG.maxDogsFree; }


/* La tendina non esiste piu': era 300x126 appesa a un bottone di 80px, per
   il dato che determina OGNI risposta dell'app. Questi due nomi restano
   perche' li chiamano piu' punti, ma ora sono navigazione. */
function toggleDogSwitcher() { go('pets'); }
function closeDogSwitcher() { /* niente da chiudere: e' una schermata */ }

/* ── Foto del gatto (upload riservato ai Premium) ── */
function dogPhotoKey(id){ return APP_CONFIG.storagePrefix + 'photo_' + id; }
function getDogPhoto(id){ try { return localStorage.getItem(dogPhotoKey(id)) || ''; } catch(e){ return ''; } }
function setDogPhoto(id, dataUrl){ try { localStorage.setItem(dogPhotoKey(id), dataUrl); return true; } catch(e){ return false; } }
function removeDogPhoto(id){ try { localStorage.removeItem(dogPhotoKey(id)); } catch(e){} }
function updateHeaderLogo(){
  const el = document.getElementById('app-logo');
  if (!el) return;
  const d = getActiveDog();
  const photo = d ? getDogPhoto(d.id) : '';
  el.innerHTML = photo
    /* niente onclick sull'<img>: il tocco lo gestisce il grilletto, che sa
       distinguere un tocco singolo da una raffica di cinque. */
    ? `<img src="${photo}" alt="Foto del gatto" title="Tocca due volte per ingrandire" style="cursor:pointer" data-lightbox="${escapeHtml(d.id)}">`
    /* ⚠️ npi-lg va ripetuto QUI: questa funzione gira dopo renderHeader() e ne
       riscrive l'innerHTML. Senza, il logo tornava a 1em (20.8px) contro i
       28.1 della gemella. E' la trappola n.1 del §14: il logo che «ricompare»
       dopo il ridisegno, in forma di misura invece che di emoji. */
    : icon('cat', 'npi-lg');
}

/* ── Lightbox: foto del gatto ingrandita ── */
let plCurrentId = null;
function openPhotoLightbox(id){
  const photo = getDogPhoto(id);
  if (!photo) return;                 // niente foto → niente da ingrandire
  plCurrentId = id;
  const d = getProfiles().find(x => x.id === id);
  document.getElementById('pl-img').src = photo;
  document.getElementById('pl-name').textContent = d ? (d.dogName || '') : '';
  const acts = document.getElementById('pl-actions');
  acts.innerHTML = isPremium()
    ? `<button class="pl-btn-primary" onclick="pickDogPhoto('${id}')"><svg class="npi" aria-hidden="true" focusable="false"><use href="#npi-camera"/></svg> Cambia foto</button>`
      + `<button class="pl-btn-danger" onclick="removeDogPhotoAndRefresh('${id}')">🗑 Rimuovi</button>`
      + `<button class="pl-btn-ghost" onclick="closePhotoLightbox()">Chiudi</button>`
    : `<button class="pl-btn-ghost" onclick="closePhotoLightbox()">Chiudi</button>`;
  document.getElementById('photo-lightbox').classList.add('open');
}
function closePhotoLightbox(){
  const lb = document.getElementById('photo-lightbox');
  if (lb) lb.classList.remove('open');
  plCurrentId = null;
}
function removeDogPhotoAndRefresh(id){
  removeDogPhoto(id); renderDogSwitcher(); updateHeaderLogo(); closePhotoLightbox();
}
function pickDogPhoto(id){
  if (!isPremium()) { showUpgradePrompt(); return; }   // la foto è una funzione Premium
  const inp = document.createElement('input');
  inp.type = 'file'; inp.accept = 'image/*';
  inp.onchange = () => { const f = inp.files && inp.files[0]; if (f) resizeAndStoreDogPhoto(id, f); };
  inp.click();
}
function resizeAndStoreDogPhoto(id, file){
  if (!/^image\//.test(file.type)) { alert('Seleziona un file immagine.'); return; }
  const MAX = 320; // ritaglio quadrato + resize → file piccolo, sicuro per il localStorage
  const reader = new FileReader();
  reader.onload = () => {
    const img = new Image();
    img.onload = () => {
      const side = Math.min(img.width, img.height);
      const sx = (img.width - side) / 2, sy = (img.height - side) / 2;
      const cv = document.createElement('canvas'); cv.width = MAX; cv.height = MAX;
      cv.getContext('2d').drawImage(img, sx, sy, side, side, 0, 0, MAX, MAX);
      let dataUrl;
      try { dataUrl = cv.toDataURL('image/jpeg', 0.82); } catch(e){ alert('Impossibile elaborare l\'immagine.'); return; }
      if (!setDogPhoto(id, dataUrl)) { alert('Memoria piena: impossibile salvare la foto.'); return; }
      renderDogSwitcher();
      updateHeaderLogo();
      if (plCurrentId === id) { const im = document.getElementById('pl-img'); if (im) im.src = dataUrl; }
    };
    img.onerror = () => alert('Immagine non valida.');
    img.src = reader.result;
  };
  reader.onerror = () => alert('Impossibile leggere il file.');
  reader.readAsDataURL(file);
}

// Aggiorna nome in header + contenuto del pannello
/* ── «I miei gatti», sul modello: card dell'animale attivo tinta di marca,
   spunta, nome nel colore di marca, riquadro tratteggiato della foto. ── */
function renderDogSwitcher() {
  const profiles = getProfiles();
  const activeId = getActiveDogId();
  const prem = isPremium();
  updateHeaderLogo();
  if (currentScreen === 'chat') renderHeader('chat');
  const lista = document.getElementById('pets-list');
  if (!lista) return;
  lista.innerHTML = profiles.map(d => {
    const attivo = d.id === activeId;
    const foto = getDogPhoto(d.id);
    const sub = [d.breed, etaLeggibile(d), d.weight ? d.weight + ' kg' : '']
                  .filter(Boolean).join(' · ');
    const titoloFoto = foto ? 'Tocca per ingrandire'
      : (prem ? 'Aggiungi la foto del gatto' : 'Foto del gatto — funzione Premium');
    const azioneFoto = foto ? "openPhotoLightbox('" + d.id + "')"
      : (prem ? "pickDogPhoto('" + d.id + "')" : 'showUpgradePrompt()');
    return '<div class="pet-row' + (attivo ? ' attivo' : '') + '">' +
      '<span class="pet-txt" onclick="switchDog(\'' + d.id + '\')" role="button" tabindex="0">' +
        (attivo ? '<span class="pet-flag" aria-label="Gatto attivo">' + icon('check') + '</span>' : '') +
        '<span class="pet-name">' + escapeHtml(d.dogName || 'Gatto') + '</span>' +
        '<span class="pet-sub">' + escapeHtml(sub || 'profilo da completare') + '</span>' +
      '</span>' +
      '<button class="pet-photo' + (foto ? ' has' : '') + '" title="' + titoloFoto + '" ' +
        'aria-label="' + titoloFoto + '" onclick="' + azioneFoto + '">' +
        (foto ? '<img src="' + foto + '" alt="">' : (prem ? icon('plus') : icon('lock'))) +
      '</button>' +
      (profiles.length > 1
        ? '<button class="pet-del" title="Rimuovi" aria-label="Rimuovi il profilo" ' +
          'onclick="deleteDog(\'' + d.id + '\')">' + icon('trash') + '</button>'
        : '') +
      '</div>';
  }).join('');
  const nota = document.getElementById('pets-note');
  if (nota) {
    const limite = profiles.length >= dogLimit();
    nota.textContent = !limite ? 'Tocca un gatto per renderlo attivo. Le conversazioni restano separate.'
      : (prem ? 'Hai raggiunto il massimo di ' + APP_CONFIG.maxDogsPremium + ' gatti.'
              : 'Con il piano gratuito puoi avere ' + APP_CONFIG.maxDogsFree +
                ' gatto. Con Premium fino a ' + APP_CONFIG.maxDogsPremium + '.');
  }
}

// Cambia il gatto attivo: nuovo contesto → resetta la conversazione
function switchDog(id) {
  closeDogSwitcher();
  if (id === getActiveDogId()) return;
  const profiles = getProfiles();
  if (!profiles.some(d => d.id === id)) return;
  localStorage.setItem(APP_CONFIG.storagePrefix+'active_dog', id);
  loadActiveDogIntoOnboarding();
  mirrorActiveDog();
  restoreActiveDogConversation(); // carica la memoria del gatto scelto (Premium) o welcome
  aggiornaTestiChat();
  renderDogSwitcher();
  updateMessageCounter();
  if (currentScreen === 'pets') go('chat');   // il cambio ha effetto dove si vede
}

// Aggiunge un nuovo gatto (gated: free=maxDogsFree, premium=maxDogsPremium)
function addDog() {
  closeDogSwitcher();
  const profiles = getProfiles();
  if (profiles.length >= dogLimit()) {
    if (!isPremium()) {
      addMessageSafe(`Con il piano gratuito puoi avere ${APP_CONFIG.maxDogsFree} gatto. Con NeosCat Premium ne gestisci fino a ${APP_CONFIG.maxDogsPremium}.`);
      showUpgradePrompt();
    } else {
      alert(`Hai raggiunto il massimo di ${APP_CONFIG.maxDogsPremium} gatti.`);
    }
    return;
  }
  // Nuovo gatto: azzero i campi ma mantengo i dati del proprietario
  const ownerName = onboardingData.ownerName || '';
  const ownerEmail = onboardingData.ownerEmail || '';
  Object.keys(onboardingData).forEach(k => delete onboardingData[k]);
  onboardingData.ownerName = ownerName;
  onboardingData.ownerEmail = ownerEmail;   // niente id → completeOnboarding ne crea uno
  isEditingProfile = false;
  isAddingDog = true;
  onboardingReturn = currentScreen || 'pets';   // §11: si torna da dove si e' entrati
  go('onboarding');
  prefillOnboarding();   // proprietario + consensi spuntati, campi gatto vuoti
  aggiornaIntestazioneOnboarding();
  onboardingStep = 2;    // salta lo step proprietario (già noto)
  hideError();
  updateOnboardingUI();
}

// Rimuove un gatto; se era attivo passa al primo rimasto. Mai sotto 1 gatto.
function deleteDog(id) {
  let profiles = getProfiles();
  if (profiles.length <= 1) return;
  const dog = profiles.find(d => d.id === id);
  if (!confirm(`Rimuovere il profilo di ${dog ? (dog.dogName||'questo gatto') : 'questo gatto'}? L'operazione non è reversibile.`)) return;
  const wasActive = (id === getActiveDogId());
  profiles = profiles.filter(d => d.id !== id);
  saveProfiles(profiles);
  clearDogHistory(id); // rimuovi anche la memoria salvata del gatto eliminato
  clearDogReminders(id); // rimuovi i promemoria e annulla le notifiche del gatto eliminato
  removeDogPhoto(id); // rimuovi anche l'eventuale foto salvata
  if (wasActive) {
    localStorage.setItem(APP_CONFIG.storagePrefix+'active_dog', profiles[0].id);
    loadActiveDogIntoOnboarding();
    mirrorActiveDog();
    restoreActiveDogConversation();
    aggiornaTestiChat();
  }
  renderDogSwitcher();
}

function setRadio(field, value) {
  document.querySelectorAll(`.radio-option[data-field="${field}"]`).forEach(o => {
    o.classList.toggle('selected', o.dataset.value === value);
  });
}

// Pre-compila tutti i campi dell'onboarding con i dati del profilo salvato
function prefillOnboarding() {
  const p = onboardingData;
  if (!p) return;
  // Step 1 — proprietario
  document.getElementById('owner-name').value = p.ownerName || '';
  document.getElementById('owner-email').value = p.ownerEmail || '';
  document.getElementById('consent-age').checked = true;
  document.getElementById('consent-privacy').checked = true;
  // Step 2 — gatto
  document.getElementById('dog-name').value = p.dogName || '';
  const pronIn = document.getElementById('dog-name-pron');
  if (pronIn) pronIn.value = p.namePhonetic || '';
  const pronBox = document.getElementById('pron-box');
  if (pronBox) pronBox.classList.toggle('open', !!p.namePhonetic);   // già impostata → si vede
  const breedSelect = document.getElementById('dog-breed');
  const breedOther = document.getElementById('dog-breed-other');
  const breedValues = Array.from(breedSelect.options).map(o => o.value);
  if (p.breed && breedValues.includes(p.breed)) {
    breedSelect.value = p.breed;
    breedOther.style.display = 'none';
    breedOther.value = '';
  } else if (p.breed) {
    breedSelect.value = 'altra';
    breedOther.style.display = 'block';
    breedOther.value = p.breed;
  } else {
    breedSelect.value = '';
    breedOther.style.display = 'none';
  }
  document.getElementById('dog-age-years').value = (p.ageYears != null ? p.ageYears : '');
  document.getElementById('dog-age-months').value = (p.ageMonths != null ? p.ageMonths : '');
  document.getElementById('dog-weight').value = p.weight || '';
  setRadio('dog-sex', p.sex || '');
  setRadio('dog-neutered', p.neutered || '');
  // Step 3 — salute
  const conditions = p.conditions || [];
  document.querySelectorAll('#conditions-list input').forEach(cb => { cb.checked = conditions.includes(cb.value); });
  const knownCond = Array.from(document.querySelectorAll('#conditions-list input')).map(cb => cb.value);
  document.getElementById('conditions-other').value = conditions.filter(c => !knownCond.includes(c)).join(', ');
  document.getElementById('dog-meds').value = p.medications || '';
  const vaccines = p.vaccines || [];
  document.querySelectorAll('#vaccines-list input').forEach(cb => { cb.checked = vaccines.includes(cb.value); });
  document.getElementById('dog-diet').value = p.diet || '';
  document.getElementById('dog-activity').value = p.activity || '';
  // Profilo clinico esteso
  document.getElementById('dog-allergies').value = p.allergies || '';
  document.getElementById('dog-clinical-notes').value = p.clinicalNotes || '';
  renderWeightLog();
  applyClinicalGating();
}

// ═══ AVATAR FÉLICETTE (clona SVG nel slot e gestisce stati) ═══
function insertFélicetteAvatar() {
  const template = document.getElementById('dog-template');
  const slot = document.getElementById('avatar-slot');
  if (!template || !slot || slot.querySelector('svg')) return;
  slot.appendChild(template.content.cloneNode(true));
}
function setAvatarState(state) {
  const slot = document.getElementById('avatar-slot');
  if (!slot) return;
  ['idle','speaking','happy'].forEach(s => slot.classList.remove(s));
  slot.classList.add(state);
  const status = document.getElementById('avatar-status');
  if (status) {
    if (state === 'speaking') status.textContent = 'Sta scrivendo…';
    else if (state === 'happy') status.textContent = 'Pronta ad aiutarti!';
    else status.textContent = 'In ascolto...';
  }
}

// ═══ BUILD SYSTEM PROMPT (inietta profilo gatto) ═══
// ═══ PROFILO CLINICO ESTESO (Premium): allergie, storia, andamento peso ═══
// Mostra i campi solo ai Premium; ai free mostra l'invito all'upgrade.
function applyClinicalGating() {
  const fields = document.getElementById('clinical-fields');
  if (!fields) return;
  const premium = isPremium();
  fields.style.display = premium ? '' : 'none';
  const msg = document.getElementById('clinical-locked-msg');
  if (msg) msg.style.display = premium ? 'none' : '';
  const badge = document.getElementById('clinical-badge');
  if (badge) badge.style.display = premium ? 'none' : '';
  const ext = document.getElementById('clinical-ext');
  if (ext) ext.classList.toggle('locked', !premium); // abilita il cursore a mano sulla card bloccata
}
// Clic sulla card clinica bloccata (free) → apre il modale Premium, come "Aggiungi gatto"
function clinicalUpgrade() {
  if (!isPremium()) showUpgradePrompt();
}

// ═══ SCHEDE RAZZA (Premium): profilo approfondito della razza, on-demand ═══
// Riusa la normale pipeline di invio: conta nella quota, passa dal system prompt
// compliant e mostra il disclaimer. Contenuto generale/educativo (entro Art. 348).
function generateBreedSheet() {
  if (!isPremium()) { showUpgradePrompt(); return; }
  if (isWaitingResponse) return;
  const breed = (onboardingData.breed || '').trim();
  const q = breed
    ? `Fammi una scheda completa della razza ${breed} con quattro sezioni brevi e distinte: predisposizioni di salute tipiche, alimentazione consigliata, attività fisica ideale e aspettativa di vita media. Sii completo ma sintetico, chiudendo ogni sezione. Resta su informazioni generali ed educative.`
    : `Fammi una scheda completa sulla razza del mio gatto con quattro sezioni brevi e distinte: predisposizioni di salute tipiche, alimentazione consigliata, attività fisica ideale e aspettativa di vita media. Sii completo ma sintetico, chiudendo ogni sezione. Resta su informazioni generali ed educative.`;
  const input = document.getElementById('input-field');
  if (!input) return;
  input.value = q;
  sendMessage({ maxTokens: BREED_SHEET_MAX_TOKENS });
}
function renderWeightLog() {
  const list = document.getElementById('weight-log-list');
  if (!list) return;
  const log = onboardingData.weightLog || [];
  if (!log.length) { list.innerHTML = '<div class="wl-empty">Nessuna pesata registrata.</div>'; return; }
  list.innerHTML = log.map((e,i) =>
    `<div class="wl-row"><span>${escapeHtml(e.date||'')}</span><span>${escapeHtml(String(e.kg))} kg</span>`
    + `<button type="button" class="wl-del" onclick="removeWeightEntry(${i})">✕</button></div>`
  ).join('');
}
function addWeightEntry() {
  const dateEl = document.getElementById('wl-date');
  const kgEl = document.getElementById('wl-kg');
  const date = dateEl.value;
  const kg = parseFloat(kgEl.value);
  if (!date || !(kg > 0)) { showError('Inserisci una data e un peso validi'); return; }
  if (!Array.isArray(onboardingData.weightLog)) onboardingData.weightLog = [];
  onboardingData.weightLog.push({ date, kg });
  onboardingData.weightLog.sort((a,b) => (a.date||'').localeCompare(b.date||''));
  dateEl.value = ''; kgEl.value = '';
  hideError();
  renderWeightLog();
  /* ⚠️ §24 — questa funzione scrive DIRETTAMENTE in onboardingData e non passa da
     saveStepData(): il salvataggio agganciato agli eventi dei campi non la vede.
     Stessa forma del difetto dei pulsanti sì/no. */
  if (typeof salvaBozza === 'function') salvaBozza();
}
function removeWeightEntry(i) {
  if (!Array.isArray(onboardingData.weightLog)) return;
  onboardingData.weightLog.splice(i, 1);
  renderWeightLog();
  if (typeof salvaBozza === 'function') salvaBozza();
}

// ═══ PROMEMORIA (Premium) — lista per gatto + notifiche native (opzione B) ═══
// Chiave storage: ncat_reminders_<dogId> → array di promemoria.
// La LISTA è puro client (funziona ovunque). Le NOTIFICHE vere sono solo native
// Android via @capacitor/local-notifications: sul web non esistono notifiche
// locali programmabili affidabili senza un server push. I promemoria sono
// organizzativi (non clinici) → nessun impatto compliance, non toccano la quota.
const REM_CHANNEL = 'neoscat-reminders';
let notifPermission = null; // 'granted' | 'denied' | 'prompt' | ... (cache, solo nativo)
function _LN() {
  return (IS_NATIVE && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.LocalNotifications) || null;
}

function reminderTypeMeta(type) {
  return ({
    vaccino:          { ic:'💉', label:'Vaccino / richiamo' },
    antiparassitario: { ic:'🪲', label:'Antiparassitario' },
    visita:           { ic:'🩺', label:'Visita di controllo' },
    farmaco:          { ic:'💊', label:'Promemoria farmaco' },
    altro:            { ic:'📌', label:'Promemoria' }
  })[type] || { ic:'📌', label:'Promemoria' };
}
function getReminders(dogId) {
  if (!dogId) return [];
  try { const a = JSON.parse(localStorage.getItem(APP_CONFIG.storagePrefix+'reminders_'+dogId)||'null'); return Array.isArray(a) ? a : []; }
  catch(e){ return []; }
}
function saveReminders(dogId, arr) {
  if (!dogId) return;
  try { localStorage.setItem(APP_CONFIG.storagePrefix+'reminders_'+dogId, JSON.stringify(arr)); }
  catch(e){ console.warn('[Promemoria] salvataggio fallito (quota localStorage?):', e); }
}

function showRemErr(msg){ const e=document.getElementById('rem-err'); if(e){ e.textContent=msg; e.classList.add('show'); } }
function hideRemErr(){ const e=document.getElementById('rem-err'); if(e) e.classList.remove('show'); }

// Apertura/chiusura del modale. Gated come le altre funzioni Premium.
function openReminders() {
  if (!isPremium()) { showUpgradePrompt(); return; }
  const dog = getActiveDog();
  const nameEl = document.getElementById('rem-dog-name');
  if (nameEl) nameEl.textContent = (dog && dog.dogName) ? 'di ' + dog.dogName : '';
  document.getElementById('rem-type').value = 'vaccino';
  document.getElementById('rem-title').value = '';
  document.getElementById('rem-date').value = '';
  const tm = document.getElementById('rem-time'); if (tm) tm.value = '09:00';
  hideRemErr();
  renderReminders();
  updateNotifNote();
  document.getElementById('rem-modal').classList.add('open');
  // Su Android, alla prima apertura chiediamo il permesso notifiche (Android 13+)
  if (IS_NATIVE && notifPermission !== 'granted') {
    ensureNotifPermission().then(updateNotifNote);
  }
}
function closeReminders() { document.getElementById('rem-modal').classList.remove('open'); }

// Nota informativa in cima al modale: cambia in base a piattaforma e permesso.
function updateNotifNote() {
  const el = document.getElementById('rem-native-note'); if (!el) return;
  el.classList.remove('off');
  if (!IS_NATIVE) {
    el.innerHTML = icon('clipboard') + ' Qui tieni l\'elenco delle scadenze del tuo gatto. Le <strong>notifiche di sistema</strong> — l\'avviso all\'orario giusto anche con l\'app chiusa — arrivano con l\'app Android di NeosCat.';
    return;
  }
  if (notifPermission === 'granted') {
    el.innerHTML = '🔔 Tutto pronto: riceverai una notifica all\'orario impostato, anche con l\'app chiusa.';
  } else {
    el.classList.add('off');
    el.innerHTML = '🔕 Notifiche disattivate: senza permesso non posso avvisarti. <button class="rem-note-btn" onclick="askNotifAgain()">Attiva le notifiche</button> (se non compare la richiesta, abilitale dalle impostazioni di Android).';
  }
}
async function askNotifAgain() { await ensureNotifPermission(); updateNotifNote(); renderReminders(); }

// Permesso notifiche (solo nativo). Ritorna true se concesso.
async function ensureNotifPermission() {
  const LN = _LN(); if (!LN) return false;
  try {
    let st = await LN.checkPermissions();
    if (st && st.display === 'granted') { notifPermission = 'granted'; return true; }
    const r = await LN.requestPermissions();
    notifPermission = (r && r.display) || 'denied';
    return notifPermission === 'granted';
  } catch(e){ notifPermission = 'denied'; return false; }
}

function reminderWhen(dateStr) {
  const today = new Date(); today.setHours(0,0,0,0);
  const d = new Date(dateStr+'T00:00:00');
  if (isNaN(d)) return { txt:'', cls:'' };
  const diff = Math.round((d - today)/86400000);
  if (diff < 0)   return { txt:'scaduto', cls:'past' };
  if (diff === 0) return { txt:'oggi', cls:'soon' };
  if (diff === 1) return { txt:'domani', cls:'soon' };
  if (diff <= 7)  return { txt:'tra '+diff+' giorni', cls:'soon' };
  return { txt:'tra '+diff+' giorni', cls:'' };
}
function fmtRemDate(dateStr, timeStr) {
  try {
    const s = new Date(dateStr+'T00:00:00').toLocaleDateString('it-IT', { weekday:'short', day:'numeric', month:'short', year:'numeric' });
    return timeStr ? s + ' · ' + timeStr : s;
  } catch(e){ return dateStr; }
}
function renderReminders() {
  const list = document.getElementById('rem-list'); if (!list) return;
  const dogId = getActiveDogId();
  const arr = getReminders(dogId).slice().sort((a,b) => (a.date+(a.time||'')).localeCompare(b.date+(b.time||'')));
  if (!arr.length) { list.innerHTML = '<div class="rem-empty">Nessun promemoria per ora.</div>'; return; }
  list.innerHTML = arr.map(r => {
    const m = reminderTypeMeta(r.type);
    const w = reminderWhen(r.date);
    const note = r.title ? '<div class="rem-sub">'+escapeHtml(r.title)+'</div>' : '';
    return '<div class="rem-row">'
      + '<span class="rem-ic">'+m.ic+'</span>'
      + '<div class="rem-body"><div class="rem-label">'+escapeHtml(m.label)+'</div>'
      +   '<div class="rem-sub">'+escapeHtml(fmtRemDate(r.date, r.time))+'</div>'+note+'</div>'
      + '<span class="rem-when '+w.cls+'">'+w.txt+'</span>'
      + '<button class="rem-del" onclick="removeReminder(\''+r.id+'\')" title="Elimina">✕</button>'
      + '</div>';
  }).join('');
}

async function addReminder() {
  const dogId = getActiveDogId();
  if (!dogId) { showRemErr('Aggiungi prima un gatto.'); return; }
  const type  = document.getElementById('rem-type').value;
  const title = document.getElementById('rem-title').value.trim();
  const date  = document.getElementById('rem-date').value;
  const time  = document.getElementById('rem-time').value || '09:00';
  if (!date) { showRemErr('Scegli una data.'); return; }
  const when = new Date(date+'T'+time+':00');
  if (isNaN(when)) { showRemErr('Data o ora non valide.'); return; }
  const arr = getReminders(dogId);
  if (arr.length >= (APP_CONFIG.maxRemindersPerDog || 30)) {
    showRemErr('Hai raggiunto il numero massimo di promemoria per questo gatto.'); return;
  }
  hideRemErr();
  const rem = {
    id: 'rem_' + Date.now().toString(36) + Math.random().toString(36).slice(2,6),
    type, title, date, time,
    notifyId: Math.floor(Math.random()*2000000000) + 1, // int 32-bit per le notifiche native
    createdAt: Date.now()
  };
  arr.push(rem);
  saveReminders(dogId, arr);
  const dog = getActiveDog();
  await scheduleReminderNotification(rem, dog && dog.dogName);
  document.getElementById('rem-title').value = ''; // pronto per il prossimo
  renderReminders();
}

async function removeReminder(id) {
  const dogId = getActiveDogId();
  let arr = getReminders(dogId);
  const rem = arr.find(r => r.id === id);
  if (!rem) return;
  if (!confirm('Eliminare questo promemoria?')) return;
  await cancelReminderNotification(rem.notifyId);
  arr = arr.filter(r => r.id !== id);
  saveReminders(dogId, arr);
  renderReminders();
}

// ── Notifiche native (solo Android) ───────────────────────────────────
async function scheduleReminderNotification(rem, dogName) {
  const LN = _LN(); if (!LN) return;
  if (notifPermission !== 'granted') return;          // senza permesso non programmo
  const at = new Date(rem.date+'T'+(rem.time||'09:00')+':00');
  if (isNaN(at) || at.getTime() <= Date.now()) return; // niente notifiche nel passato
  const m = reminderTypeMeta(rem.type);
  let body = m.label + (dogName ? ' · ' + dogName : '');
  if (rem.title) body += ' — ' + rem.title;
  try {
    await LN.schedule({ notifications: [{
      id: rem.notifyId,
      title: APP_CONFIG.icon + ' ' + APP_CONFIG.name + ' — Promemoria',
      body,
      schedule: { at, allowWhileIdle: true },
      channelId: REM_CHANNEL
    }]});
  } catch(e){ console.warn('[Promemoria] schedule fallito:', e); }
}
async function cancelReminderNotification(notifyId) {
  const LN = _LN(); if (!LN || !notifyId) return;
  try { await LN.cancel({ notifications: [{ id: notifyId }] }); } catch(e){}
}
// All'avvio (nativo): ricrea il canale e ri-programma tutti i promemoria futuri.
// schedule() con lo stesso id è idempotente (sostituisce), quindi è sicuro.
async function syncReminderNotifications() {
  const LN = _LN(); if (!LN) return;
  try { const st = await LN.checkPermissions(); notifPermission = (st && st.display) || notifPermission; } catch(e){}
  if (notifPermission !== 'granted') return;
  for (const dog of getProfiles()) {
    for (const rem of getReminders(dog.id)) {
      await scheduleReminderNotification(rem, dog.dogName);
    }
  }
}
async function initReminderNotifications() {
  const LN = _LN(); if (!LN) return;
  try {
    if (LN.createChannel) {
      await LN.createChannel({ id: REM_CHANNEL, name: 'Promemoria salute', description: 'Avvisi per vaccini, antiparassitari e visite', importance: 4 });
    }
  } catch(e){}
  syncReminderNotifications();
}
// Rimozione promemoria di un gatto (chiamata quando si elimina il gatto)
async function clearDogReminders(dogId) {
  if (!dogId) return;
  for (const rem of getReminders(dogId)) { await cancelReminderNotification(rem.notifyId); }
  localStorage.removeItem(APP_CONFIG.storagePrefix+'reminders_'+dogId);
}
// Annulla TUTTE le notifiche pendenti (chiamata alla cancellazione totale dei dati)
async function cancelAllReminderNotifications() {
  const LN = _LN(); if (!LN) return;
  try {
    const pending = await LN.getPending();
    const ns = (pending && pending.notifications) || [];
    if (ns.length) await LN.cancel({ notifications: ns.map(n => ({ id: n.id })) });
  } catch(e){}
}

// Costruisce SOLO l'anagrafica del gatto. Il ruolo, i vincoli dell'art. 348 C.P., la
// trasparenza AI e il formato stanno nel Worker e non passano più di qui: il Worker
// incapsula questo blocco fra marcatori e lo dichiara DATO, non istruzione.
function buildProfileBlock() {
  let prompt = '';
  const profile = JSON.parse(localStorage.getItem(APP_CONFIG.storagePrefix+'profile')||'null');
  if (!profile) return '';
  prompt += '═══ PROFILO DEL GATTO ═══';
  if (profile.dogName) prompt += `\nNome: ${profile.dogName}`;
  if (profile.breed) prompt += ` | Razza: ${profile.breed}`;
  if (profile.ageYears || profile.ageMonths) prompt += ` | Età: ${profile.ageYears||0} anni ${profile.ageMonths?'e '+profile.ageMonths+' mesi':''}`;
  if (profile.weight) prompt += ` | Peso: ${profile.weight} kg`;
  if (profile.sex) prompt += ` | Sesso: ${profile.sex==='M'?'Maschio':'Femmina'}`;
  if (profile.neutered) prompt += ` | ${profile.sex==='M'?'Castrato':'Sterilizzata'}: ${profile.neutered}`;
  if (profile.conditions && profile.conditions.length > 0 && !profile.conditions.includes('Nessuna patologia nota')) {
    prompt += `\n\nPATOLOGIE NOTE: ${profile.conditions.join(', ')}`;
  }
  if (profile.medications) prompt += `\nFARMACI IN CORSO: ${profile.medications}`;
  if (profile.vaccines && profile.vaccines.length > 0) {
    prompt += `\nVACCINI: ${profile.vaccines.join(', ')}`;
  }
  if (profile.diet) prompt += `\nALIMENTAZIONE: ${profile.diet}`;
  if (profile.activity) prompt += `\nATTIVITÀ: ${profile.activity}`;
  // Profilo clinico esteso: SOLO Premium, e SOLO come contesto di background.
  if (isPremium()) {
    const hasExt = profile.allergies || profile.clinicalNotes || (profile.weightLog && profile.weightLog.length);
    if (hasExt) {
      prompt += '\n\n═══ CONTESTO CLINICO ESTESO (solo background) ═══';
      prompt += '\nQuesti dati servono ESCLUSIVAMENTE come contesto di background per rendere le risposte più pertinenti. NON usarli per formulare diagnosi, valutare interazioni tra farmaci, suggerire dosaggi o terapie: per qualsiasi valutazione clinica rimanda sempre al veterinario.';
      if (profile.allergies) prompt += `\nAllergie note: ${profile.allergies}`;
      if (profile.clinicalNotes) prompt += `\nStoria clinica / note: ${profile.clinicalNotes}`;
      if (profile.weightLog && profile.weightLog.length) {
        prompt += `\nAndamento del peso: ${profile.weightLog.map(e => `${e.date}: ${e.kg} kg`).join(' · ')}`;
      }
    }
  }
  prompt += '\n═══ FINE PROFILO ═══';
  prompt += '\nUsa questi dati per personalizzare ogni risposta alla situazione specifica di questo gatto.';
  return prompt;
}

// ═══ EMERGENZE VETERINARIE ═══
function checkEmergency(text) {
  const t = text.toLowerCase();
  return [
    'non respira','non riesce a respirare','sta soffocando',
    'ha mangiato veleno','ha ingerito','cioccolato','antigelo','xilitolo',
    'convulsioni','sta convulsando','collasso','è collassato','è svenuto',
    'sanguina molto','emorragia','pancia gonfia','torsione',
    'non si muove','non si alza','è morto','sta morendo',
    'investito','caduto dal balcone','trauma','frattura esposta'
  ].some(k => t.includes(k));
}

// ═══ FREEMIUM / PREMIUM ═══
function isPremium() {
  if (APP_CONFIG.devForcePremium) return true; // ⚠️ solo per collaudo (vedi APP_CONFIG.devForcePremium)
  const sub = JSON.parse(localStorage.getItem(APP_CONFIG.storagePrefix+'subscription')||'null');
  if (!sub) return false;
  return sub.active && new Date(sub.expiresAt) > new Date();
}
function getMaxMessages() {
  // Usa il valore in cache dal Worker SOLO se coerente con lo stato Premium locale:
  // subito dopo un upgrade la cache è ancora quella free (cap 5) → andrebbe mostrato
  // "0 su 5" accanto a "⭐ Premium". In caso di incoerenza, ripiega sui valori giusti.
  if (typeof quota.cap === 'number' && quota.premium === isPremium()) return quota.cap;
  return isPremium() ? APP_CONFIG.premiumQuota30d : APP_CONFIG.freeQuota30d;
}
function getRemainingMessages() {
  const used = (typeof quota.used === 'number') ? quota.used : 0;
  return Math.max(0, getMaxMessages() - used);
}
function fmtResetDate(iso) {
  if (!iso) return '';
  try { return new Date(iso).toLocaleDateString('it-IT', { day:'numeric', month:'long' }); }
  catch(e){ return ''; }
}
function updateMessageCounter() {
  const el = document.getElementById('message-counter');
  const manage = document.getElementById('manage-sub-link');
  const premium = isPremium();
  if (manage) manage.style.display = premium ? 'inline' : 'none';
  // Tag "Premium" sui pulsanti gated (scheda razza, promemoria salute): nascondili quando
  // si è Premium. Prima del return anticipato, così avviene anche se il contatore non c'è.
  document.querySelectorAll('.breed-tag').forEach(b => { b.style.display = premium ? 'none' : ''; });
  applyFreeGate(); // mostra/nasconde input vs gate Premium (solo PWA)
  if (!el) return;
  if (premium) {
    const left = getRemainingMessages();
    el.innerHTML = icon('star') + ` Premium — ${left} rispost${left===1?'a':'e'} su ${getMaxMessages()} (30 gg)`;
  } else if (!IS_NATIVE) {
    // PWA free (pseudo-freemium): anteprima gratuita, input riservato al Premium
    el.innerHTML = icon('star') + ' Anteprima gratuita — passa a Premium per chiedere del tuo gatto';
  } else {
    const left = getRemainingMessages();
    el.textContent = `${left} rispost${left===1?'a':'e'} gratis su ${getMaxMessages()} (30 gg)`;
  }
}

/* Dal paywall al modale del codice: si CHIUDE il paywall prima di aprire l'altro,
   o resterebbero due modali sovrapposti e la freccia indietro ne chiuderebbe uno
   solo (§0.5).
   ⚠️ Sulla nativa la voce non si mostra: là l'acquisto passa da Google Play e i
   codici valgono per il canale PWA. */
function codiceDalPaywall() {
  closeUpgradeModal();
  openCodeModal();
}

function showUpgradePrompt() {
  /* §17b — l'elenco del modale nasce dalla STESSA sorgente della vetrina e
     del benvenuto. Tre testi diversi, un elenco solo: alla prossima funzione
     aggiunta non ne resta indietro nessuno. */
  const ul = document.getElementById('upgrade-list');
  if (ul) ul.innerHTML = FUNZIONI_PREMIUM.map(f =>
    '<li data-fn="' + f.id + '"><span>' + icon(f.ico) + '</span> ' + f.vetrina + '</li>').join('');
  // Sul nativo il Premium si compra col billing di Google Play: il modale si apre solo
  // se il billing è pronto (app installata dallo store col prodotto attivo).
  if (IS_NATIVE && !playBillingReady) return;
  // Prezzi mostrati (da APP_CONFIG; il Price reale è gestito dal Worker)
  const pp  = document.getElementById('price-premium');      if (pp)  pp.textContent  = APP_CONFIG.premiumPriceYear;
  const ppp = document.getElementById('price-premium-plus'); if (ppp) ppp.textContent = APP_CONFIG.premiumPlusPriceYear;
  const sel = document.getElementById('plan-select');
  if (IS_NATIVE) {
    // Sul nativo l'acquisto passa da Google Play: i DUE tier annuali su Play sono
    // attività di LANCIO (creare i 2 prodotti in Play Console e mapparli in
    // buyPremiumNative). Finché non esistono, nascondiamo il selettore.
    if (sel) sel.style.display = 'none';
  } else {
    if (sel) sel.style.display = '';
    selectPlan('premium_plus'); // sul web il tier alto è consigliato di default
  }
  const consent = document.querySelector('.consent-recesso');
  const btn = document.querySelector('.upgrade-btn-primary');
  if (IS_NATIVE) {
    // Acquisto via Google Play: il consenso/recesso e i diritti consumatore sono gestiti
    // nel flusso d'acquisto di Google (merchant of record) → niente checkbox Stripe.
    if (consent) consent.style.display = 'none';
    if (btn) btn.disabled = false;
  } else {
    if (consent) consent.style.display = '';
    // reset del consenso recesso a ogni apertura del modale
    const cb = document.getElementById('consent-recesso');
    if (cb) cb.checked = false;
    toggleCheckoutBtn();
  }
  /* La voce del codice solo in PWA, come le altre tre: sulla nativa i codici non
     valgono e offrirli manderebbe l'utente in un vicolo cieco. */
  const codeLink = document.getElementById('upgrade-code-link');
  if (codeLink) codeLink.style.display = IS_NATIVE ? 'none' : '';
  document.getElementById('upgrade-modal').classList.add('visible');
}
// Il pulsante "Passa a Premium" è abilitato solo dopo il consenso al recesso
function toggleCheckoutBtn() {
  const cb = document.getElementById('consent-recesso');
  const btn = document.querySelector('.upgrade-btn-primary');
  if (btn) btn.disabled = !(cb && cb.checked);
}
// Tier scelto nel modale ('premium' | 'premium_plus'). Inviato al Worker, che instrada al Price.
let selectedPlan = 'premium_plus';
function selectPlan(plan) {
  selectedPlan = (plan === 'premium') ? 'premium' : 'premium_plus';
  const a = document.getElementById('plan-premium');
  const b = document.getElementById('plan-premium-plus');
  if (a) a.classList.toggle('selected', selectedPlan === 'premium');
  if (b) b.classList.toggle('selected', selectedPlan === 'premium_plus');
}
function closeUpgradeModal() {
  document.getElementById('upgrade-modal').classList.remove('visible');
}

// ═══ CANALE CODICI PREMIUM (riscatto in-app + deep-link da QR/link) ═══════
let codiceDaPendente = false;   // il modale e' stato aperto da un codice in attesa?
function openCodeModal() {
  const m = document.getElementById('code-modal');
  if (!m) return;
  const msg = document.getElementById('free-code-msg');
  if (msg) { msg.textContent = ''; msg.className = 'free-code-msg'; }
  const btn = document.getElementById('free-code-btn');
  if (btn) { btn.disabled = false; btn.textContent = 'Riscatta'; }
  m.classList.add('open');
  const i = document.getElementById('free-code-input');
  if (i) setTimeout(() => i.focus(), 80);
}
/* ⚠️ Se il modale e' stato aperto DA SOLO per un codice pendente, chiuderlo deve
   anche togliere quel codice: altrimenti riappare al prossimo avvio, e l'utente
   ha chiuso una finestra che torna. */
function closeCodeModal() {
  if (codiceDaPendente) {
    try { localStorage.removeItem(APP_CONFIG.storagePrefix + 'pending_code'); } catch (e) {}
    codiceDaPendente = false;
  }
  const m = document.getElementById('code-modal');
  if (m) m.classList.remove('open');
}
// Compatibilità: il vecchio nome resta valido (deep-link, eventuali richiami residui).
function toggleFreeCode() { openCodeModal(); }
async function doRedeem(code, msgEl, btnEl) {
  const setMsg = (t, cls) => { if (msgEl) { msgEl.textContent = t; msgEl.className = 'free-code-msg ' + (cls || ''); } };
  if (!code) { setMsg('Inserisci un codice.', 'err'); return false; }
  if (btnEl) { btnEl.disabled = true; btnEl.textContent = 'Attendere…'; }
  setMsg('', '');
  let ok = false;
  try {
    const res = await fetch(API_PROXY_URL + '/redeem-code', {
      method: 'POST', headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ clientId, code })
    });
    const data = await res.json().catch(() => ({}));
    if (res.ok && data.premium && data.expiresAt) {
      localStorage.setItem(APP_CONFIG.storagePrefix + 'subscription',
        JSON.stringify({ active: true, expiresAt: data.expiresAt, plan: data.plan || null, source: 'code' }));
      try { localStorage.removeItem(APP_CONFIG.storagePrefix + 'pending_code'); } catch (e) {}
      // Evita che una quota in cache di un piano precedente faccia dedurre il tier sbagliato.
      try { localStorage.removeItem(APP_CONFIG.storagePrefix + 'quota'); } catch (e) {}
      setMsg(data.already ? '✅ Premium già attivo!' : '✅ Premium attivato!', 'ok');
      ultimaAttivazioneLocale = Date.now();   // §19.1: da qui parte la grazia
      negativiConsecutivi = 0;
      aggiornaDopoCambioPiano();
      refreshQuota();
      ok = true;
    } else if (res.status === 429) {
      // ⚠️ v1.0.65 — Il limite di frequenza per indirizzo IP (rilievo M-3) introduce
      // un rifiuto del server che NON è definitivo: è temporaneo. Senza questo ramo
      // finirebbe nel caso qui sotto, che cancella il codice pendente — e chi ha
      // sbagliato a digitare dieci volte perderebbe per sempre quello buono.
      // Il codice NON è stato consumato: si conserva e si riprova più tardi.
      setMsg(data.error || 'Troppi tentativi. Riprova fra qualche minuto.', 'err', 'x-circle');
    } else {
      /* ⚠️ Rifiuto del SERVER: e' definitivo, quel codice non funzionera' mai. Se
         resta fra i pendenti, il modale si riapre DA SOLO a ogni avvio con lo
         stesso errore, e l'utente non ha modo di liberarsene. Si scarta qui.
         Diverso l'errore di RETE, gestito nel catch: la' il codice non e' stato
         consumato e va conservato per il ritentativo. */
      try { localStorage.removeItem(APP_CONFIG.storagePrefix + 'pending_code'); } catch (e) {}
      setMsg('❌ ' + (data.error || 'Codice non valido.'), 'err');
    }
  } catch (e) {
    // Errore di RETE: il codice non è stato consumato. Un solo ritentativo dopo una
    // pausa breve — su una PWA appena installata la prima richiesta parte spesso mentre
    // la connessione si sta ancora stabilizzando, e fallire lì significa che il Premium
    // arriva solo al riavvio successivo.
    console.warn('[Codice] rete non disponibile, ritento fra 1,5 s:', e && e.message);
    setMsg('Connessione lenta, riprovo…', '');
    try {
      await new Promise(r => setTimeout(r, 1500));
      const res2 = await fetch(API_PROXY_URL + '/redeem-code', {
        method: 'POST', headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ clientId, code })
      });
      const d2 = await res2.json().catch(() => ({}));
      if (res2.ok && d2.premium && d2.expiresAt) {
        localStorage.setItem(APP_CONFIG.storagePrefix + 'subscription',
          JSON.stringify({ active: true, expiresAt: d2.expiresAt, plan: d2.plan || null, source: 'code' }));
      ultimaAttivazioneLocale = Date.now();
        try { localStorage.removeItem(APP_CONFIG.storagePrefix + 'pending_code'); } catch (e3) {}
        try { localStorage.removeItem(APP_CONFIG.storagePrefix + 'quota'); } catch (e3) {}
        setMsg(d2.already ? 'Premium già attivo!' : 'Premium attivato!', 'ok', 'check');
        aggiornaDopoCambioPiano();
        refreshQuota();
        ok = true;
      } else {
        /* Il ritentativo ha RAGGIUNTO il server, che ha rifiutato: definitivo. */
        try { localStorage.removeItem(APP_CONFIG.storagePrefix + 'pending_code'); } catch (e3) {}
        setMsg(d2.error || 'Codice non valido.', 'err', 'x-circle');
      }
    } catch (e2) {
      setMsg('Errore di rete. Riprova.', 'err');
    }
  } finally {
    /* ⚠️ Al SUCCESSO il bottone non torna riscattabile: il codice e' consumato, e
       ripremerlo restituirebbe «codice esaurito» — un errore su un'operazione
       andata a buon fine. Si riabilita solo dopo un fallimento.
       REGOLA: un `finally` che ripristina lo stato iniziale e' corretto solo se
       l'operazione e' RIPETIBILE. Quando consuma qualcosa — un codice, un
       pagamento, un invito — lo stato finale del successo e' diverso da quello
       di partenza, e va scritto esplicitamente. */
    if (btnEl) {
      if (ok) { btnEl.disabled = true; btnEl.textContent = 'Attivato'; }
      else { btnEl.disabled = false; btnEl.textContent = 'Riscatta'; }
    }
    // Al successo il modale si chiude da solo dopo un attimo: il tempo di leggere
    // «Premium attivato!» e vedere il pulsante diventare «Attivato».
    if (ok) setTimeout(closeCodeModal, 1600);
  }
  return ok;
}
function redeemFreeCode() {
  const inp = document.getElementById('free-code-input');
  const code = inp && inp.value ? inp.value.trim().toUpperCase() : '';
  return doRedeem(code, document.getElementById('free-code-msg'), document.getElementById('free-code-btn'));
}
// Deep-link da QR / link: ?code=XXXX → riscatto automatico (solo PWA). Memorizza il codice
// come "pending" così sopravvive all'installazione (Android: storage condiviso; iOS standalone
// no → in quel caso si incolla a mano nell'ingresso "Ho un codice").
function handleCodeDeepLink() {
  if (IS_NATIVE) return;
  const KEY = APP_CONFIG.storagePrefix + 'pending_code';
  let urlCode = '';
  try { urlCode = (new URLSearchParams(location.search).get('code') || '').trim().toUpperCase(); } catch (e) {}
  if (urlCode) {
    try { localStorage.setItem(KEY, urlCode); } catch (e) {}
    try { history.replaceState(null, '', location.pathname); } catch (e) {}
  }
  let pending = '';
  try { pending = localStorage.getItem(KEY) || ''; } catch (e) {}
  if (!pending) return;
  if (isPremium()) { try { localStorage.removeItem(KEY); } catch (e) {} return; }
  setTimeout(() => {
    const inp = document.getElementById('free-code-input');
    console.log('[Codice] riscatto automatico da link/QR:', pending, '· clientId:', clientId);
    /* Nessun riallineamento qui: la corsa con /status e' gia' risolta alla radice
       (ultimaAttivazioneLocale, §19.1). Aggiungerne un secondo sarebbe una richiesta
       di rete in piu' per lo stesso problema, e due meccanismi che fanno la stessa
       cosa divergono appena qualcuno ne tocca uno. */
    const dopo = (ok) => console.log('[Codice] esito riscatto automatico:', ok ? 'ATTIVATO' : 'NON riuscito');
    if (inp) {
      openCodeModal();          // il riscatto avviene SOTTO GLI OCCHI dell'utente
      codiceDaPendente = true;  // chiudere il modale scartera' il codice
      inp.value = pending;
      Promise.resolve(redeemFreeCode()).then(dopo, () => dopo(false));
    } else {
      Promise.resolve(doRedeem(pending, null, null)).then(dopo, () => dopo(false));
    }
  }, 500);
}
async function startPremiumCheckout() {
  // Ramo nativo: acquisto col billing di Google Play (niente Stripe in-app: policy store)
  if (IS_NATIVE) { buyPremiumNative(); return; }
  const cb = document.getElementById('consent-recesso');
  if (!cb || !cb.checked) {
    alert('Per procedere devi confermare il consenso all\'attivazione immediata del servizio.');
    return;
  }
  const btn = document.querySelector('.upgrade-btn-primary');
  if (btn) { btn.disabled = true; btn.textContent = 'Attendere…'; }
  try {
    const res = await fetch(API_PROXY_URL + '/create-checkout', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ clientId, withdrawalConsent: true, plan: selectedPlan })
    });
    const data = await res.json();
    if (data.url) {
      window.location.href = data.url;   // redirect alla pagina di pagamento Stripe
    } else {
      alert('Impossibile avviare il pagamento: ' + (data.error || 'errore sconosciuto'));
    }
  } catch (e) {
    alert('Errore di rete nell\'avvio del pagamento. Riprova.');
  } finally {
    if (btn) { btn.disabled = false; btn.textContent = 'Passa a Premium'; }
  }
}

// Chiede al Worker se questo client è Premium e allinea lo stato locale
// Istante dell'ultima attivazione avvenuta su QUESTO dispositivo.
// ⚠️ Serve a non farsi cancellare il Premium da una risposta /status PARTITA PRIMA
// dell'attivazione: al bootstrap /status parte subito, il riscatto del codice avviene
// mezzo secondo dopo, e la risposta — che dice ancora "premium: false" perché il server
// non aveva ancora registrato il riscatto — arrivava DOPO e rimuoveva la sottoscrizione
// appena scritta. Da qui il Premium che compariva solo al riavvio successivo.
/* ═══ CAMBIO DI PIANO — RIDISEGNO UNICO (§19.2) ═══
   Lo stato Premium condiziona sette punti sparsi: contatore, blocco dell'input,
   annunci, voci della Home, vetrina, badge, e il gating del profilo clinico dentro
   l'onboarding. I QUATTRO punti che possono cambiare il piano — riscatto codice,
   verifica periodica, Stripe, Play — ne aggiornavano ciascuno un sottoinsieme
   diverso, e il profilo clinico restava bloccato finche' non si rientrava nel
   profilo una seconda volta. Qui si aggiorna tutto, e non conta piu' da dove
   e' arrivato il cambiamento.

   ⚠️⚠️ §19.4 — IN CHAT NON SI RIDISEGNA NULLA. `resetWelcome()` riscrive
   l'innerHTML del contenitore dei messaggi, cioe' CANCELLA LA CONVERSAZIONE, e
   questa funzione e' chiamata anche dal controllo periodico ogni 60 secondi: la
   risposta di Félicette sparirebbe a metà. Il passaggio free → Premium in chat
   e' gia' gestito da applyFreeGate() dentro updateMessageCounter(), che sblocca
   l'input senza toccare i messaggi.

   REGOLA: un ridisegno «uno solo e completo» e' giusto, ma «completo» significa
   tutti i CONSUMATORI dello stato, non tutte le schermate. Le schermate che
   contengono dati vivi vanno escluse esplicitamente. */
function aggiornaDopoCambioPiano() {
  try { updateMessageCounter(); } catch (e) {}   // contatore + applyFreeGate
  try { initAds(); } catch (e) {}
  try { applyClinicalGating(); } catch (e) {}    // ⚠️ era il pezzo che mancava
  try {
    if (currentScreen === 'home') renderHome();
    else if (currentScreen === 'settings') renderSettings();
    else if (currentScreen === 'pets') renderDogSwitcher();
    else if (currentScreen === 'breed') renderBreed();
    else if (currentScreen === 'fun') renderFunScreen();
    else if (currentScreen === 'onboarding') updateOnboardingUI();
    // chat: esclusa di proposito, vedi sopra
    if (currentScreen) renderHeader(currentScreen);
  } catch (e) { console.warn('[Piano] ridisegno parziale', e); }
}

/* Versione del Worker che ha risposto per ultimo. Serve solo alla diagnostica:
   e' il modo di sapere in campo se il backend distribuito e' quello nuovo. */
let versioneWorker = null;
let ultimoControlloStatus = 0;   // per la diagnostica: quanto manca al prossimo
let archivioPersistente = null;
let ultimaAttivazioneLocale = 0;
let negativiConsecutivi = 0;   // quante risposte /status negative di fila
let riconferma = null;
// ⚠️ 90 s non è un numero a caso: il KV di Cloudflare mantiene una cache di lettura per
// data center con TTL predefinito di 60 secondi. Dopo una scrittura, le letture da un
// colo che ha già la chiave in cache restituiscono il valore vecchio fino a un minuto.
// La grazia deve coprire quella finestra con un margine, altrimenti l'app disattiva un
// Premium appena attivato. Abbassarla sotto i 60 s riapre il difetto della v1.26.0.
const GRAZIA_ATTIVAZIONE_MS = 90 * 1000;
const RICONTROLLO_MS = 6 * 1000;           // seconda lettura ravvicinata prima di revocare

/* Chiede al browser di NON sfrattare i dati per far posto ad altri siti. Non
   protegge dalla rimozione manuale dell'app — quella cancella tutto comunque —
   ma toglie di mezzo l'altra causa di perdita, quella silenziosa. */
/* ═══ iOS: la rimozione dell'app cancella l'archivio ═══
   ⚠️ Vale SOLO per la PWA installata su iOS: lì l'archivio è isolato e il sistema
   lo cancella insieme all'icona. In browser no, su Android no, in nativa no.
   L'avviso si mostra solo a chi ha un abbonamento da perdere, e una volta sola. */
/* «Va mostrato lo STRUMENTO?» — a chiunque abbia un abbonamento in PWA.
   ⚠️ Il codice serve a ogni CAMBIO DI DISPOSITIVO, non solo alla rimozione su iOS:
   telefono nuovo, dati del browser svuotati, «Cancella i miei dati». Agganciarlo al
   rischio iOS lo nascondeva su Android — e chi ha attivato con un codice, che e'
   consumato e non si riscatta due volte, perdeva l'abbonamento per sempre senza
   aver mai visto il modo di conservarlo.
   In nativa no: la' l'acquisto passa da Play, che ha il proprio ripristino. */
function fonteAbbonamento() {
  try { return (JSON.parse(localStorage.getItem(APP_CONFIG.storagePrefix+'subscription')||'{}')).source || null; }
  catch (e) { return null; }
}
function isIOS() {
  return /iPad|iPhone|iPod/.test(navigator.userAgent) ||
         (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
}
function isInstallata() {
  return window.matchMedia('(display-mode: standalone)').matches || navigator.standalone === true;
}
function serveCodiceRipristino() {
  return !IS_NATIVE && isPremium();
}

/* «Serve l'AVVISO FORTE?» — solo dove il sistema cancella l'archivio da se'.
   La' e' un allarme; altrove il codice e' un promemoria per il cambio di telefono. */
function serveAvvisoArchivio() { return isIOS() && isInstallata() && isPremium(); }

// Non protegge dalla rimozione manuale — quella non è difendibile senza un account —
// ma toglie l'altra causa di perdita: lo sfratto silenzioso per mancanza di spazio.


// ── Codice di ripristino ──
// Chiude il §19.3: con Stripe non esiste un «Ho già pagato» come su Google Play, e
// senza questo il Premium è perso a ogni cambio di telefono.
// ⚠️⚠️ IL COMPROMESSO VA DETTO, NON NASCOSTO: chi ha il codice ha il Premium. È
// l'equivalente di una password, e l'app lo scrive dov'è visibile. Due dispositivi con
// la stessa identità CONDIVIDONO la quota — corretto per un abbonamento personale, ed è
// anche la difesa naturale se il codice circolasse.
const STORICO_ID_MAX = 5;

function storicoId() {
  try { return JSON.parse(localStorage.getItem(APP_CONFIG.storagePrefix + 'client_id_storico') || '[]'); }
  catch (e) { return []; }
}
/* `vecchio` e' l'id che si sta perdendo, `nuovo` quello che si adotta. Uno dei due
   puo' essere null: alla cancellazione il nuovo non esiste ancora, e nella pulizia
   della diagnostica non c'e' nulla da aggiungere. */
function aggiungiAStorico(vecchio, nuovo) {
  // ⚠️ Lo storico non deve contenere l'id ATTUALE: la diagnostica interroga /status su
  // ognuno, e trovare l'abbonamento su un id «precedente» che è quello in uso
  // produceva un avviso su un abbonamento perfettamente sano.
  let st = storicoId().filter(x => x && x !== nuovo);
  if (vecchio && vecchio !== nuovo) st.unshift(vecchio);
  st = st.slice(0, STORICO_ID_MAX);
  try { localStorage.setItem(APP_CONFIG.storagePrefix + 'client_id_storico', JSON.stringify(st)); } catch (e) {}
}


function mostraCodiceRipristino() {
  const t = document.getElementById('rip-testo');
  const c = document.getElementById('rip-codice');
  if (c) c.textContent = clientId;
  // I casi in cui serve, in un elenco: quelli comuni a tutti i sistemi, più quello
  // specifico di iPhone e iPad — dove è il sistema stesso a cancellare l'archivio.
  const casi = [
    'cambi telefono o tablet, o passi a un altro dispositivo',
    'usi «Cancella i miei dati» o svuoti i dati del browser'
  ];
  if (isIOS()) {
    casi.push('<b>su iPhone e iPad:</b> rimuovi l\'app dalla schermata Home — iOS cancella '
            + 'da sé i dati dell\'app, e senza questo codice l\'abbonamento non si recupera');
  } else {
    casi.push('disinstalli l\'app o reinstalli il sistema');
  }
  if (t) t.innerHTML = 'Questo è il codice che collega <b>questo dispositivo</b> al tuo abbonamento. '
    + 'Ti serve se:<ul class="rip-casi"><li>' + casi.join('</li><li>') + '</li></ul>'
    + (fonteAbbonamento() === 'code'
        ? '<div class="rip-avviso"><b>Il tuo abbonamento è stato attivato con un codice di attivazione.</b>'
          + 'Quel codice è già stato usato e non si riscatta due volte: questo è l\'unico modo di '
          + 'recuperare l\'abbonamento.</div>'
        : '')
    + '<b>Conservalo dove conservi le password:</b> chi lo ha può usare il tuo abbonamento.';
  const m = document.getElementById('rip-modal');
  if (m) { m.classList.add('open'); m.dataset.modo = 'mostra'; aggiornaRipModal(); }
}

function apriRipristino() {
  const m = document.getElementById('rip-modal');
  if (m) { m.classList.add('open'); m.dataset.modo = 'usa'; aggiornaRipModal(); }
}
function closeRipModal() {
  const m = document.getElementById('rip-modal');
  if (m) m.classList.remove('open');
  const e = document.getElementById('rip-msg'); if (e) e.textContent = '';
}

function aggiornaRipModal() {
  const m = document.getElementById('rip-modal');
  if (!m) return;
  const mostra = m.dataset.modo === 'mostra';
  document.getElementById('rip-blocco-mostra').style.display = mostra ? '' : 'none';
  document.getElementById('rip-blocco-usa').style.display = mostra ? 'none' : '';
  document.getElementById('rip-titolo').textContent = mostra ? 'Codice di ripristino' : 'Ho già un abbonamento';
  // L'indirizzo dell'assistenza vive in APP_CONFIG: un punto solo da cambiare.
  const sup = document.getElementById('rip-supporto');
  if (sup) sup.textContent = APP_CONFIG.supportEmail || 'assistenza';
}

function copiaCodiceRipristino() {
  const b = document.getElementById('rip-copia');
  const fatto = () => { if (b) b.textContent = 'Copiato ✓'; };
  try {
    if (navigator.clipboard) navigator.clipboard.writeText(clientId).then(fatto, () => {});
    else {
      const ta = document.createElement('textarea'); ta.value = clientId;
      document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); fatto();
    }
  } catch (e) {}
}

async function usaCodiceRipristino() {
  const inp = document.getElementById('rip-input');
  const msg = document.getElementById('rip-msg');
  const btn = document.getElementById('rip-usa-btn');
  const dire = (t, cls) => { if (msg) { msg.textContent = t; msg.className = 'rip-msg ' + (cls || ''); } };
  /* ⚠️ Si tolgono TUTTI gli spazi bianchi, non solo quelli ai bordi. `trim()` non
     basta: chi incolla il codice da un'email o da un'app di note si porta dietro
     spazi o ritorni a capo, e il codice viene rifiutato per «forma sbagliata» pur
     essendo giusto — un rifiuto che l'utente non puo' capire.
     ⚠️ DIVERGENZA DICHIARATA dalla gemella, che usa `trim()`: e' una migliorìa
     nostra, segnalata nella nota di ritorno. Se la adottano si toglie da qui. */
  const codice = (inp && inp.value || '').replace(/\s+/g, '');

  if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(codice)) {
    dire('Il codice non ha la forma giusta. Controlla di averlo incollato per intero.', 'err'); return;
  }

  // ⚠️ ORDINE: i controlli che RISOLVONO vengono prima di quelli che rifiutano.
  // Il proprio stesso codice, da free, è il caso PIÙ UTILE: l'abbonamento è stato
  // riattivato lato server e l'app non se ne è ancora accorta. Rifiutarlo con «non c'è
  // niente da ripristinare» negava aiuto proprio a chi ne aveva bisogno.
  if (codice === clientId) {
    if (isPremium()) { dire('Questo è già il codice di questo dispositivo, e il Premium è attivo.', ''); return; }
    dire('Ricontrollo lo stato…', '');
    await refreshPremiumStatus();
    aggiornaDopoCambioPiano();
    dire(isPremium() ? 'Premium ritrovato su questo dispositivo!' : 'Nessun abbonamento attivo per questo codice.',
         isPremium() ? 'ok' : 'err');
    return;
  }

  if (btn) { btn.disabled = true; btn.textContent = 'Verifico…'; }
  dire('Verifico il codice…', '');
  try {
    // ⚠️ È la verifica sul SERVER a rendere sicuro il meccanismo: un codice inventato
    // non passa, perché non esiste un record per lui.
    const r = await chiediAlWorker('/status', { clientId: codice });
    const d = await r.json().catch(() => ({}));
    if (!r.ok || !d.premium || !d.expiresAt) {
      dire('Nessun abbonamento attivo per questo codice.', 'err');
      return;
    }
    const fino = new Date(d.expiresAt).toLocaleDateString('it-IT');
    if (!confirm('Trovato un abbonamento ' + (d.plan === 'premium_plus' ? 'Premium Plus' : 'Premium')
      + ' valido fino al ' + fino + '.\n\nQuesto dispositivo assumerà quell\'identità.'
      + '\n\nSe l\'abbonamento è in uso su un altro dispositivo, i due CONDIVIDERANNO la quota'
      + ' di risposte: è un abbonamento personale.\n\nProcedere?')) {
      dire('Annullato.', ''); return;
    }
    const precedente = clientId;
    clientId = codice;
    deviceId = codice;
    try { localStorage.setItem(APP_CONFIG.storagePrefix + 'client_id', codice); } catch (e) {}
    aggiungiAStorico(precedente, codice);
    localStorage.setItem(APP_CONFIG.storagePrefix + 'subscription',
      JSON.stringify({ active: true, expiresAt: d.expiresAt, plan: d.plan || null, source: d.source || null }));
    ultimaAttivazioneLocale = Date.now();
    try { localStorage.removeItem(APP_CONFIG.storagePrefix + 'quota'); } catch (e) {}
    await refreshQuota();
    aggiornaDopoCambioPiano();
    dire('Abbonamento ripristinato su questo dispositivo!', 'ok');
    console.log('[Ripristino] identità adottata:', codice, '(precedente:', precedente + ')');
    setTimeout(() => { closeRipModal(); renderAccount(); }, 1600);
  } catch (e) {
    dire('Errore di rete: riprova quando hai connessione.', 'err');
  } finally {
    if (btn) { btn.disabled = false; btn.textContent = 'Ripristina'; }
  }
}



// ═══ DIMENSIONE DEL TESTO ═══
// Portata da LireaGut, dove è una scelta a due pulsanti. Qui è una riga come Tema e
// Voce, perché in questo pannello le preferenze si alternano al tocco mostrando il
// valore a destra: un terzo stile per la stessa cosa sarebbe una incoerenza.
// ⚠️ Non sostituisce lo zoom a due dita (WCAG 1.4.4, §1), che resta attivo: quello è un
// ingrandimento momentaneo, questa una preferenza che resta. Il sottotitolo lo dice,
// altrimenti chi la trova pensa che una escluda l'altra.
function testoGrande() {
  try { return localStorage.getItem(APP_CONFIG.storagePrefix + 'testo') === 'grande'; }
  catch (e) { return false; }
}

function applicaMisuraTesto() {
  document.body.classList.toggle('testo-grande', testoGrande());
}

function toggleMisuraTesto() {
  const nuovo = testoGrande() ? 'normale' : 'grande';
  try { localStorage.setItem(APP_CONFIG.storagePrefix + 'testo', nuovo); } catch (e) {}
  applicaMisuraTesto();
  /* ⚠️ `syncAccountToggles()` e' il nome LORO: da noi le Impostazioni non hanno
     interruttori da sincronizzare — si ridisegnano per intero con `renderSettings()`,
     che ricalcola anche il valore mostrato a destra. Copiare la chiamata alla lettera
     dava «syncAccountToggles is not defined» al primo tocco.
     E si ridisegna SOLO se siamo su quella schermata: altrove sarebbe lavoro inutile
     su una schermata che l'utente non sta guardando. */
  if (currentScreen === 'settings') renderSettings();
  // ⚠️ La chat va riportata in fondo: cambiando il corpo del testo le bolle cambiano
  // altezza e l'ultimo messaggio finisce fuori vista, come se fosse sparito.
  if (currentScreen === 'chat') {
    const c = document.getElementById('chat-container');
    if (c) requestAnimationFrame(() => { c.scrollTop = c.scrollHeight; });
  }
}

async function chiediArchivioPersistente() {
  try {
    if (!navigator.storage || !navigator.storage.persist) return;
    if (await navigator.storage.persisted()) { archivioPersistente = true; return; }
    archivioPersistente = await navigator.storage.persist();
    console.log('[Dati] archivio persistente:', archivioPersistente ? 'concesso' : 'negato');
  } catch (e) { /* non supportato: non e' un errore */ }
}


// ⚠️ v1.0.65 — /status e /quota si chiamano in POST, non più con il clientId nella query
// string (rilievo M-2). Il clientId è la credenziale che dà accesso all'abbonamento — è
// il «codice di ripristino» — e i parametri di query finiscono scritti nei log delle
// richieste. Una password non si mette in un URL.
// Un solo punto di chiamata per entrambe: i punti sparsi di prima erano altrettante
// occasioni di dimenticarne uno.
function chiediAlWorker(percorso, dati) {
  return fetch(API_PROXY_URL + percorso, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify(dati)
  });
}

async function refreshPremiumStatus() {
  const partita = Date.now();     // istante in cui la domanda lascia il dispositivo
  ultimoControlloStatus = partita;
  void partita;   // tenuto: e' l'istante che il §19.1 impone di confrontare
  try {
    const res = await chiediAlWorker('/status', { clientId });
    if (!res.ok) return;
    const data = await res.json();
    if (data.premium && data.expiresAt) {
      // Preserva la "source" locale (play/stripe): serve a "Gestisci abbonamento"
      const prev = JSON.parse(localStorage.getItem(APP_CONFIG.storagePrefix+'subscription')||'{}');
      localStorage.setItem(APP_CONFIG.storagePrefix+'subscription',
        /* ⚠️ La fonte arriva dal SERVER se la conosce, e solo in mancanza si ricade
           sulla copia locale. Prima era il contrario — cioe' solo la copia locale —
           e bastava una ricostruzione per perderla. */
        JSON.stringify({ active: true, expiresAt: data.expiresAt,
                         plan: data.plan || prev.plan || null,
                         source: data.source || prev.source || null }));
      negativiConsecutivi = 0;
      clearTimeout(riconferma);
      if (data.worker) versioneWorker = data.worker;   // per la diagnostica
    } else if (data.premium === false) {
      /* ⚠️⚠️ Non basta confrontare gli istanti. Il KV di Cloudflare e' a CONSISTENZA
         EVENTUALE: dopo il riscatto la scrittura impiega qualche secondo (a volte
         decine) a essere visibile a tutte le letture, quindi anche una richiesta
         partita DOPO l'attivazione puo' ricevere ancora «premium: false».
         Due difese, entrambe necessarie. */
      const dallAttivazione = Date.now() - ultimaAttivazioneLocale;
      if (ultimaAttivazioneLocale && dallAttivazione < GRAZIA_ATTIVAZIONE_MS) {
        // 1. grazia dopo un'attivazione avvenuta QUI: una risposta negativa in questa
        //    finestra e' quasi certamente una lettura KV non ancora propagata.
        console.log('[Premium] /status dice no, ma l\'attivazione locale è di',
                    Math.round(dallAttivazione / 1000) + 's fa: ignorata (grazia)');
      } else if (++negativiConsecutivi < 2) {
        // 2. una sola risposta negativa non revoca: serve una conferma, e si chiede
        //    SUBITO, non al giro dei 60 s, o una revoca legittima raddoppia i tempi.
        console.log('[Premium] /status dice no (1ª volta): richiedo conferma fra', RICONTROLLO_MS / 1000, 's');
        clearTimeout(riconferma);
        riconferma = setTimeout(() => refreshPremiumStatus(), RICONTROLLO_MS);
      } else {
        console.log('[Premium] /status dice no due volte: revoco in locale · clientId:', clientId);
        localStorage.removeItem(APP_CONFIG.storagePrefix+'subscription');
        aggiornaDopoCambioPiano();
      }
    }
    aggiornaDopoCambioPiano();
    // Se lo stato Premium è cambiato rispetto alla quota in cache (es. appena dopo il
    // pagamento), richiedi subito la quota aggiornata: il contatore passa a 300/30gg
    // senza dover chiudere e riaprire l'app.
    if (typeof quota.premium === 'boolean' && quota.premium !== isPremium()) refreshQuota();
  } catch (e) { /* offline: si usa lo stato già salvato in locale */ }
}

// Allinea la quota locale a quella del Worker (fonte di verità) e aggiorna il display
function applyQuota(q) {
  if (!q || typeof q.cap !== 'number') return;
  quota = { used: q.used||0, cap: q.cap, premium: !!q.premium, resetAt: q.resetAt||null };
  try { localStorage.setItem(APP_CONFIG.storagePrefix+'quota', JSON.stringify(quota)); } catch(e){}
  updateMessageCounter();
}
// Chiede al Worker quante risposte restano (NON ne consuma): usata all'avvio
async function refreshQuota() {
  try {
    // /quota in POST come /status: stesso motivo (M-2).
    const res = await chiediAlWorker('/quota', { clientId, deviceId });
    if (!res.ok) return;
    applyQuota(await res.json());
  } catch (e) { /* offline: resta la cache locale */ }
}

// Gestisce il ritorno da Stripe Checkout (?checkout=success|cancel)
function handleCheckoutReturn() {
  const params = new URLSearchParams(location.search);
  const c = params.get('checkout');
  if (!c) return;
  history.replaceState(null, '', location.pathname); // pulisce l'URL
  if (c === 'success') {
    addMessageSafe('⭐ Pagamento ricevuto! Sto attivando il tuo Premium…');
    // Lo stato definitivo arriva dal webhook Stripe: riprova qualche volta
    let tries = 0;
    const poll = setInterval(async () => {
      tries++;
      await refreshPremiumStatus();
      if (isPremium() || tries >= 6) {
        clearInterval(poll);
        if (isPremium()) addMessageSafe('✅ Premium attivo: fino a 300 risposte ogni 30 giorni e nessuna pubblicità. Grazie!');
      }
    }, 2000);
  }
}
function addMessageSafe(t){ try{ addMessage(t,'ai'); }catch(e){ console.log(t); } }

// Ripristina un abbonamento attivo (cambio dispositivo / reinstallazione)
async function restorePremium() {
  // ⚠️ v1.0.65 — Il ripristino via EMAIL è stato RITIRATO (rilievo C-1). Bastava
  // conoscere l'indirizzo di un abbonato per prendersi il suo abbonamento e, di lì, il
  // suo Portale Clienti Stripe (fatture, ultime 4 cifre della carta, disdetta).
  // Un'email non è un segreto: quel percorso non era recuperabile, andava tolto.
  //
  // Restano due strade, entrambe sicure:
  //   • il CODICE di ripristino — il clientId stesso, un UUID che non si indovina;
  //   • il recupero assistito — l'utente scrive all'assistenza, che verifica su Stripe
  //     e riassegna con /admin/sub-recover.
  //
  // Sul nativo si interroga prima Google Play: gli acquisti sono legati all'account
  // Google e il recupero è automatico. Se non risulta nulla si manda al codice.
  if (IS_NATIVE && window.CdvPurchase) {
    restorePurchasesNative();
    setTimeout(() => { if (!isPremium()) apriRipristinoDaPaywall(); }, 6000);
    return;
  }
  apriRipristinoDaPaywall();
}

// Chiude il paywall e apre il modale «Ho già un abbonamento» in modalità incolla-codice,
// che è esattamente ciò che serve a chi preme «Ho già pagato».
// ⚠️ Il paywall va chiuso PRIMA: due modali aperti insieme si sovrappongono e su schermi
// piccoli il campo di input finisce coperto.
function apriRipristinoDaPaywall() {
  try { closeUpgradeModal(); } catch (e) {}
  apriRipristino();
}

// Apre la gestione abbonamento: centro abbonamenti Google Play per gli acquisti Play,
// Portale Clienti Stripe per gli acquisti via sito (disdetta facile in entrambi i casi)
async function manageSubscription() {
  let sub = JSON.parse(localStorage.getItem(APP_CONFIG.storagePrefix+'subscription')||'null');
  /* ⚠️ Senza `source` la scelta e' un'euristica che sbaglia: su un dispositivo
     nativo con abbonamento Stripe, «IS_NATIVE e fonte ignota» apriva Google Play.
     Prima di tirare a indovinare si chiede al server, che la fonte la sa. */
  if (sub && !sub.source) {
    try {
      const r = await chiediAlWorker('/status', { clientId });
      if (r.ok) {
        const d = await r.json();
        if (d.source) {
          sub = { ...sub, source: d.source };
          localStorage.setItem(APP_CONFIG.storagePrefix+'subscription', JSON.stringify(sub));
          console.log('[Piano] fonte recuperata dal server:', d.source);
        }
      }
    } catch (e) { /* si procede con l'euristica */ }
  }
  if ((sub && sub.source === 'play') || (IS_NATIVE && sub && !sub.source)) {
    const url = 'https://play.google.com/store/account/subscriptions?sku=' +
      encodeURIComponent(APP_CONFIG.playProductId) + '&package=' + encodeURIComponent(APP_CONFIG.playPackage);
    window.open(url, '_blank');
    return;
  }
  try {
    const res = await fetch(API_PROXY_URL + '/create-portal', {
      method: 'POST', headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ clientId })
    });
    const data = await res.json();
    if (data.url) {
      window.location.href = data.url;
    } else {
      alert('Impossibile aprire la gestione abbonamento: ' + (data.error || 'errore sconosciuto'));
    }
  } catch (e) { alert('Errore di rete. Riprova.'); }
}

// ═══ GOOGLE PLAY BILLING (solo app nativa; plugin cordova-plugin-purchase) ═══
// Il Premium sul canale store si compra col billing di Google. La verifica vera è
// SERVER-SIDE: l'app manda il purchaseToken al Worker (/verify-play), che interroga
// Google e solo allora attiva il Premium in KV. Mai fidarsi del solo client.
let playBillingReady = false;   // true quando il prodotto è caricato dallo store
let playBillingInited = false;

function initPlayBilling() {
  if (playBillingInited || !IS_NATIVE || !window.CdvPurchase) return;
  playBillingInited = true;
  try {
    const { store, ProductType, Platform } = CdvPurchase;
    store.register([{
      id: APP_CONFIG.playProductId,
      type: ProductType.PAID_SUBSCRIPTION,
      platform: Platform.GOOGLE_PLAY
    }]);
    store.when().approved(onPlayApproved);
    const refreshReady = () => {
      const p = store.get(APP_CONFIG.playProductId, Platform.GOOGLE_PLAY);
      playBillingReady = !!(p && p.getOffer());
    };
    store.when().productUpdated(refreshReady);
    store.initialize([Platform.GOOGLE_PLAY]).then(refreshReady).catch(() => {});
  } catch (e) { console.warn('[Play] init billing fallita', e); }
}

// Google ha approvato l'acquisto → verifica sul Worker, poi conferma (finish/acknowledge)
async function onPlayApproved(transaction) {
  // Il purchaseToken su Android: campo da confermare col log al primo test reale
  const token = transaction.purchaseId ||
    (transaction.nativePurchase && transaction.nativePurchase.purchaseToken);
  console.log('[Play] acquisto approvato — token:', token, transaction);
  if (!token) return;
  try {
    const res = await fetch(API_PROXY_URL + '/verify-play', {
      method: 'POST', headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ clientId, purchaseToken: token })
    });
    const data = await res.json();
    if (data.premium) {
      localStorage.setItem(APP_CONFIG.storagePrefix+'subscription',
        JSON.stringify({ active: true, expiresAt: data.expiresAt, plan: data.plan || null, source: 'play' }));
      try { localStorage.removeItem(APP_CONFIG.storagePrefix + 'quota'); } catch (e) {}
      transaction.finish();              // acknowledge verso Google (anti-rimborso 3 giorni)
      ultimaAttivazioneLocale = Date.now();   // §19.1
      negativiConsecutivi = 0;
      aggiornaDopoCambioPiano(); closeUpgradeModal();
      refreshQuota();                    // il contatore passa subito a 300/30gg
      addMessageSafe('✅ Premium attivo: fino a 300 risposte ogni 30 giorni e nessuna pubblicità. Grazie!');
    }
  } catch (e) { console.warn('[Play] verifica server fallita', e); }
}

// Apre il foglio di pagamento di Google
// NOTA PIANO ANNUALE SU PLAY: oggi c'è un solo prodotto (APP_CONFIG.playProductId) con
// la sua offerta. Per offrire anche l'annuale sul nativo si crea nel Play Console un
// secondo "piano base" (base plan) annuale SOTTO lo stesso prodotto abbonamento, poi qui
// si seleziona l'offerta giusta in base a `selectedPlan` (p.getOffer(tag) / scelta del
// base plan) prima di .order(). Finché quel piano base non esiste, sul nativo mostriamo
// solo il mensile (vedi showUpgradePrompt) per non rischiare un acquisto sul piano sbagliato.
function buyPremiumNative() {
  try {
    const { store, Platform } = CdvPurchase;
    const p = store.get(APP_CONFIG.playProductId, Platform.GOOGLE_PLAY);
    const offer = p && p.getOffer();
    if (!offer) { alert('Acquisto non ancora disponibile. Riprova tra qualche istante.'); return; }
    offer.order();
  } catch (e) { alert('Acquisto non disponibile su questo dispositivo.'); }
}

// Ri-emette gli acquisti esistenti dell'account Google (→ ripassano da onPlayApproved)
function restorePurchasesNative() {
  try {
    CdvPurchase.store.restorePurchases();
    addMessageSafe('Sto verificando i tuoi acquisti su Google Play…');
  } catch (e) { /* plugin assente: ignora */ }
}

// Cancellazione dei dati locali (diritto all'oblio GDPR art.17 + requisito store)
/* §25 — non piu' due confirm() di sistema in fila: un modale dell'app in cui
   ogni pulsante dice cosa FA, non «OK» o «Annulla».
   ⚠️ All'utente NON si offre piu' di cancellare anche l'abbonamento: non ha
   ragione di volerlo (l'ha pagato) e il danno e' irreversibile. Quella via resta,
   ma nel menu sviluppatore, dove serve al collaudo. */
// Cancellazione dei dati locali (diritto all'oblio GDPR art.17 + requisito store)
/* Interroga il server sullo stato REALE dell'abbonamento per questo dispositivo.
   Restituisce true/false/null (null = non si e' potuto sapere). */
async function premiumConfermatoDalServer() {
  if (!clientId) return false;
  try {
    const res = await chiediAlWorker('/status', { clientId });
    if (!res.ok) return null;
    const d = await res.json();
    return !!d.premium;
  } catch (e) { return null; }
}

async function deleteMyData() {
  const m = document.getElementById('del-modal');
  const box = document.getElementById('del-scelte');
  if (!m || !box) return;
  const prem = isPremium();
  /* ⚠️ La copia locale di `subscription` e' l'unica cosa che tiene in piedi il
     Premium finche' c'e'. Cancellarla scopre lo stato vero — e se il server non
     ha il record, l'utente si ritrova free DOPO aver cancellato tutto, avendo
     letto che il Premium sarebbe restato. Si chiede prima. */
  /* ⚠️ `intro` va dichiarata PRIMA di usarla: un `const` piu' in basso e' in zona
     morta e da' ReferenceError a tempo di esecuzione — `node --check` non lo vede,
     perche' e' sintatticamente valido. */
  const intro = document.getElementById('del-intro');
  let confermato = null;
  if (prem) {
    if (intro) intro.textContent = 'Verifico l\'abbonamento sul server…';
    box.innerHTML = '';
    m.classList.add('open');
    confermato = await premiumConfermatoDalServer();
    premConfermatoServer = confermato;   // lo usa anche la seconda conferma
    console.log('[Dati] il server conferma il Premium per questo dispositivo:', confermato);
  }
  const BASE = 'Verranno eliminati da questo dispositivo il profilo del tuo gatto, la cronologia '
             + 'delle conversazioni e le preferenze. L\'operazione è irreversibile.';
  if (intro) intro.textContent = !prem ? BASE
    : (confermato === true
        ? 'Verranno eliminati il profilo del tuo gatto, la cronologia e le preferenze. '
          + 'L\'abbonamento resta attivo su questo dispositivo e continua a essere addebitato: '
          + 'per interromperlo usa prima «Gestisci abbonamento».'
      : confermato === false
        ? '⚠️ Attenzione: il server non risulta avere un abbonamento collegato a questo '
          + 'dispositivo. Cancellando i dati il Premium NON sarà recuperabile qui. '
          + 'Prima di procedere, scrivici indicando l\'email con cui hai pagato.'
        : '⚠️ Non è stato possibile verificare l\'abbonamento (connessione assente). '
          + 'Riprova quando sei online: cancellare ora potrebbe rendere il Premium '
          + 'non recuperabile su questo dispositivo.');

  // ⚠️ Ogni pulsante dice cosa FA, non «OK» o «Annulla»: la scelta a tre vie con un
  // confirm() di sistema costringeva a far significare ad «Annulla» l'azione più
  // distruttiva, che è il contrario di quello che quella parola vuol dire.
  // ⚠️ All'utente NON si offre più di cancellare anche l'abbonamento: non ha ragione di
  // volerlo (l'ha pagato) e il danno è irreversibile. Quella via resta, ma nel menu
  // sviluppatore, dove serve al collaudo.
  if (!prem || confermato === true) {
    box.innerHTML = `<button class="del-opt pericolo" onclick="confermaCancellazioneTotale()">
         ${prem ? 'Cancella i miei dati' : 'Cancella tutti i miei dati'}
         <small>${prem
           ? 'Profilo, cronologia e preferenze spariscono da questo dispositivo. L\'abbonamento Premium resta attivo.'
           : 'Profilo, cronologia e preferenze. Non si può annullare.'}</small>
       </button>`;
  } else if (confermato === false) {
    /* ⚠️ Si può ancora cancellare — e' un diritto (GDPR art. 17) — ma il pulsante
       dice la verita': il Premium andra' perso. Non si nasconde l'azione, si
       smette di prometterne l'esito. */
    box.innerHTML = `<button class="del-opt pericolo" onclick="confermaCancellazioneTotale()">
         Cancella comunque i miei dati
         <small>Il Premium NON sarà recuperabile su questo dispositivo.</small>
       </button>`;
  } else {
    /* Non verificabile: non si offre di cancellare a occhi chiusi. */
    box.innerHTML = `<button class="del-opt" onclick="deleteMyData()">
         Riprova la verifica
         <small>Serve una connessione per sapere se il Premium resta.</small>
       </button>`;
  }
  m.classList.add('open');
}

// ═══ ADS MANAGEMENT (AdMob — solo app nativa, solo utenti free) ═══
// La PWA non ha pubblicità (AdSense abbandonato). Sul nativo gli annunci sono
// overlay AdMob, non vivono nel <div> .ad-banner (che resta solo segnaposto web).

// ID interstitial DI TEST ufficiale di Google per Android — non genera ricavi e
// non rischia ban per click. Si usa finché APP_CONFIG.adsTestMode === true.
const TEST_INTERSTITIAL_ID = 'ca-app-pub-3940256099942544/1033173712';

function closeDeleteModal() {
  const m = document.getElementById('del-modal');
  if (m) m.classList.remove('open');
}

/* ⚠️ Anche la SECONDA conferma prometteva «l'abbonamento resta attivo», e lo
   faceva senza sapere: la verifica sul server la fa deleteMyData(), e il suo
   esito va portato fin qui. Una promessa ripetuta in due schermate va verificata
   una volta e propagata, non riscritta a occhio nel secondo posto. */
let premConfermatoServer = null;
function confermaCancellazioneTotale() {
  const box = document.getElementById('del-scelte');
  if (!box) return;
  const prem = isPremium();
  const rassicura = prem && premConfermatoServer === true;
  box.innerHTML = `<button class="del-opt pericolo" onclick="eseguiCancellazione(${prem ? 'true' : 'false'})">
      Sì, cancella definitivamente
      <small>${rassicura ? 'L\'abbonamento Premium resta attivo su questo dispositivo.'
             : (prem ? 'Il Premium NON sarà recuperabile su questo dispositivo.'
                     : 'Ultimo passo: dopo questo non si torna indietro.')}</small>
    </button>
    <button class="del-opt" onclick="deleteMyData()">Ripensaci</button>`;
}

function eseguiCancellazione(mantieniIdentita) {
  // L'identificativo del dispositivo (client_id) è ciò che lega questo telefono
  // all'abbonamento sul server: cancellandolo il Premium diventa irrecuperabile.
  const daPreservare = mantieniIdentita
    ? [APP_CONFIG.storagePrefix + 'client_id', APP_CONFIG.storagePrefix + 'client_id_storico']
    : [];
  /* ⚠️ §19.3 — Quando l'identità NON si conserva, l'id vecchio è l'unica traccia
     che collega questo dispositivo all'abbonamento pagato. Perderlo rende il
     Premium irrecuperabile in silenzio. Si tiene un elenco (ultimi cinque), che
     la diagnostica interroga: se il server riconosce un id precedente, il caso
     si diagnostica in un tocco invece che con un'indagine.
     ⚠️ Non è un buco: sono id di QUESTO dispositivo, già presenti in locale. */
  if (!mantieniIdentita && clientId) {
    /* ⚠️ Una sola funzione scrive lo storico — `aggiungiAStorico()` — perche' e' lei
       che sa la regola: l'id ATTUALE non e' un id precedente. Prima c'erano tre
       punti con tre logiche, e due filtravano l'id attuale mentre uno no.
       Qui il nuovo id non esiste ancora (verra' generato al ricaricamento), quindi
       si passa `null`: l'unica cosa da registrare e' quello che si sta perdendo. */
    daPreservare.push(APP_CONFIG.storagePrefix + 'client_id_storico');
    aggiungiAStorico(clientId, null);
  }
  const salvati = {};
  daPreservare.forEach(k => { const v = localStorage.getItem(k); if (v !== null) salvati[k] = v; });

  if (IS_NATIVE) { try { cancelAllReminderNotifications(); } catch (e) {} }
  try {
    Object.keys(localStorage)
      .filter(k => k.startsWith(APP_CONFIG.storagePrefix))
      .forEach(k => localStorage.removeItem(k));
    Object.keys(salvati).forEach(k => localStorage.setItem(k, salvati[k]));
  } catch (e) { /* no-op */ }
  try { sessionStorage.clear(); } catch (e) { /* no-op */ }

  // Reset totale della PWA: svuota la cache dell'app-shell e deregistra il Service
  // Worker, altrimenti dopo una reinstallazione uno stato vecchio può "risorgere".
  const finish = () => {
    /* ⚠️ Il messaggio non promette piu' che il Premium resta: quello lo si e'
       verificato PRIMA, e se non era confermato l'utente lo sapeva già. Qui si
       dice solo cosa e' stato fatto. */
    alert(mantieniIdentita
      ? 'Dati eliminati da questo dispositivo. L\'app verrà ricaricata.'
      : 'Dati e collegamento all\'abbonamento eliminati. L\'app verrà ricaricata.');
    location.reload();
  };
  Promise.all([
    (self.caches && caches.keys)
      ? caches.keys().then(ks => Promise.all(ks.map(k => caches.delete(k)))).catch(() => {})
      : Promise.resolve(),
    (navigator.serviceWorker && navigator.serviceWorker.getRegistrations)
      ? navigator.serviceWorker.getRegistrations().then(rs => Promise.all(rs.map(r => r.unregister()))).catch(() => {})
      : Promise.resolve()
  ]).then(finish, finish);
}

function initAds() {
  const banner = document.getElementById('ad-banner');
  // Nascondiamo sempre il segnaposto HTML: su web non ci sono ads, su nativo
  // ci pensa AdMob con un overlay a parte.
  if (banner) banner.classList.remove('visible');
  initNativeAds();
}

// Inizializza l'SDK AdMob e gestisce il consenso UMP (GDPR/EEA). Idempotente.
async function initNativeAds() {
  if (!IS_NATIVE || isPremium()) return;          // niente ads su web né per i Premium
  const AdMob = window.Capacitor?.Plugins?.AdMob;
  if (!AdMob) { console.warn('[Ads] plugin AdMob non disponibile'); return; }
  if (adsReady) return;

  // 1) Consenso UMP (GDPR/EEA): in un try/catch SEPARATO, perché finché il
  //    messaggio non è configurato nella console AdMob il consenso non è
  //    disponibile e NON deve impedire l'inizializzazione degli annunci.
  try {
    const consent = await AdMob.requestConsentInfo();
    if (consent?.isConsentFormAvailable && consent?.status === 'REQUIRED') {
      await AdMob.showConsentForm();
    }
  } catch (e) {
    console.warn('[Ads] consenso UMP saltato (configura il messaggio in console AdMob):', e?.message || e);
  }

  // 2) Inizializza l'SDK e pre-carica il primo interstitial. Questo blocco deve
  //    girare comunque, anche se il consenso sopra è stato saltato.
  try {
    await AdMob.initialize({
      initializeForTesting: APP_CONFIG.adsTestMode,
      testingDevices: APP_CONFIG.adsTestDevices || []
    });
    adsReady = true;
    prepareInterstitial();
  } catch (e) {
    console.warn('[Ads] inizializzazione AdMob fallita:', e?.message || e);
  }
}

// Pre-carica un interstitial in background (AdMob richiede prepare → show).
async function prepareInterstitial() {
  if (!IS_NATIVE || !adsReady || isPremium()) return;
  const AdMob = window.Capacitor?.Plugins?.AdMob;
  if (!AdMob) return;
  try {
    await AdMob.prepareInterstitial({
      adId: APP_CONFIG.adsTestMode ? TEST_INTERSTITIAL_ID : APP_CONFIG.adUnitInterstitial,
      isTesting: APP_CONFIG.adsTestMode
    });
    interstitialReady = true;
  } catch (e) {
    interstitialReady = false;
    console.warn('[Ads] prepare interstitial:', e);
  }
}

// Decide se è il momento di mostrare un interstitial. Le guardie (Premium,
// nativo, emergenza) sono ridondanti rispetto al chiamante: difesa in profondità.
function showInterstitialAd() {
  if (isPremium() || !IS_NATIVE) return;                 // mai per i Premium, mai su web
  const emerg = document.getElementById('emergency-banner');
  if (emerg && emerg.classList.contains('visible')) return; // mai durante un'emergenza
  if (localMsgCount > 0 && localMsgCount % APP_CONFIG.adInterstitialEvery === 0) {
    presentInterstitial();
  }
}

// Mostra l'interstitial pre-caricato, poi ne carica subito un altro per la volta dopo.
async function presentInterstitial() {
  const AdMob = window.Capacitor?.Plugins?.AdMob;
  if (!AdMob) return;
  if (!interstitialReady) { prepareInterstitial(); return; } // non era pronto: ricarica e salta
  try {
    await AdMob.showInterstitial();
  } catch (e) {
    console.warn('[Ads] show interstitial:', e);
  } finally {
    interstitialReady = false;
    prepareInterstitial(); // pre-carica il prossimo
  }
}

// ═══ CHAT ═══
function addMessage(text, role) {
  const c = document.getElementById('chat-container');
  const wc = document.getElementById('welcome-card');
  if (wc) wc.remove();
  const m = document.createElement('div');
  m.className = 'message ' + role;
  m.innerHTML = `<div class="message-avatar">${role==='ai'?'🐱':'🧑'}</div><div class="message-bubble">${escapeHtml(text)}</div>`;
  c.appendChild(m);
  c.scrollTop = c.scrollHeight;
}
function escapeHtml(t) {
  const d = document.createElement('div');
  d.textContent = t;
  let h = d.innerHTML;
  h = h.replace(/\n/g,'<br>');
  h = h.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>');
  h = h.replace(/\*(.+?)\*/g,'<em>$1</em>');
  return h;
}
function showTyping() {
  document.getElementById('typing-indicator').classList.add('visible');
  document.getElementById('chat-container').scrollTop = document.getElementById('chat-container').scrollHeight;
  const status = document.getElementById('avatar-status');
  if (status) status.textContent = 'Sta scrivendo…';
}
function hideTyping() {
  document.getElementById('typing-indicator').classList.remove('visible');
}

// ═══ OUTPUT VOCALE — Félicette parla (Web Speech API) ═══
// Carica le voci disponibili (alcuni browser le forniscono in modo asincrono)
function loadVoices() {
  if (!('speechSynthesis' in window)) return;
  ttsVoices = window.speechSynthesis.getVoices();
}
if ('speechSynthesis' in window) {
  loadVoices();
  window.speechSynthesis.onvoiceschanged = loadVoices;
}

// Lingua TTS per il nativo: di default it-IT; all'avvio (initNativeTTS) si cerca una
// voce italiana femminile tra quelle del dispositivo e si memorizza qui se trovata.
let nativeVoiceLang = 'it-IT';
let nativeVoiceUri = null;

// Sul nativo: verifica che l'italiano sia supportato e prova a scegliere una voce femminile.
async function initNativeTTS() {
  if (!IS_NATIVE || !window.Capacitor?.Plugins?.TextToSpeech) return;
  const TTS = window.Capacitor.Plugins.TextToSpeech;
  try {
    const { voices } = await TTS.getSupportedVoices();
    if (Array.isArray(voices)) {
      const female = /alice|elsa|federica|paola|carla|bianca|giulia|chiara|isabella|female|donna|femm/i;
      const it = voices.filter(v => /it[-_]?IT/i.test(v.lang || ''));
      const pick = it.find(v => female.test(v.name || '')) || it[0];
      if (pick && pick.lang) nativeVoiceLang = pick.lang;
    }
  } catch (e) { /* alcune versioni non espongono getSupportedVoices: si resta su it-IT */ }
}

// Dizionario di pronuncia: parole che il sintetizzatore italiano tende ad accentare
// male. La forma con accento esplicito guida la cadenza corretta. Riguarda SOLO la
// voce, non il testo mostrato. Estendibile man mano (validazione CVO).
const TTS_STRESS = {
  'letargia': 'letargìa',
  'zoppia': 'zoppìa',
  'displasia': 'displasìa'
};

// Pulizia del testo prima della lettura (adatta alla Web Speech API / TTS nativo italiano)
// Pulizia del testo prima della lettura (adatta alla Web Speech API / TTS nativo italiano)
// ═══ PRONUNCIA DEL NOME ═══
// Sintesi "nuda": stesse impostazioni della chat, senza toccare l'avatar né lo stato.
// Usata sia da speakResponse() sia dalla prova, così non possono divergere.
function speakRaw(testo, opts) {
  const o = opts || {};
  try {
    if (IS_NATIVE && window.Capacitor?.Plugins?.TextToSpeech) {
      const TTS = window.Capacitor.Plugins.TextToSpeech;
      return Promise.resolve()
        .then(() => TTS.stop().catch(() => {}))
        .then(() => TTS.speak({
          text: testo, lang: nativeVoiceLang || 'it-IT',
          rate: 1.0, pitch: 1.15, volume: 1.0, category: 'playback'
        }))
        .catch(e => console.warn('[TTS] lettura fallita', e))
        .finally(() => { if (o.onend) o.onend(); });
    }
    if (!('speechSynthesis' in window)) { if (o.onend) o.onend(); return; }
    window.speechSynthesis.cancel();
    const u = new SpeechSynthesisUtterance(testo);
    u.lang = 'it-IT'; u.rate = 1.0; u.pitch = 1.15;
    const v = pickItalianVoice();
    if (v) u.voice = v;
    u.onend = u.onerror = () => { if (o.onend) o.onend(); };
    window.speechSynthesis.speak(u);
  } catch (e) {
    console.warn('[TTS] lettura fallita', e);
    if (o.onend) o.onend();
  }
}

function togglePronBox() {
  const box = document.getElementById('pron-box');
  if (!box) return;
  box.classList.toggle('open');
  if (box.classList.contains('open')) {
    const inp = document.getElementById('dog-name-pron');
    // Si parte dal nome vero: l'utente ci mette l'accento invece di riscrivere tutto.
    if (inp && !inp.value) inp.value = (document.getElementById('dog-name').value || '').trim();
    if (inp) inp.focus();
  }
}

/* Il nome va letto DENTRO UNA FRASE, e ripetuto: da solo il sintetizzatore
   quasi sempre lo indovina, e la prova darebbe verde su forme che nella chat
   suonano sbagliate. */
const PRON_FRASE = 'Ciao! Come sta {N} oggi? Vedo che {N} sta bene.';

function tryPronunciation() {
  const inp  = document.getElementById('dog-name-pron');
  const nome = document.getElementById('dog-name');
  const detto = ((inp && inp.value.trim()) || (nome && nome.value.trim()) || '').slice(0, 60);
  if (!detto) return;
  const btn = document.querySelector('.pron-try');
  if (btn) { btn.disabled = true; setTimeout(() => { btn.disabled = false; }, 2500); }
  speakRaw(PRON_FRASE.split('{N}').join(detto));
}

// Sostituisce il nome con la variante fonetica nel testo destinato alla VOCE.
// Confini di parola con lookaround: "Leo" non deve intaccare "Leonardo".
function applyNamePhonetic(text) {
  const vero = (onboardingData.dogName || '').trim();
  const fon  = (onboardingData.namePhonetic || '').trim();
  if (!vero || !fon || fon === vero) return text;
  const esc = vero.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  try {
    return text.replace(new RegExp(`(?<![\\p{L}\\p{N}])${esc}(?![\\p{L}\\p{N}])`, 'giu'), fon);
  } catch (e) {
    // Safari più vecchi non supportano i lookbehind: ripiego senza confine iniziale
    return text.replace(new RegExp(esc, 'gi'), fon);
  }
}

function preprocessTTS(text) {
  // il nome vero resta nel testo scritto: qui si tocca solo la voce
  let p = applyNamePhonetic(text);
  p = p.replace(/\*\*(.+?)\*\*/g, '$1');       // **grassetto**
  p = p.replace(/\*(.+?)\*/g, '$1');            // *corsivo*
  p = p.replace(/^#{1,3}\s+/gm, '');            // intestazioni markdown
  p = p.replace(/<[^>]*>/g, ' ');               // eventuale HTML
  // Rimuove le emoji (la voce non deve leggerle)
  p = p.replace(/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{2190}-\u{21FF}\u{2B00}-\u{2BFF}\u{FE0F}\u{200D}]/gu, '');

  // Intervalli numerici: "20-30" → "20 a 30" (senza questo il trattino è letto "meno").
  // Gestisce decimali all'italiana (1,5-2) e i vari tipi di trattino/meno.
  p = p.replace(/(\d+(?:[.,]\d+)?)\s*[-\u2010\u2011\u2012\u2013\u2014\u2015\u2212]\s*(\d+(?:[.,]\d+)?)/g, '$1 a $2');

  // Parentesi tonde → pause: la voce le ignora, lasciando frasi appiccicate.
  // Sostituendole con una virgola si ottiene la pausa giusta in apertura e chiusura.
  p = p.replace(/\s*\(\s*/g, ', ');
  p = p.replace(/\s*\)\s*/g, ', ');

  p = p.replace(/\n+/g, '. ').replace(/•/g, '. '); // a capo / elenchi → pause
  p = p.replace(/\bDott\.ssa\s*/gi, 'Dottoressa ');
  p = p.replace(/\bDott\.\s*/gi, 'Dottor ');
  p = p.replace(/\bDr\.\s*/gi, 'Dottor ');
  p = p.replace(/%/g, ' per cento');
  p = p.replace(/\bkg\b/gi, 'chili').replace(/\bgr?\b/gi, 'grammi');

  // Cadenza/accenti: applica il dizionario di pronuncia (parola intera, maiusc/minusc indifferenti).
  for (const w in TTS_STRESS) {
    p = p.replace(new RegExp('\\b' + w + '\\b', 'gi'), TTS_STRESS[w]);
  }

  // Pulizia della punteggiatura introdotta dalle virgole (es. parentesi a fine frase).
  p = p.replace(/,\s*([.;:!?])/g, '$1');    // ", ." → "."
  p = p.replace(/([.;:!?])\s*,/g, '$1 ');   // ". ," → ". "
  p = p.replace(/(?:\s*,\s*){2,}/g, ', ');  // virgole multiple → una
  p = p.replace(/\.{2,}/g, '.').replace(/\.\s*\.+/g, '. '); // punti ripetuti → uno
  p = p.replace(/^[\s,]+/, '');             // virgola/spazi a inizio frase
  p = p.replace(/\s+/g, ' ').trim();
  return p;
}

// Seleziona preferibilmente una voce FEMMINILE italiana
// (es. Alice su iOS/macOS, Elsa su Windows, voci Google femminili su Android)
function pickItalianVoice() {
  if (!ttsVoices.length) loadVoices();
  // Solo voci italiane
  const it = ttsVoices.filter(v => /it[-_]?IT/i.test(v.lang) || /^it/i.test(v.lang));
  if (!it.length) return null;
  // Nomi noti di voci femminili italiane / indicatori femminili
  const female = /alice|elsa|federica|paola|carla|bianca|giulia|chiara|isabella|female|donna|femm/i;
  // Nomi noti di voci maschili italiane (da evitare)
  const male = /cosimo|luca|diego|giorgio|male|uomo|masch/i;
  // 1) voce femminile esplicita
  let v = it.find(x => female.test(x.name));
  if (v) return v;
  // 2) qualunque voce italiana che NON sia notoriamente maschile
  v = it.find(x => !male.test(x.name));
  if (v) return v;
  // 3) fallback: prima voce italiana disponibile
  return it[0];
}

// Legge ad alta voce la risposta e anima Félicette mentre parla
function speakResponse(text) {
  // Se la voce è disattivata: solo una breve animazione "contenta" poi torna in ascolto
  if (avatarVoiceMuted) {
    setAvatarState('happy');
    setTimeout(() => setAvatarState('idle'), 2000);
    return;
  }
  const speakText = preprocessTTS(text).slice(0, 2000); // limite di sicurezza
  if (!speakText) { setAvatarState('idle'); return; }

  // Sul nativo (WebView Capacitor) la Web Speech API è muta → usa il plugin TTS.
  if (IS_NATIVE && window.Capacitor?.Plugins?.TextToSpeech) {
    speakNative(speakText);
    return;
  }
  // Sul web: Web Speech API come prima.
  if (!('speechSynthesis' in window)) { setAvatarState('idle'); return; }
  window.speechSynthesis.cancel(); // ferma letture in corso

  /* ⚠️ §15 — le impostazioni della voce stanno in speakRaw() e SOLO li'. Duplicarle
     qui e nel pulsante «Prova» significa che i due percorsi possono divergere, ed e'
     esattamente il difetto del falso verde. */
  avatarSpeaking = true;
  setAvatarState('speaking');
  showStopVoice();
  const status = document.getElementById('avatar-status');
  if (status) status.textContent = 'Sta parlando… (tocca per fermare)';
  speakRaw(speakText, { onend: () => {
    avatarSpeaking = false; hideStopVoice(); setAvatarState('idle');
  } });
  return;
}

// Lettura sul nativo tramite @capacitor-community/text-to-speech.
// L'API è a Promise (non ha onend): l'animazione "speaking" dura finché la Promise si risolve.
async function speakNative(speakText) {
  const TTS = window.Capacitor.Plugins.TextToSpeech;
  try {
    try { await TTS.stop(); } catch (e) {}            // ferma eventuali letture in corso
    avatarSpeaking = true;
    setAvatarState('speaking');
    showStopVoice();
    const status = document.getElementById('avatar-status');
    if (status) status.textContent = 'Sta parlando… (tocca per fermare)';
    await TTS.speak({
      text: speakText,
      lang: nativeVoiceLang || 'it-IT',
      rate: 1.0,
      pitch: 1.15,
      volume: 1.0,
      category: 'playback'   // (solo iOS) suona anche con telefono in silenzioso/background
    });
  } catch (e) {
    console.warn('[TTS] lettura nativa fallita', e);
  } finally {
    avatarSpeaking = false;
    hideStopVoice();
    setAvatarState('idle');
  }
}

// Attiva/disattiva la voce
function toggleAvatarVoice() {
  avatarVoiceMuted = !avatarVoiceMuted;
  localStorage.setItem(APP_CONFIG.storagePrefix+'voice_muted', String(avatarVoiceMuted));
  // ⚠️ Il bottone esiste SOLO nell'header della chat: da Impostazioni questa
  //    funzione girava su un nodo assente e si interrompeva a meta', lasciando
  //    la voce accesa mentre l'etichetta diceva il contrario. Accesso difensivo.
  const btn = document.getElementById('btn-voice-toggle');
  if (btn) {
    btn.innerHTML = avatarVoiceMuted ? icon('speaker-off') : icon('speaker');
    btn.classList.toggle('muted', avatarVoiceMuted);
  }
  // Se sto disattivando mentre Félicette parla, interrompi subito
  if (avatarVoiceMuted) {
    if (IS_NATIVE && window.Capacitor?.Plugins?.TextToSpeech) {
      try { window.Capacitor.Plugins.TextToSpeech.stop(); } catch (e) {}
    } else if ('speechSynthesis' in window) {
      window.speechSynthesis.cancel();
    }
    avatarSpeaking = false;
    hideStopVoice();
    setAvatarState('idle');
  }
}

// ═══ INTERRUZIONE VOCE (pulsante ⏹ / tap sull'avatar) ═══
// Ferma SUBITO la voce di Félicette, sia nativa sia web, senza toccare il testo già
// mostrato in chat (che resta da leggere). È il modo per "accorciare il vocale":
// l'utente taglia l'ascolto quando ha sentito abbastanza.
function stopSpeaking() {
  try {
    if (IS_NATIVE && window.Capacitor?.Plugins?.TextToSpeech) {
      window.Capacitor.Plugins.TextToSpeech.stop();
    }
  } catch (e) {}
  try { if ('speechSynthesis' in window) window.speechSynthesis.cancel(); } catch (e) {}
  avatarSpeaking = false;
  hideStopVoice();
  setAvatarState('idle');
}
// Tap sull'avatar: se Félicette sta parlando, la ferma (scorciatoia oltre al pulsante ⏹).
function onAvatarTap() {
  if (avatarSpeaking) stopSpeaking();
}
// Mostra/nasconde il pulsante ⏹. Va mostrato SOLO mentre Félicette parla davvero.
function showStopVoice() {
  const b = document.getElementById('btn-voice-stop');
  if (b) b.style.display = '';
}
function hideStopVoice() {
  const b = document.getElementById('btn-voice-stop');
  if (b) b.style.display = 'none';
}

// Invio eventi anonimi al Worker per le metriche aggregate (nessun dato personale)
function sendEvent(type) {
  try {
    fetch(API_PROXY_URL + '/event', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ clientId, type }),
      keepalive: true
    }).catch(() => {});
  } catch (e) { /* mai bloccare l'app per le metriche */ }
}

async function sendMessage(opts) {
  const input = document.getElementById('input-field');
  const text = input.value.trim();
  if (!text || isWaitingResponse) return;
  // Budget token di questa chiamata: di norma quello della chat, ma alcune azioni
  // (es. la scheda razza) ne passano uno più alto per non troncare la risposta.
  const maxTokens = (opts && typeof opts.maxTokens === 'number') ? opts.maxTokens : ANTHROPIC_MAX_TOKENS;

  // Check quota residua (stima locale; il Worker è comunque la fonte di verità)
  if (getRemainingMessages() <= 0) {
    const quando = quota.resetAt ? ` Le risposte tornano disponibili dal ${fmtResetDate(quota.resetAt)}.` : '';
    if (!isPremium()) {
      if (IS_NATIVE && !playBillingReady) {
        // App nativa senza store (build di sviluppo): nessun acquisto possibile
        addMessage(`Hai esaurito le ${getMaxMessages()} risposte gratuite di questo periodo.${quando} 🐾`,'ai');
      } else {
        addMessage(`Hai esaurito le ${getMaxMessages()} risposte gratuite di questo periodo.${quando} Con NeosCat Premium hai fino a ${APP_CONFIG.premiumQuotaPlus} risposte ogni 30 giorni e nessuna pubblicità, da ${APP_CONFIG.premiumPriceYear}€/anno.`,'ai');
        showUpgradePrompt();
      }
      sendEvent('limit');
    } else {
      addMessage(`Hai raggiunto il limite di ${getMaxMessages()} risposte negli ultimi 30 giorni.${quando}`,'ai');
    }
    return;
  }

  addMessage(text,'user');
  input.value = '';
  autoResize(input);

  // Check emergenza nel messaggio dell'utente
  if (checkEmergency(text)) {
    document.getElementById('emergency-banner').classList.add('visible');
  }

  conversationHistory.push({role:'user',content:text});
  if (conversationHistory.length > APP_CONFIG.maxConversationHistory) {
    conversationHistory = conversationHistory.slice(-APP_CONFIG.maxConversationHistory);
  }

  isWaitingResponse = true;
  document.getElementById('btn-send').disabled = true;
  showTyping();

  try {
    if (!API_PROXY_URL || API_PROXY_URL === 'YOUR_NEOSCAT_WORKER_URL_HERE') {
      throw new Error('Worker proxy non configurato. Imposta API_PROXY_URL nel codice.');
    }
    const response = await fetch(API_PROXY_URL, {
      method:'POST',
      headers:{'content-type':'application/json'},
      body: JSON.stringify({
        clientId,                        // identità per Premium/Stripe
        deviceId,                        // identità per la quota (persistente su nativo)
        max_tokens: maxTokens,
        profilo: buildProfileBlock(),   // solo l'anagrafica: il prompt sta nel Worker
        messages: conversationHistory
      })
    });
    // Tetto di sicurezza raggiunto (risposta 429 dal Worker): gestione dedicata
    if (response.status === 429) {
      hideTyping();
      let info = {};
      try { info = await response.json(); } catch(e) {}
      // togli l'ultimo messaggio utente dalla cronologia: non ha avuto risposta
      if (conversationHistory.length && conversationHistory[conversationHistory.length-1].role === 'user') {
        conversationHistory.pop();
      }
      if (info.burst) {
        addMessage('Stai inviando messaggi troppo in fretta. Attendi qualche secondo e riprova. 🐾','ai');
      } else {
        // Quota esaurita: allinea il contatore locale ai dati reali del Worker
        if (info.limit) applyQuota(info);
        const quando = info.resetAt ? ` Le risposte tornano disponibili dal ${fmtResetDate(info.resetAt)}.` : '';
        if (info.premium) {
          addMessage(`Hai raggiunto il limite di ${info.cap||getMaxMessages()} risposte negli ultimi 30 giorni.${quando}`,'ai');
        } else if (IS_NATIVE && !playBillingReady) {
          addMessage(`Hai esaurito le ${info.cap||getMaxMessages()} risposte gratuite di questo periodo.${quando} 🐾`,'ai');
          sendEvent('limit');
        } else {
          addMessage(`Hai esaurito le ${info.cap||getMaxMessages()} risposte gratuite di questo periodo.${quando} Con NeosCat Premium hai fino a ${APP_CONFIG.premiumQuotaPlus} risposte ogni 30 giorni e nessuna pubblicità, da ${APP_CONFIG.premiumPriceYear}€/anno.`,'ai');
          showUpgradePrompt();
          sendEvent('limit');
        }
      }
      return; // il blocco finally riabilita input e pulsante
    }
    if (!response.ok) {
      const errBody = await response.text();
      console.error('[API] Status:',response.status,'Body:',errBody);
      throw new Error(errBody || `HTTP ${response.status}`);
    }
    const data = await response.json();
    const aiText = data.content.filter(b => b.type==='text').map(b => b.text).join('\n');
    conversationHistory.push({role:'assistant',content:aiText});
    saveDogHistory(getActiveDogId()); // memoria Premium: persisti la conversazione del gatto attivo
    hideTyping();
    addMessage(aiText,'ai');
    speakResponse(aiText); // Félicette legge la risposta (se la voce è attiva) e si anima
    // Check emergenza nella risposta dell'AI
    if (/urgenza|veterinario|emergenza|immediatamente/i.test(aiText)) {
      // Mostra il banner solo se l'AI menziona urgenza chiara
      if (/⚠️|urgenza/i.test(aiText)) {
        document.getElementById('emergency-banner').classList.add('visible');
      }
    }
    // Aggiorna la quota dagli header del Worker (fonte di verità)
    const qUsed = response.headers.get('X-Quota-Used');
    if (qUsed !== null) {
      applyQuota({
        used: parseInt(qUsed, 10),
        cap: parseInt(response.headers.get('X-Quota-Cap') || getMaxMessages(), 10),
        premium: response.headers.get('X-Quota-Premium') === '1',
        resetAt: response.headers.get('X-Quota-Reset')
      });
    } else {
      /* ⚠️ La cache della quota contiene ANCHE `premium`: e' la quarta sorgente
         del §19, quella che sembra innocua. Ridisegnare tutto, non solo il
         contatore. */
      aggiornaDopoCambioPiano();
    }
    sendEvent('message');
    // Cadenza interstitial: solo free, solo nativo, MAI durante un'emergenza.
    // Gli scambi in emergenza non contano nemmeno verso la cadenza.
    const emergNow = document.getElementById('emergency-banner');
    if (!(emergNow && emergNow.classList.contains('visible'))) {
      localMsgCount++;
      showInterstitialAd();
    }
  } catch (error) {
    hideTyping();
    setAvatarState('idle');
    console.error('[API]',error);
    let msg = 'Problema tecnico. Riprova tra qualche secondo.';
    if (error.message.includes('Worker proxy non configurato')) msg = '⚠️ ' + error.message;
    else if (error.message.includes('credit balance')) msg = 'Credito API esaurito.';
    else if (error.message.includes('rate_limit')) msg = 'Troppe richieste. Aspetta qualche secondo.';
    else if (error.message.includes('Failed to fetch')) msg = 'Worker proxy non raggiungibile. Verifica la connessione.';
    addMessage(msg,'ai');
  } finally {
    isWaitingResponse = false;
    document.getElementById('btn-send').disabled = false;
  }
}

// ═══ ANTEPRIME FAQ GRATUITE (web free: risposte statiche, zero API) ═══════
const FREE_FAQ = {
  food: {
    q: 'Cosa può mangiare il mio gatto?',
    a: 'Il gatto è un carnivoro obbligato: ha bisogno di proteine animali e di nutrienti come la taurina, che trova solo nella carne. La base dovrebbe essere un alimento completo e bilanciato per gatti, adatto a età e condizione: gattino, adulto e anziano hanno fabbisogni diversi. Meglio orari regolari, porzioni costanti e acqua sempre fresca; il cibo umido aiuta l\u2019idratazione, importante per reni e vie urinarie. Vanno esclusi del tutto alcuni alimenti pericolosi: cioccolato, cipolla e aglio, uva e uvetta, alcol, xilitolo. Niente avanzi della tavola, e ogni cambio di dieta va fatto in modo graduale su più giorni. Per le quantità e le scelte su misura — soprattutto in caso di sovrappeso o patologie — il riferimento resta il veterinario.\n\nℹ️ Informazione generale, non sostituisce il parere veterinario.'
  },
  vaccines: {
    q: 'Quando fare i richiami vaccinali?',
    a: 'I vaccini proteggono il gatto dalle principali malattie infettive. Nei gattini si parte di solito intorno alle 8-9 settimane con la trivalente (FVRCP: rinotracheite, calicivirosi, panleucopenia) e una serie di richiami a poche settimane di distanza, fino al completamento del ciclo iniziale; spesso si valuta anche la leucemia felina (FeLV), soprattutto per i gatti con accesso all\u2019esterno. Da adulto restano i richiami periodici, con cadenza che dipende dal vaccino e dallo stile di vita. Quali vaccini servono e ogni quanto li stabilisce il veterinario, in base a età, ambiente (solo in casa o con uscite) e rischi specifici.\n\nℹ️ Informazione generale, non sostituisce il parere veterinario.'
  },
  activity: {
    q: 'Quanta attività fisica gli serve?',
    a: 'Anche il gatto di casa ha bisogno di muoversi e di stimoli: il gioco soddisfa l\u2019istinto di caccia e tiene sotto controllo peso e noia. Funzionano bene più sessioni brevi al giorno con giochi che imitano la preda (bacchette, topini), alternate a momenti di riposo. Conta molto l\u2019ambiente: tiragraffi, mensole e punti in alto per arrampicarsi ed esplorare in verticale. Il fabbisogno varia con età e salute: il gattino gioca a piccole dosi, l\u2019anziano preferisce attività più tranquille. Se noti apatia, calo di movimento o aumento di peso, conviene parlarne col veterinario.\n\nℹ️ Informazione generale, non sostituisce il parere veterinario.'
  },
  alarms: {
    q: 'Segnali di allarme da non ignorare',
    a: 'Alcuni segnali richiedono attenzione rapida. Tra quelli da non ignorare nel gatto: difficoltà o sforzi a urinare o lettiera senza urina (soprattutto nel maschio può essere un\u2019ostruzione urinaria, un\u2019emergenza), vomito o diarrea ripetuti o con sangue, rifiuto del cibo per più di un giorno (nel gatto il digiuno prolungato è rischioso), respiro affannoso o a bocca aperta, gengive pallide o bluastre, debolezza improvvisa, convulsioni, tendenza a nascondersi e isolarsi in modo insolito. In questi casi non si aspetta: si contatta subito il veterinario o una clinica d\u2019emergenza. Anche cambiamenti improvvisi di sete, quantità di urina o comportamento meritano un controllo.\n\n⚠️ In caso di emergenza contatta subito il veterinario. Informazione generale, non sostituisce il parere veterinario.'
  },
  bath: {
    q: 'Ogni quanto fare il bagno?',
    a: 'Il gatto si pulisce da solo con la toelettatura quotidiana, quindi nella maggior parte dei casi il bagno non serve. Può essere utile solo in situazioni particolari: pelo molto sporco o con sostanze appiccicose/tossiche, gatti anziani o in sovrappeso che non riescono più a pulirsi bene, o su indicazione del veterinario (es. alcune dermatiti). Quando serve, si usano solo prodotti specifici per gatti (mai shampoo umani) e acqua tiepida, asciugando bene. Per la cura ordinaria conta molto di più la spazzolatura regolare, soprattutto nei gatti a pelo lungo: riduce nodi e boli di pelo.\n\nℹ️ Informazione generale, non sostituisce il parere veterinario.'
  },
  teeth: {
    q: 'Come tenere puliti i denti?',
    a: 'I problemi dentali (tartaro, gengiviti, riassorbimenti dentali) sono molto comuni nel gatto e possono causare dolore e difficoltà a mangiare. La prevenzione migliore è la spazzolatura regolare con dentifricio specifico per gatti (mai quello umano), introdotta con gradualità e pazienza; in alternativa o in aggiunta esistono diete, snack e prodotti dentali pensati allo scopo. Segnali da non trascurare: alito molto cattivo, gengive arrossate o sanguinanti, fatica o dolore nel masticare, salivazione abbondante. Un controllo periodico dal veterinario permette di valutare se serve una pulizia professionale.\n\nℹ️ Informazione generale, non sostituisce il parere veterinario.'
  }
};
function renderStaticPreview(q, a) {
  const c = document.getElementById('chat-container');
  if (!c) return;
  const add = (text, role) => {
    const m = document.createElement('div');
    m.className = 'message ' + role;
    m.innerHTML = `<div class="message-avatar">${role==='ai'?'🐱':'🧑'}</div><div class="message-bubble">${escapeHtml(text)}</div>`;
    c.appendChild(m);
  };
  add(q, 'user');
  add(a, 'ai');
  c.scrollTop = c.scrollHeight;
  speakResponse(a); // Félicette legge l'anteprima (se la voce è attiva) e si anima, come nel flusso live
}
// Mostra l'input (nativo o Premium) oppure il "gate" free (pulsante Premium + codice). Solo PWA.
function applyFreeGate() {
  const pwaFree = !IS_NATIVE && !isPremium();
  const row = document.getElementById('input-row');
  const gate = document.getElementById('free-gate');
  if (row) row.style.display = pwaFree ? 'none' : '';
  if (gate) gate.style.display = pwaFree ? '' : 'none';
}
function sendSuggestion(btn) {
  // PWA free (pseudo-freemium): risposte preconfezionate, nessuna chiamata API.
  if (!IS_NATIVE && !isPremium()) {
    const faq = btn.dataset.faq;
    if (faq && FREE_FAQ[faq]) { renderStaticPreview(FREE_FAQ[faq].q, FREE_FAQ[faq].a); return; }
    showUpgradePrompt(); // suggerimenti senza anteprima → invito al Premium
    return;
  }
  document.getElementById('input-field').value = btn.textContent.replace(/^[^\s]+\s/,'');
  sendMessage();
}
function handleKeyDown(e) {
  if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
}
function autoResize(el) {
  el.style.height = 'auto';
  el.style.height = Math.min(el.scrollHeight,120)+'px';
}

// ═══ MODALE INPUT (campo grande full-screen; il campo inline è readonly e apre questo) ═══
function openInputModal(doFocus = true) {
  const m = document.getElementById('input-modal'), f = document.getElementById('input-modal-field');
  f.value = document.getElementById('input-field').value || '';
  m.classList.add('open');
  if (doFocus) setTimeout(() => { f.focus(); f.setSelectionRange(f.value.length, f.value.length); }, 60);
}
function closeInputModal() {
  if (isListening) stopDictation();
  document.getElementById('input-modal').classList.remove('open');
}
function sendFromModal() {
  const f = document.getElementById('input-modal-field'); const t = f.value.trim();
  if (!t) return;
  document.getElementById('input-field').value = t;
  f.value = ''; closeInputModal(); sendMessage();
}
function handleModalKeyDown(e) {
  if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendFromModal(); }
  if (e.key === 'Escape') { closeInputModal(); }
}

// ═══ VOCALE (dettatura) ═══
// Tre modalità, in ordine di preferenza:
//   • nativo Android  → plugin @capacitor-community/speech-recognition (riconoscimento di Google)
//   • web (Chrome/PWA) → Web Speech API
//   • nessuno          → si apre il modale e si mostra la tastiera (mic di sistema)
function speechSupported() {
  return ('webkitSpeechRecognition' in window) || ('SpeechRecognition' in window);
}
// Plugin nativo (presente solo se installato e su piattaforma nativa)
function SRPlugin() {
  return (IS_NATIVE && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.SpeechRecognition) || null;
}
function nativeVoiceAvailable() { return !!SRPlugin(); }
function voiceAvailable() { return nativeVoiceAvailable() || speechSupported(); }

// Microfono della barra di input: apre il modale e avvia SUBITO la dettatura.
// Dove non c'è riconoscimento (es. WebView senza plugin), apre il modale e porta
// il focus al campo, così il microfono della tastiera è a un tocco.
function startVoiceInput() {
  if (voiceAvailable()) {
    openInputModal(false);   // niente focus: evitiamo che la tastiera entri in conflitto col riconoscimento
    startDictation();
  } else {
    openInputModal(true);
    const f = document.getElementById('input-modal-field');
    if (f && !f.value) f.placeholder = 'Tocca 🎤 sulla tastiera per dettare, oppure scrivi qui…';
  }
}

// Microfono dentro il modale: avvia/ferma la dettatura.
function toggleModalVoice() {
  if (!voiceAvailable()) {
    const f = document.getElementById('input-modal-field');
    if (f) { f.focus(); if (!f.value) f.placeholder = 'Tocca 🎤 sulla tastiera per dettare, oppure scrivi qui…'; }
    return;
  }
  if (isListening) stopDictation(); else startDictation();
}

function setListeningUI(on) {
  const mic = document.getElementById('input-modal-mic');
  const title = document.getElementById('input-modal-title');
  const bv = document.getElementById('btn-voice');
  if (mic) mic.classList.toggle('listening', on);
  if (bv) bv.classList.toggle('listening', on);
  if (title) {
    title.classList.toggle('listening', on);
    title.textContent = on ? '🎙️ Parla pure… ti ascolto' : '✍️ La tua domanda';
  }
}
function dictationDeniedUI() {
  setListeningUI(false);
  const title = document.getElementById('input-modal-title');
  if (title) title.textContent = '🎤 Consenti il microfono per dettare';
  const f = document.getElementById('input-modal-field'); if (f) f.focus();
}

// Dispatcher: sceglie nativo, web o tastiera.
function startDictation() {
  if (isListening) return;
  if (nativeVoiceAvailable()) { startDictationNative(); return; }
  if (speechSupported())     { startDictationWeb();    return; }
  const f = document.getElementById('input-modal-field');
  if (f) { f.focus(); if (!f.value) f.placeholder = 'Tocca 🎤 sulla tastiera per dettare, oppure scrivi qui…'; }
}

// ── WEB (Web Speech API) ──
function startDictationWeb() {
  const f = document.getElementById('input-modal-field');
  const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
  recognition = new SR();
  recognition.lang = 'it-IT';
  recognition.continuous = false;
  recognition.interimResults = true;
  let base = (f.value || '').trim();
  if (base) base += ' ';
  recognition.onstart = () => { isListening = true; setListeningUI(true); };
  recognition.onresult = (e) => {
    let txt = '';
    for (let i = 0; i < e.results.length; i++) txt += e.results[i][0].transcript;
    f.value = base + txt;
    autoResize(f);
  };
  recognition.onerror = (e) => {
    stopDictation();
    if (e.error === 'not-allowed' || e.error === 'service-not-allowed') dictationDeniedUI();
  };
  recognition.onend = () => stopDictation();
  try { recognition.start(); } catch(e) { stopDictation(); }
}

// ── NATIVO (plugin Capacitor) ──
// Gestisce in modo difensivo le differenze tra versioni del plugin
// (checkPermissions/requestPermissions vs hasPermission/requestPermission;
//  risultati via evento 'partialResults' e/o via promessa di start()).
async function ensureNativeMic(SRn) {
  try {
    if (SRn.checkPermissions) {
      let p = await SRn.checkPermissions();
      if (p && p.speechRecognition === 'granted') return true;
      p = await SRn.requestPermissions();
      return !!(p && p.speechRecognition === 'granted');
    }
    if (SRn.hasPermission) { // versioni più vecchie del plugin
      let h = await SRn.hasPermission();
      if (h && h.permission) return true;
      if (SRn.requestPermission) await SRn.requestPermission();
      h = await SRn.hasPermission();
      return !!(h && h.permission);
    }
  } catch(e) {}
  return true; // se il plugin non espone i permessi, proviamo comunque
}
async function startDictationNative() {
  const SRn = SRPlugin(); if (!SRn) return;
  const f = document.getElementById('input-modal-field');
  let base = (f.value || '').trim(); if (base) base += ' ';
  try {
    if (SRn.available) {
      try { const a = await SRn.available(); if (a && a.available === false) { startDictation_fallbackKeyboard(); return; } } catch(e) {}
    }
    const ok = await ensureNativeMic(SRn);
    if (!ok) { dictationDeniedUI(); return; }

    isListening = true; setListeningUI(true);
    try { await SRn.removeAllListeners(); } catch(e) {}
    if (SRn.addListener) {
      // Solo stato di ascolto per l'UI; il TESTO arriva dal risultato finale (sotto).
      SRn.addListener('listeningState', (d) => {
        if (d && d.status === 'stopped' && isListening) stopDictation();
      });
    }
    // Android: con partialResults:FALSE e popup:false, start() si risolve con il
    // risultato FINALE completo dell'intera frase. Con partialResults:true il
    // riconoscitore Android chiudeva all'EOS dopo la prima parola e il finale
    // andava perso → si scriveva solo la prima parola (o nulla). Niente più testo
    // "live" parola-per-parola: la frase compare quando finisci di parlare.
    const res = await SRn.start({ language: 'it-IT', maxResults: 2, partialResults: false, popup: false });
    const m = res && res.matches && res.matches[0];
    if (m) { f.value = base + m; autoResize(f); }
    stopDictation();
  } catch(e) { stopDictation(); }
}
function startDictation_fallbackKeyboard() {
  setListeningUI(false);
  const f = document.getElementById('input-modal-field');
  if (f) { f.focus(); if (!f.value) f.placeholder = 'Tocca 🎤 sulla tastiera per dettare, oppure scrivi qui…'; }
}

function stopDictation() {
  isListening = false;
  setListeningUI(false);
  if (recognition) { try { recognition.stop(); } catch(e) {} recognition = null; }
  const SRn = SRPlugin();
  if (SRn) { try { SRn.stop(); } catch(e) {} try { SRn.removeAllListeners(); } catch(e) {} }
  // a fine dettatura porta il focus al campo, per rivedere o inviare
  const f = document.getElementById('input-modal-field');
  if (f && document.getElementById('input-modal').classList.contains('open')) {
    setTimeout(() => { try { f.focus(); f.setSelectionRange(f.value.length, f.value.length); } catch(e){} }, 50);
  }
}

// ═══ THEME ═══
function toggleTheme() {
  const h = document.documentElement;
  const d = h.getAttribute('data-theme') === 'dark';
  h.setAttribute('data-theme', d ? 'light' : 'dark');
  localStorage.setItem(APP_CONFIG.storagePrefix+'theme', d ? 'light' : 'dark');
  /* ⚠️ #theme-btn non esiste piu': il tema si cambia da Impostazioni, e quel
     bottone e' uscito dall'header con la riarchitettura. Senza guardia qui
     partiva un TypeError che interrompeva la funzione a meta'. */
  const tb = document.getElementById('theme-btn');
  if (tb) tb.innerHTML = d ? SVGmoon() : SVGsun();
  if (currentScreen === 'settings') renderSettings();
}
function SVGmoon() { return icon('moon'); }
function SVGsun() { return icon('sun'); }
if (localStorage.getItem(APP_CONFIG.storagePrefix+'theme') === 'dark') {
  document.documentElement.setAttribute('data-theme','dark');
  document.addEventListener('DOMContentLoaded', () => {
    const tb = document.getElementById('theme-btn'); if (tb) tb.innerHTML = icon('sun');
  });
}

// ═══ CLEAR CHAT ═══
function clearChat() {
  // In PWA-free le anteprime statiche non finiscono in conversationHistory: per decidere se
  // c'è qualcosa da pulire guardiamo anche le bolle effettivamente a schermo.
  const hasBubbles = !!document.querySelector('#chat-container .message');
  if (!conversationHistory.length && !hasBubbles) return;
  if (!confirm('Iniziare una nuova conversazione?')) return;
  if (typeof stopSpeaking === 'function') stopSpeaking(); // ferma l'eventuale lettura in corso
  conversationHistory = [];
  clearDogHistory(getActiveDogId()); // azzera anche la memoria salvata (Premium)
  resetWelcome();
}

/* ═══════════════════════════════════════════════════════════════════════
   ROUTER A STACK (§10)
   ⚠️ Non a cronologia. Impilare sempre, anche verso una schermata gia'
   nello stack, produce home→chat→home→chat con stack ["home","chat",
   "home","chat"]: il tasto Indietro ripercorre la CRONOLOGIA invece di
   risalire la GERARCHIA. La segnalazione arriva vaga - «a volte non torna
   dove mi aspetto» - e sotto c'e' una proprieta' del router.
   ═══════════════════════════════════════════════════════════════════════ */
let currentScreen = null;
let navStack = [];
let onboardingReturn = 'home';   // §11: da dove si e' entrati nell'onboarding

const SCHERMATE = {
  /* ⚠️ Su benvenuto e onboarding la gemella NON mostra l'header: quelle due
     schermate hanno gia' il proprio logo e il proprio titolo, e una barra in
     piu' ruberebbe spazio proprio dove il contenuto e' piu' lungo. */
  welcome:    { titolo: null,             marchio: true,  indietro: false, header: false },
  onboarding: { titolo: null,             marchio: true,  indietro: false, header: false },
  home:       { titolo: null,             marchio: true,  indietro: false, header: true },
  chat:       { titolo: null,             marchio: false, indietro: true,  header: true },
  breed:      { titolo: 'Scheda della razza', marchio: false, indietro: true, header: true },
  fun:        { titolo: 'Cosa dice…?',    marchio: false, indietro: true,  header: true },
  pets:       { titolo: 'I miei gatti',   marchio: false, indietro: true,  header: true },
  settings:   { titolo: 'Impostazioni',   marchio: false, indietro: true,  header: true },
};

/* ── Icone di interfaccia (§14) ────────────────────────────────────────
   ⚠️ Tre trappole incontrate su NeosDog, evitate qui:
     1. ${icon(...)} dentro apici SINGOLI non viene interpolato: serve la
        concatenazione;
     2. el.textContent = icon(...) stampa l'SVG come testo letterale: va
        innerHTML;
     3. un <option> non puo' contenere un SVG: nei menu a tendina le emoji
        restano, e va bene cosi'. */
function icon(nome, cls) {
  return '<svg class="npi' + (cls ? ' ' + cls : '') + '" aria-hidden="true" focusable="false">' +
         '<use href="#npi-' + nome + '"/></svg>';
}

function hasPet() {
  try { return getProfiles().length > 0; } catch (e) { return false; }
}
function haVistoBenvenuto() {
  try { return localStorage.getItem(APP_CONFIG.storagePrefix + 'seen_welcome') === '1'; }
  catch (e) { return false; }
}
function segnaBenvenutoVisto() {
  // sotto lo storagePrefix, cosi' «Cancella i miei dati» lo ripristina
  try { localStorage.setItem(APP_CONFIG.storagePrefix + 'seen_welcome', '1'); } catch (e) {}
}

/* La gerarchia delle schermate: tutte le figlie stanno sotto la Home. Serve a
   ricostruire lo stack quando si ENTRA in una schermata senza esserci arrivati
   navigando — al ripristino di una bozza, o da un collegamento esterno. */
function catenaFino(name) {
  if (!name || name === 'home') return ['home'];
  if (name === 'welcome' || name === 'onboarding') return [];
  return ['home', name];
}

function go(name) {
  if (name === currentScreen) return;
  const at = navStack.indexOf(name);
  if (at !== -1) { navStack = navStack.slice(0, at); renderScreen(name); return; }
  if (currentScreen) navStack.push(currentScreen);
  renderScreen(name);
}

function back() {
  // Con un modale aperto, Indietro chiude quello senza navigare.
  if (chiudiModaleAperto()) return true;
  if (navStack.length) { renderScreen(navStack.pop()); return true; }
  /* ⚠️ Stack vuoto. Se siamo in una schermata FIGLIA, la freccia e' a schermo e
     deve fare qualcosa: si torna alla Home. Restituire `false` la lasciava
     visibile e inerte, e l'utente restava bloccato senza capire perche'.
     Dalla Home invece `false` e' giusto: e' il segnale che il tasto Indietro di
     sistema deve chiudere l'app. */
  if (currentScreen && currentScreen !== 'home'
      && currentScreen !== 'welcome' && currentScreen !== 'onboarding') {
    console.warn('[Nav] stack vuoto in', currentScreen, '- ripiego sulla Home');
    navStack = [];
    renderScreen('home');
    return true;
  }
  return false;
}

function chiudiModaleAperto() {
  const aperti = [
    ['dev-modal', 'open', closeDevMenu],   // era devClose(), rimossa col vecchio menu
    /* ⚠️ Il modale del codice mancava da questo elenco: il tasto Indietro non lo
       chiudeva e restava aperto sopra tutto. L'ha trovato un presidio che non
       riusciva piu' a toccare il logo perche' coperto — non la prova che avrebbe
       dovuto cercarlo.
       REGOLA: ogni modale nuovo va aggiunto QUI nello stesso momento in cui nasce.
       Un elenco di sovrapposti si dimentica sempre dell'ultimo aggiunto. */
    ['code-modal', 'open', closeCodeModal],
    ['del-modal', 'open', closeDeleteModal],
    ['photo-lightbox', 'open', () => closePhotoLightbox()],
    ['rem-modal', 'open', () => closeReminders()],
    ['input-modal', 'open', () => closeInputModal()],
    ['upgrade-modal', 'visible', () => closeUpgradeModal()],
  ];
  for (const [id, cls, chiudi] of aperti) {
    const el = document.getElementById(id);
    if (el && el.classList.contains(cls)) { try { chiudi(); } catch (e) {} return true; }
  }
  return false;
}

function renderScreen(name) {
  const precedente = currentScreen;
  /* ⚠️ GUARDIA SUL PRIMO ACCESSO. Il motivo non e' estetico: i consensi
     obbligatori - 14 anni (Legge IA 132/2025) e privacy - stanno nello
     step 1 dell'onboarding, e nessuna funzione deve essere raggiungibile
     prima che siano stati dati. Vale anche per il tasto Indietro. */
  if (!hasPet() && name !== 'welcome' && name !== 'onboarding') {
    name = haVistoBenvenuto() ? 'onboarding' : 'welcome';
    navStack = [];
  }
  currentScreen = name;
  document.querySelectorAll('#screens > .screen').forEach(el => {
    el.classList.toggle('hidden', el.id !== 'screen-' + name);
  });
  const scr = document.getElementById('screen-' + name);
  if (scr) scr.scrollTop = 0;
  const barra = document.getElementById('app-header');
  if (barra) barra.classList.toggle('hidden', (SCHERMATE[name] || {}).header === false);
  renderHeader(name);
  if (name === 'home') renderHome();
  if (name === 'pets') renderDogSwitcher();
  if (name === 'settings') renderSettings();
  if (name === 'breed') renderBreed();
  if (name === 'fun') renderFunScreen();
  /* ⚠️ onLeave: uscendo dal gioco il microfono va rilasciato, o resta
     occupato e la dettatura in chat smette di funzionare (§9b). */
  if (precedente === 'fun' && name !== 'fun') { try { stopFunRecording(false); } catch (e) {} }
  if (name === 'chat') { renderDogSwitcher(); initAds(); updateMessageCounter(); }
  // Il disclaimer e il footer sono della shell: restano visibili ovunque.
  // Il banner d'emergenza no: appartiene alla conversazione.
  if (name !== 'chat') {
    const eb = document.getElementById('emergency-banner');
    if (eb) eb.classList.remove('visible');
  }
  sincronizzaIndietroDiSistema();
  console.log('[Nav]', name, '· stack', JSON.stringify(navStack));
}

/* ── Header contestuale (§10) ──────────────────────────────────────────
   Nelle schermate figlie IL TITOLO SOSTITUISCE IL MARCHIO: l'utente sa gia'
   in che app e', e cosi' l'header non cresce. In chat il titolo e' il nome
   dell'animale con la razza come sottotitolo, toccabile per cambiare gatto.
   I comandi della chat restano solo in chat.
   ⚠️ Prima qui concorrevano cinque bottoni, il logo, il titolo e la pill del
   nome: su 360px andava tutto a capo e l'header arrivava a 118px. */
/* ── Header contestuale (§10), sul modello dell'app gemella ────────────
   Cerchio grigio per la freccia e per i comandi, icona a tratto scuro.
   Nelle schermate figlie il TITOLO SOSTITUISCE IL MARCHIO; in chat il
   titolo e' il nome dell'animale con la razza sotto, toccabile. */
function renderHeader(name) {
  const h = document.getElementById('app-header');
  if (!h) return;
  const cfg = SCHERMATE[name] || {};
  const d = getActiveDog();
  let sx = '', titolo = '', dx = '';
  if (cfg.indietro) {
    sx = '<button class="header-back" onclick="back()" aria-label="Torna al menu" ' +
         'title="Indietro">' + icon('arrow-left', 'npi-lg') + '</button>';
  }
  if (cfg.marchio) {
    sx += '<span class="logo" id="app-logo" onclick="devTocco(event)" ' +
          'style="cursor:default;-webkit-tap-highlight-color:transparent">' + icon('cat', 'npi-lg') + '</span>';
    titolo = '<h1>NeosCat <span class="ai-badge">AI</span></h1>';
  } else if (name === 'chat') {
    sx += '<span class="header-logo-ico" id="app-logo" onclick="devTocco(event)">' + icon('cat', 'npi-lg') + '</span>';
    const nome = (d && d.dogName) ? d.dogName : 'Il tuo gatto';
    const razza = (d && d.breed) ? d.breed : '';
    titolo = '<button class="header-chat-title" onclick="go(\'pets\')" ' +
             'aria-label="Cambia gatto o gestisci i profili">' +
             '<span class="hct-name">' + escapeHtml(nome) +
             '<span class="hct-caret">▾</span></span>' +
             (razza ? '<span class="hct-breed">' + escapeHtml(razza) + '</span>' : '') +
             '</button>';
  } else {
    const sotto = (name === 'breed' && d && d.dogName) ? d.dogName : '';
    titolo = '<span class="header-screen-title">' + escapeHtml(cfg.titolo || '') + '</span>' +
             (sotto ? '<span class="header-screen-sub">' + escapeHtml(sotto) + '</span>' : '');
  }
  if (name === 'chat') {
    dx = '<button class="header-btn" id="btn-voice-toggle" onclick="toggleAvatarVoice()" ' +
         'title="Attiva/disattiva la voce di Félicette" aria-label="Attiva o disattiva la voce di Félicette">' +
         icon('speaker') + '</button>' +
         '<button class="header-btn voice-stop" id="btn-voice-stop" onclick="stopSpeaking()" ' +
         'title="Interrompi la voce di Félicette" aria-label="Interrompi la voce di Félicette" ' +
         'style="display:none">' + icon('stop') + '</button>' +
         '<button class="header-btn" onclick="clearChat()" title="Nuova conversazione" ' +
         'aria-label="Inizia una nuova conversazione">' + icon('trash') + '</button>';
  }
  h.innerHTML = '<div class="header-left">' + sx + '<div class="header-title">' + titolo + '</div></div>' +
                '<div class="header-right">' + dx + '</div>';
  /* ⚠️ L'header e' RIDISEGNATO a ogni cambio di schermata: il grilletto della
     diagnostica e la foto sul logo vanno reinstallati, o spariscono al primo
     ridisegno. E' il §6 in forma nuova. */
  /* ⚠️ L'header e' ridisegnato a ogni cambio di schermata: il gancio della
     pressione prolungata va rimesso, mentre i cinque tocchi sopravvivono da
     soli, perche' sono un onclick INLINE nel markup generato. */
  if (typeof devAggancia === 'function') devAggancia(document.getElementById('app-logo'));
  updateHeaderLogo();
  const bv = document.getElementById('btn-voice-toggle');
  if (bv) {
    bv.innerHTML = avatarVoiceMuted ? icon('speaker-off') : icon('speaker');
    bv.classList.toggle('muted', avatarVoiceMuted);
    if (typeof voiceAvailable === 'function' && !voiceAvailable()) bv.style.display = 'none';
  }
}

/* ── Home a menu ──────────────────────────────────────────────────────
   ⚠️ Coerenza del paywall (§13): se una voce porta il badge PREMIUM, il
   tocco deve portare SUBITO alla decisione d'acquisto, non a un invito
   pieno che sbatte contro il paywall un tocco dopo. */
/* «0 anni» per un gattino di pochi mesi: il dato c'e' - l'onboarding chiede
   anni E mesi - ma veniva letto solo il campo degli anni. */
function etaLeggibile(d) {
  if (!d) return '';
  const a = Number(d.ageYears) || 0;
  const m = Number(d.ageMonths) || 0;
  if (!a && !m) return '';
  if (!a) return m + (m === 1 ? ' mese' : ' mesi');
  if (!m) return a + (a === 1 ? ' anno' : ' anni');
  return a + (a === 1 ? ' anno' : ' anni') + ' e ' + m + (m === 1 ? ' mese' : ' mesi');
}

/* ── L'elenco delle funzioni, in UN SOLO posto ─────────────────────────
   §17b: benvenuto, vetrina e modale di upgrade descrivono le stesse
   funzioni con testi diversi per contesto. Centralizzare i TESTI
   perderebbe l'adattamento; qui e' centralizzato l'ELENCO — quali funzioni
   esistono e quale symbol le rappresenta — e ogni superficie scrive la
   propria frase. Il presidio verifica la copertura sul symbol.

   ⚠️ Le eccezioni vanno dichiarate, non aggirate: «Chiedi a Félicette» sta
   nella vetrina PWA perche' li' il free NON ha l'input, mentre sul nativo
   il free puo' gia' chattare e il vantaggio e' il VOLUME, non l'accesso. */
const FUNZIONI_PREMIUM = [
  { id: 'chat',       ico: 'chat',      vetrina: '<strong>Chiedi a Félicette</strong> del tuo gatto: risposte calibrate su età, peso e condizioni del suo profilo' },
  { id: 'memoria',    ico: 'brain',     vetrina: '<strong>Ricorda le conversazioni</strong> precedenti' },
  { id: 'razza',      ico: 'clipboard', vetrina: '<strong>Scheda della razza</strong> e profilo clinico esteso' },
  { id: 'promemoria', ico: 'bell',      vetrina: '<strong>Promemoria</strong> per vaccini, antiparassitari e visite' },
  { id: 'gioco',      ico: 'play',      vetrina: '<strong>Cosa dice il tuo gatto?</strong> il mini-gioco che «traduce» i miagolii' },
  { id: 'multigatto', ico: 'paw',       vetrina: '<strong>Fino a ' + APP_CONFIG.maxDogsPremium + ' gatti</strong>, senza pubblicità' },
];


/* ── Home, sul modello: avatar grande dell'animale, nome, domanda, pill
   dello stato del piano, poi le voci senza riquadro colorato e senza
   freccia a destra. ─────────────────────────────────────────────────── */
function renderHome() {
  const d = getActiveDog();
  const testa = document.getElementById('home-head');
  if (testa) {
    const foto = d ? getDogPhoto(d.id) : '';
    testa.innerHTML =
      /* ⚠️ Il ritratto risponde SEMPRE al tocco. Con la foto il tocco singolo la
         ingrandisce e cinque tocchi aprono il menu sviluppatore: le due cose
         convivono perche' l'ingrandimento e' RINVIATO di 350 ms e viene annullato
         se arriva un secondo tocco. Prima, con la foto, non c'era nessun onclick e
         il ritratto era inerte: ne' visore ne' menu. */
      '<button class="home-ava" onclick="toccaRitratto(event' +
        (foto ? ", '" + d.id + "'" : '') + ')"' +
        ' aria-label="' + (foto ? 'Foto del gatto: tocca per ingrandire' : 'Icona del gatto') + '">' +
        (foto ? '<img src="' + foto + '" alt="">' : icon('cat', 'npi-xl')) + '</button>' +
      '<div class="home-name">' + escapeHtml((d && d.dogName) || 'Il tuo gatto') + '</div>' +
      '<div class="home-ask">Cosa vuoi sapere oggi?</div>' +
      '<div class="home-pill">' + testoPiano() + '</div>';
    if (typeof devAggancia === 'function') devAggancia(document.querySelector('.home-ava'));
  }
  /* ⚠️ §13 — LA STESSA condizione che chiude l'input: una scelta sola, una
     regola sola. In PWA il free non ha risposte, quindi la Home non e' un
     menu ma una vetrina; sul nativo il free PUO' usare l'app e il menu resta
     completo. */
  const vetrinaPwa = !IS_NATIVE && !isPremium();
  const box = document.getElementById('home-vetrina');
  if (box) box.innerHTML = vetrinaPwa ? htmlVetrina() : '';

  const voci = vetrinaPwa
    /* Le voci Premium spariscono dal menu e diventano righe della vetrina:
       elencarne cinque di cui tre chiuse mostrerebbe solo cose non
       disponibili. Restano le tre che il free puo' davvero usare. */
    ? [
      { ico: 'chat', lab: 'Chat con Félicette', sub: 'Vedi come risponde Félicette',
        azione: "go('chat')" },
      { ico: 'paw', lab: 'I miei gatti', sub: 'Cambia gatto, foto, profili',
        azione: "go('pets')" },
      { ico: 'settings', lab: 'Impostazioni e abbonamento', sub: 'Piano, aspetto, voce, dati',
        azione: "go('settings')" },
    ]
    : [
      { ico: 'chat', lab: 'Chat con Félicette',
        sub: 'Fai una domanda su ' + ((d && d.dogName) || 'il tuo gatto'),
        azione: "go('chat')" },
      { ico: 'clipboard', lab: 'Scheda della razza', sub: 'Profilo completo del tuo gatto',
        premium: true, azione: "go('breed')" },
      { ico: 'bell', lab: 'Promemoria salute', sub: 'Vaccini, antiparassitari, visite',
        premium: true, azione: "openReminders()" },
      { ico: 'play', lab: 'Cosa dice ' + ((d && d.dogName) || 'il tuo gatto') + '?',
        sub: 'Un gioco: registra un miagolio e scopri la «traduzione»',
        azione: "go('fun')" },
      { ico: 'paw', lab: 'I miei gatti', sub: 'Cambia gatto, foto, profili',
        azione: "go('pets')" },
      { ico: 'settings', lab: 'Impostazioni e abbonamento', sub: 'Piano, aspetto, voce, dati',
        azione: "go('settings')" },
    ];
  const lista = document.getElementById('home-list');
  if (!lista) return;
  lista.innerHTML = voci.map(v =>
    '<button class="home-item" onclick="' + v.azione + '">' +
    '<span class="home-item-ico">' + icon(v.ico, 'npi-lg') + '</span>' +
    '<span class="home-item-txt"><span class="home-item-lab">' + v.lab + '</span>' +
    '<span class="home-item-sub">' + escapeHtml(v.sub) + '</span></span>' +
    /* il badge serve dove il menu e' COMPLETO: in vetrina le voci gated non
       ci sono nemmeno, quindi non c'e' nulla da marcare */
    (v.premium && !isPremium() ? '<span class="home-badge">Premium</span>' : '') +
    '</button>').join('');
}

/* La vetrina. ⚠️ Il riscatto del codice e' uno dei TRE ingressi (§13b): gli
   altri sono Impostazioni e il collegamento in fondo alla chat. Un utente
   che apre l'app con un codice in mano non deve entrare in chat per
   cercarlo. Sul nativo i tre ingressi si nascondono: li' l'acquisto passa
   da Google Play e i codici non valgono. */
function htmlVetrina() {
  return '<div class="vetrina">' +
    '<div class="vetrina-nome">NeosCat Premium</div>' +
    '<div class="vetrina-prezzo">da ' + (APP_CONFIG.premiumPriceYear || 30) +
      '€ all\'anno · disdici quando vuoi</div>' +
    '<div class="vetrina-list">' +
      FUNZIONI_PREMIUM.map(f => '<div class="vetrina-riga" data-fn="' + f.id + '">' +
        icon(f.ico) + '<span>' + f.vetrina + '</span></div>').join('') +
    '</div>' +
    '<button class="vetrina-cta" onclick="showUpgradePrompt()">Attiva Premium</button>' +
    '<button class="vetrina-link" onclick="go(\'chat\')">Prima guarda come risponde Félicette</button>' +
    '<button class="vetrina-link" onclick="openCodeModal()">' + icon('key') +
      'Ho un codice di attivazione</button>' +
  '</div>';
}

/* §13b — punto unico di riscatto. La logica (doRedeem) era gia' condivisa,
   il MARKUP dell'input no: replicarlo avrebbe creato due campi con lo stesso
   id. Qui non si duplica: si porta l'utente all'unico campo esistente. */

/* ⚠️ §0.6 — il testo del piano ha piu' rami e deve stare in UNA funzione
   sola: ricalcolandolo in piu' punti, il ramo centrale e' quello che si
   dimentica, e in PWA compariva «100 risposte su 100» a un utente che non
   puo' inviare nulla. */
function statoPiano() {
  const n = getRemainingMessages(), max = getMaxMessages();
  if (isPremium())
    return { nome: 'NeosCat Premium · ' + n + ' rispost' + (n === 1 ? 'a' : 'e') + ' su ' + max,
             dettaglio: n + ' rispost' + (n === 1 ? 'a' : 'e') + ' su ' + max + ' (30 gg)' };
  if (IS_NATIVE)
    /* sulla nativa il free ha risposte vere: il conteggio e' un'informazione
       utile, non un numero falso come sarebbe in PWA (§0.6) */
    return { nome: n + ' rispost' + (n === 1 ? 'a' : 'e') + ' gratis su ' + max,
             dettaglio: n + ' rispost' + (n === 1 ? 'a' : 'e') + ' gratis su ' + max + ' (30 gg)' };
  /* ⚠️ In PWA il free NON ha l'input: mostrare un conteggio qui significherebbe
     dire «100 risposte su 100» a chi non puo' inviare nulla (§0.6). */
  return { nome: 'Anteprima gratuita',
           dettaglio: 'Le risposte sul tuo gatto sono riservate al Premium' };
}
function testoPiano() { return statoPiano().nome; }

/* La scheda della razza non e' una schermata: e' una domanda alla chat, che
   passa da buildProfileBlock() e quindi e' calibrata su ETA', PESO e
   PATOLOGIE del gatto. Servirla da testo statico la degraderebbe proprio
   sulla personalizzazione, che e' il differenziale del prodotto. */
/* ── Scheda della razza ────────────────────────────────────────────────
   ⚠️ §0.10: la schermata dice SUBITO cosa si ottiene. Se il piano non la
   copre, il comando e' «Passa a Premium», non «Genera la scheda» che sbatte
   contro il paywall un tocco dopo.
   La scheda passa da buildProfileBlock(), quindi e' la razza letta INSIEME a
   eta', peso e patologie: non e' contenuto generico. */
async function askBreedSheet() {
  if (!isPremium()) { showUpgradePrompt(); return; }
  if (breedBusy) return;
  const id = getActiveDogId();
  const breed = (onboardingData.breed || '').trim();
  if (getRemainingMessages() <= 0) {
    const err = document.getElementById('breed-err');
    const quando = quota.resetAt ? ` Tornano disponibili dal ${fmtResetDate(quota.resetAt)}.` : '';
    if (err) err.textContent = `Hai esaurito le risposte di questo periodo.${quando}`;
    return;
  }
  breedBusy = true; renderBreedScreen();
  const prompt = (breed
    ? `Fammi una scheda completa della razza ${breed}`
    : `Fammi una scheda completa sulla razza del mio gatto`)
    + ` con quattro sezioni brevi e distinte: predisposizioni di salute tipiche, alimentazione`
    + ` consigliata, attività fisica ideale e aspettativa di vita media. Sii completo ma sintetico,`
    + ` chiudendo ogni sezione. Resta su informazioni generali ed educative.`;
  try {
    const res = await fetch(API_PROXY_URL, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({
        clientId, deviceId,
        max_tokens: BREED_SHEET_MAX_TOKENS,
        profilo: buildProfileBlock(),   // solo l'anagrafica: il prompt sta nel Worker
        messages: [{ role: 'user', content: prompt }]
      })
    });
    if (res.status === 429) {
      let info = {};
      try { info = await res.json(); } catch (e) {}
      if (info.limit) applyQuota(info);
      breedBusy = false; renderBreedScreen();
      const err = document.getElementById('breed-err');
      if (err) err.textContent = info.burst
        ? 'Troppe richieste ravvicinate. Riprova fra qualche secondo.'
        : 'Hai esaurito le risposte di questo periodo.';
      return;
    }
    if (!res.ok) throw new Error('HTTP ' + res.status);
    const data = await res.json();
    const text = data.content.filter(b => b.type === 'text').map(b => b.text).join('\n');
    if (id) saveBreedSheet(id, text, breed);
    // Quota: il Worker resta la fonte di verità (header della risposta)
    const qUsed = res.headers.get('X-Quota-Used');
    if (qUsed !== null) {
      applyQuota({
        used: parseInt(qUsed, 10),
        cap: parseInt(res.headers.get('X-Quota-Cap') || getMaxMessages(), 10),
        premium: res.headers.get('X-Quota-Premium') === '1',
        resetAt: res.headers.get('X-Quota-Reset')
      });
    } else { updateMessageCounter(); }
    sendEvent('breed_sheet');
    breedBusy = false; renderBreedScreen();
  } catch (e) {
    console.error('[Breed]', e);
    breedBusy = false; renderBreedScreen();
    const err = document.getElementById('breed-err');
    if (err) err.textContent = 'Non sono riuscito a preparare la scheda. Riprova fra poco.';
  }
}

function regenBreedSheet() {
  if (!confirm('Rigenerare la scheda? Consuma una risposta del tuo piano.')) return;
  const id = getActiveDogId();
  if (id) { try { localStorage.removeItem(breedKey(id)); } catch (e) {} }
  askBreedSheet();
}

function breedKey(id) { return APP_CONFIG.storagePrefix + 'breedsheet_' + id; }
function getBreedSheet(id) {
  try { return JSON.parse(localStorage.getItem(breedKey(id)) || 'null'); } catch (e) { return null; }
}

let breedBusy = false;
function renderBreed() {
  const box = document.getElementById('breed-body');
  if (!box) return;
  const id = getActiveDogId();
  const dog = onboardingData.dogName || 'il tuo gatto';
  const breed = (onboardingData.breed || '').trim();
  const saved = id ? getBreedSheet(id) : null;
  const prem = isPremium();

  if (breedBusy) {
    box.innerHTML = `<div class="breed-empty"><div class="breed-empty-icon">${icon('cat','npi-xl')}</div>
      <h2>Félicette sta preparando la scheda…</h2>
      <p>Ci vuole qualche secondo. Non chiudere la schermata.</p></div>`;
    return;
  }
  if (saved && saved.text) {
    let quando = '';
    try { quando = new Date(saved.at).toLocaleDateString('it-IT', { day: 'numeric', month: 'long', year: 'numeric' }); } catch (e) {}
    box.innerHTML =
      `<div class="breed-meta">
         <span>${escapeHtml(saved.breed || breed || '—')}</span>
         ${quando ? `<span>· generata il ${quando}</span>` : ''}
         <span style="flex:1"></span>
         ${prem ? '<button class="breed-regen" onclick="regenBreedSheet()">' + icon('refresh') + ' Rigenera</button>' : ''}
       </div>
       <div class="breed-doc">${escapeHtml(saved.text)}</div>
       <p class="breed-note">Informazioni generali ed educative sulla razza. Non sostituiscono
       la visita di un medico veterinario iscritto all'Albo.</p>`;
    return;
  }
  // Da free la schermata lo dice SUBITO: prima il badge sulla Home spariva entrando,
  // e il paywall arrivava solo dopo aver premuto un invito pieno («Genera la scheda»).
  if (!prem) {
    box.innerHTML =
      `<div class="breed-empty">
         <div class="breed-empty-icon">${icon('clipboard','npi-xl')}</div>
         <h2>Scheda della razza${breed ? ' — ' + escapeHtml(breed) : ''}</h2>
         <p>Con Premium, Félicette prepara una scheda su misura per ${escapeHtml(dog)}:
         predisposizioni di salute della razza lette insieme a età, peso e condizioni del
         suo profilo, alimentazione, attività fisica e aspettativa di vita.</p>
         <p>Resta salvata: riaprirla non consuma risposte.</p>
         <button class="breed-gen-btn" onclick="showUpgradePrompt()">Passa a Premium</button>
       </div>`;
    return;
  }
  box.innerHTML =
    `<div class="breed-empty">
       <div class="breed-empty-icon">${icon('clipboard','npi-xl')}</div>
       <h2>Scheda della razza${breed ? ' — ' + escapeHtml(breed) : ''}</h2>
       <p>Predisposizioni di salute, alimentazione, attività fisica e aspettativa di vita
       ${breed ? 'per la razza di ' + escapeHtml(dog) : 'del tuo gatto'}.
       Una volta generata resta salvata: riaprirla non consuma risposte.</p>
       <button class="breed-gen-btn" onclick="askBreedSheet()">Genera la scheda</button>
       <div class="breed-err" id="breed-err"></div>
     </div>`;
}
function generaSchedaRazza() {
  if (!isPremium()) { showUpgradePrompt(); return; }
  go('chat');
  setTimeout(generateBreedSheet, 60);
}

/* ── Impostazioni ─────────────────────────────────────────────────────── */
/* ── Impostazioni, sul modello: card del piano tinta di marca, poi gruppi
   con titoletto maiuscolo. ──────────────────────────────────────────── */
function renderSettings() {
  const body = document.getElementById('settings-body');
  if (!body) return;
  const prem = isPremium();
  const d = getActiveDog();
  let sub = null;
  try { sub = JSON.parse(localStorage.getItem(APP_CONFIG.storagePrefix + 'subscription') || 'null'); }
  catch (e) {}
  const scad = (sub && sub.expiresAt) ? new Date(sub.expiresAt).toLocaleDateString('it-IT') : '';
  const scuro = document.documentElement.getAttribute('data-theme') === 'dark';

  const riga = (lab, opz) => {
    const o = opz || {};
    return '<button class="set-row' + (o.cls ? ' ' + o.cls : '') + '" onclick="' + (o.azione || '') + '">' +
      '<span class="set-row-txt"><span class="set-lab">' + lab + '</span>' +
      (o.sub ? '<span class="set-sub">' + o.sub + '</span>' : '') + '</span>' +
      (o.val ? '<span class="set-val">' + o.val + '</span>' : '') +
      (o.freccia ? '<span class="set-go">›</span>' : '') + '</button>';
  };

  let html = '<div class="set-plan">' +
    '<div class="set-plan-name">' + (prem ? 'NeosCat Premium'
       : (IS_NATIVE ? 'Piano Free' : 'Anteprima gratuita')) + '</div>' +
    '<div class="set-plan-sub">' + statoPiano().dettaglio +
      (scad ? ' · attivo fino al ' + scad : '') + '</div>' +
    '<button class="set-plan-btn" onclick="' +
      (prem ? 'manageSubscription()' : 'showUpgradePrompt()') + '">' +
      (prem ? 'Gestisci abbonamento' : 'Passa a Premium') + '</button></div>';

  /* ⚠️ Le tre voci dell'abbonamento stanno in un gruppo PROPRIO, subito sotto la
     scheda del piano — accanto a «Gestisci abbonamento», che e' l'unico posto dove
     si cercano le cose dell'abbonamento. Prima erano sparse fra «Il tuo gatto» e
     «Documenti e dati»: due gruppi che parlano di altro. */
  const vociAbb =
    (serveCodiceRipristino()
      ? riga('Codice di ripristino', { sub: 'Conservalo: serve a recuperare l\'abbonamento',
             azione: 'mostraCodiceRipristino()', freccia: 1 })
      : '') +
    /* Solo al free e solo in PWA: sulla nativa il ripristino e' quello di Play, e
       offrire questo campo inviterebbe a incollare un `client_id` che la' non
       c'entra. */
    (!prem && !IS_NATIVE
      ? riga('Ho già un abbonamento', { sub: 'Ripristinalo con il codice di un altro dispositivo',
             azione: 'apriRipristino()', freccia: 1 })
      : '') +
    (!prem && !IS_NATIVE
      ? riga('Ho un codice di attivazione', { sub: 'Attiva Premium con un codice ricevuto',
             azione: 'openCodeModal()', freccia: 1 })
      : '');
  /* ⚠️ Il gruppo si nasconde INTERO quando le tre voci mancano tutte, o resta una
     intestazione «ABBONAMENTO» sospesa sopra il vuoto: succede in nativa Premium,
     dove nessuna delle tre serve. */
  if (vociAbb) {
    html += '<div><div class="set-title">Abbonamento</div><div class="set-group">' +
      vociAbb + '</div></div>';
  }

  html += '<div><div class="set-title">Il tuo gatto</div><div class="set-group">' +
    riga('Modifica il profilo', { sub: escapeHtml((d && d.dogName) || ''), azione: 'editProfile()', freccia: 1 }) +
    '</div></div>';

  html += '<div><div class="set-title">Preferenze</div><div class="set-group">' +
    riga('Tema', { val: scuro ? 'Scuro' : 'Chiaro', azione: 'toggleTheme();renderSettings()' }) +
    riga('Voce di Félicette', { sub: 'Legge ad alta voce le risposte',
         val: avatarVoiceMuted ? 'Disattivata' : 'Attiva',
         azione: 'toggleAvatarVoice();renderSettings()' }) +
    /* ⚠️ In LireaGut e' una scelta a due pulsanti; qui le preferenze si alternano al
       tocco mostrando il valore a destra, come Tema e Voce. Un terzo stile per la
       stessa cosa sarebbe una stonatura: la funzione si adatta alle convenzioni di
       chi la riceve, non si trapianta col suo aspetto. */
    riga('Dimensione del testo', {
      sub: 'Lo zoom a due dita resta sempre attivo.',
      azione: 'toggleMisuraTesto()',
      val: testoGrande() ? 'Più grande' : 'Normale' }) +
    '</div></div>';

  html += '<div><div class="set-title">Documenti e dati</div><div class="set-group">' +
    '<a class="set-row link" href="privacy.html" target="_blank">' +
      '<span class="set-row-txt"><span class="set-lab">Privacy Policy</span></span>' +
      '<span class="set-go">›</span></a>' +
    '<a class="set-row link" href="terms.html" target="_blank">' +
      '<span class="set-row-txt"><span class="set-lab">Termini di Servizio</span></span>' +
      '<span class="set-go">›</span></a>' +
    riga('Cancella i miei dati', { cls: 'danger',
         sub: 'Profili, conversazioni e preferenze su questo dispositivo',
         azione: 'deleteMyData()' }) + '</div></div>';
  body.innerHTML = html;
}

/* ── Tasto Indietro di SISTEMA ────────────────────────────────────────
   ⚠️ Plugin @capacitor/app in nativa, History API in PWA: UNA SOLA delle
   due, altrimenti un back ne consuma due.
   ⚠️ In logcat deve comparire `capacitor`. Se compare `history`, il plugin
   manca: npm i @capacitor/app && npx cap sync android  (sync, non copy). */
let indietroDiSistema = null;

/* ⚠️⚠️ IL CUSCINETTO NON SI ARMA DOVE NON C'È UN TASTO DA INTERCETTARE.
   Serve dove esiste un indietro DI SISTEMA: su Android c'è, in Safari a scheda lo
   swipe porterebbe fuori dal sito. Nell'app AGGIUNTA ALLA SCHERMATA HOME DI IPHONE
   quel tasto non esiste, e lì la sentinella non protegge da niente: è solo un
   bersaglio. Lo swipe dal bordo la trova, la consuma, mostra la sua transizione, e
   il fermo la rimette — da fuori sembra che l'app «torni da sola».
   ⚠️ Quello che si vede durante il gesto NON è una schermata dell'app: è
   l'istantanea che iOS ha catturato della voce di fondo, presa all'AVVIO. Per
   questo compare identica da qualunque pagina.

   ⚠️⚠️ LA CONDIZIONE NON È «SE iOS», e soprattutto NON È `isInstallata()`: quella
   risponde vero anche in una PWA Android (display-mode: standalone), dove il
   cuscinetto SERVE, e disarmarlo là rimetterebbe in campo l'uscita al terzo swipe.
   `navigator.standalone === true` è vero SOLO con l'app installata su iOS; su
   Android è `undefined`, e il confronto stretto lo lascia armato.

   ⚠️ UNA funzione sola per armare. Le chiamate erano due, identiche e distanti; con
   la guardia ripetuta, il giorno in cui ne nasce una terza la si dimentica — e un
   cuscinetto armato a metà è peggio di entrambe le scelte: funziona una volta e poi
   no. Il presidio controlla che di `pushState({ nav:` ce ne sia esattamente uno. */
const IOS_INSTALLATA = (navigator.standalone === true);
function armaCuscinetto() {
  if (!IOS_INSTALLATA) history.pushState({ nav: 1 }, '');
}

function installaIndietroDiSistema() {
  const Cap = window.Capacitor;
  const AppPlugin = Cap && Cap.Plugins && Cap.Plugins.App;
  if (IS_NATIVE && AppPlugin && AppPlugin.addListener) {
    indietroDiSistema = 'capacitor';
    AppPlugin.addListener('backButton', () => {
      if (!back() && AppPlugin.exitApp) AppPlugin.exitApp();
    });
  } else {
    indietroDiSistema = 'history';
    window.addEventListener('popstate', () => {
      /* ⚠️⚠️ Il cuscinetto si rimette SEMPRE, anche quando `back()` dice «non c'è
         dove tornare». Prima si riarmava solo se `back()` era true: in Home è
         false, quindi un solo swipe dal bordo consumava il cuscinetto senza
         rimetterlo, e i successivi mangiavano la cronologia vera fino a USCIRE
         dalla PWA. Misurato: al terzo swipe si è fuori.
         Su iOS lo swipe si innesca mentre si scorre, quindi in Home capita per
         sbaglio più volte al giorno.
         ⚠️ Si perde la possibilità di uscire con il back: è una perdita accettabile,
         perché in una PWA installata quel gesto non usciva comunque — chi vuole
         chiudere chiude la finestra. Sul NATIVO non cambia nulla: là il ramo è
         quello di Capacitor, con exitApp(), che resta. */
      back();
      armaCuscinetto();
    });
  }
  console.log('[Nav] tasto Indietro di sistema:', indietroDiSistema);
}
function sincronizzaIndietroDiSistema() {
  if (indietroDiSistema !== 'history') return;
  /* ⚠️ Il cuscinetto serve SEMPRE, non solo «finché c'è qualcosa da cui tornare».
     La condizione `navStack.length` lo escludeva proprio dalla Home — dove lo stack
     è vuoto per definizione — cioè dall'unico posto in cui la sua assenza fa uscire
     dall'app. Il riarmo esisteva, ed era condizionato al caso in cui non serviva. */
  /* ⚠️ Su iPhone installata `armaCuscinetto()` non fa nulla, quindi `history.state`
     resta `null` e questa riga gira a vuoto a ogni navigazione: è voluto, ed è anche
     la misura del presidio — «stato assente» significa «nessun cuscinetto armato». */
  if (!history.state) armaCuscinetto();
}

function chiudiBenvenuto() {
  segnaBenvenutoVisto();
  go('onboarding');
}

/* ═══ MENU SVILUPPATORE (§28) ═══════════════════════════════════════════
   Costruito PRIMA di toccare il layout, non dopo: senza misure si procede
   per ipotesi, e le ipotesi sbagliate introducono difetti nuovi.

   ⚠️ Le unita' di viewport (vh/svh/lvh/dvh) NON sono leggibili da
   JavaScript: vanno misurate con un elemento di prova. Lo stesso vale per
   le safe-area, che esistono solo come env() dentro il CSS. */





// updateHeaderLogo() riscrive l'innerHTML del logo, non l'elemento: il
// listener sopravvive. Se un domani venisse sostituito il nodo, questa
// chiamata va ripetuta (e' il §6 di questa stessa nota, in agguato).

const FUN_DURATA = 5;
let funCtx = null, funStream = null, funTimer = null, funRaf = null, funRec = false;
const FUN_QUOTA_FREE = 10;   // assaggi al mese, solo su NATIVA free (in PWA è Premium)

const FUN_FRASI = {
  fame: [
    'Reclamo ufficiale: la ciotola è tragicamente vuota.',
    'Ho controllato tre volte. È ancora vuota. Che sfortuna.',
    'Non per insistere, ma è passata un\'era geologica dall\'ultimo pasto.',
    'Segnalo un guasto: il distributore di croccantini non eroga.'
  ],
  gioco: [
    'Modalità turbo attivata — lanciami la pallina!',
    'Ho trovato il gioco migliore del mondo. È questo. Sempre questo.',
    'Corriamo? Dai, corriamo. Ho già deciso: corriamo.',
    'Propongo dieci minuti di follia pura. Accetti?'
  ],
  coccole: [
    'Richiesta grattini in corso. Priorità: massima.',
    'Mi sono messo qui apposta. Sai cosa fare.',
    'Solo cinque minuti di carezze. Poi altri cinque.',
    'Ti ho scelto come cuscino ufficiale. È un onore.'
  ],
  guardia: [
    'Qualcuno alla porta? Ci penso io, sono di guardia.',
    'Ho sentito un rumore. Probabilmente il nulla. Ma vigilo.',
    'Situazione sotto controllo. Per ora.',
    'Segnalo movimento sospetto nel raggio di tre metri.'
  ],
  relax: [
    'Un filo di noia… intrattienimi, umano.',
    'Sto sorvegliando il divano. È un lavoro sedentario.',
    'Oggi il programma è: niente. E lo sto rispettando.',
    'Se ti muovi vengo. Se non ti muovi resto. Sono flessibile.'
  ],
  saluto: [
    'SEI TORNATO! Il giorno più bello della mia vita!',
    'Ah, eccoti. Stavo giusto pensando a te. Come sempre.',
    'Ciao ciao ciao ciao ciao. Volevo essere chiaro.',
    'Ti ho aspettato tutto il tempo. Anche i due minuti in bagno.'
  ]
};
let funUltimaFrase = '';

// ── Quota locale (nessun server: il gioco non costa nulla) ──
function funQuotaKey() { return APP_CONFIG.storagePrefix + 'funaudio'; }
function funMeseCorrente() { const d = new Date(); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0'); }
function funUsati() {
  try {
    const r = JSON.parse(localStorage.getItem(funQuotaKey()) || '{}');
    return (r.ym === funMeseCorrente()) ? (r.n || 0) : 0;
  } catch (e) { return 0; }
}
function funSegnaUso() {
  try { localStorage.setItem(funQuotaKey(), JSON.stringify({ ym: funMeseCorrente(), n: funUsati() + 1 })); } catch (e) {}
}
// In PWA è riservato al Premium; sul nativo il free ha alcuni assaggi al mese.
function funDisponibile() {
  if (isPremium()) return { ok: true, illimitato: true };
  if (!IS_NATIVE) return { ok: false, motivo: 'premium' };
  const restanti = FUN_QUOTA_FREE - funUsati();
  return restanti > 0 ? { ok: true, restanti } : { ok: false, motivo: 'quota' };
}

// ── Analisi del suono ──────────────────────────────────────────────
// Le caratteristiche sono ADIMENSIONALI: rapporti, non grandezze assolute.
// Le soglie su "quanto è forte in RMS" non sono trasferibili — dipendono dalla
// distanza del gatto, dal microfono e soprattutto dal guadagno automatico, che
// appiattisce proprio l'intensità che si vorrebbe misurare. Un rapporto invece
// non cambia se alzi il volume o ti allontani.
//
// ⚠️ Tutto resta un GIOCO: l'analisi può essere seria, l'esito no. Nessun numero,
// nessuna percentuale, nessun "livello" esce mai verso l'utente (vedi §8 della spec).

// FFT radix-2 iterativa in place. Serve al centroide spettrale, che distingue un
// guaito acuto da un ringhio grave — cosa che lo zero-crossing rate, usato prima,
// stimava troppo grossolanamente.
function funFFT(re, im) {
  const n = re.length;
  for (let i = 1, j = 0; i < n; i++) {
    let bit = n >> 1;
    for (; j & bit; bit >>= 1) j ^= bit;
    j ^= bit;
    if (i < j) { let t = re[i]; re[i] = re[j]; re[j] = t; t = im[i]; im[i] = im[j]; im[j] = t; }
  }
  for (let len = 2; len <= n; len <<= 1) {
    const ang = -2 * Math.PI / len;
    const wr = Math.cos(ang), wi = Math.sin(ang);
    for (let i = 0; i < n; i += len) {
      let cr = 1, ci = 0;
      for (let k = 0; k < len / 2; k++) {
        const ur = re[i + k], ui = im[i + k];
        const vr = re[i + k + len / 2] * cr - im[i + k + len / 2] * ci;
        const vi = re[i + k + len / 2] * ci + im[i + k + len / 2] * cr;
        re[i + k] = ur + vr; im[i + k] = ui + vi;
        re[i + k + len / 2] = ur - vr; im[i + k + len / 2] = ui - vi;
        const ncr = cr * wr - ci * wi; ci = cr * wi + ci * wr; cr = ncr;
      }
    }
  }
}

// Centroide spettrale normalizzato su Nyquist → 0 (grave) … 1 (acuto).
function funCentroide(campioni, rate) {
  const N = 1024;
  if (campioni.length < N) return 0.3;
  const salti = Math.max(1, Math.floor(campioni.length / N / 8));
  // ⚠️ Soglia RELATIVA al picco: nelle finestre quasi silenziose resta solo il rumore
  // di fondo, che è acutissimo e falserebbe il centroide verso l'alto. Con una soglia
  // assoluta un mugolio grave in una stanza silenziosa risultava "acuto".
  let picco = 0;
  for (let i = 0; i < campioni.length; i++) { const a = Math.abs(campioni[i]); if (a > picco) picco = a; }
  const minEnergia = N * (picco * 0.12) * (picco * 0.12);
  let somma = 0, conta = 0;
  for (let off = 0; off + N <= campioni.length; off += N * salti) {
    const re = new Float64Array(N), im = new Float64Array(N);
    let energia = 0;
    for (let k = 0; k < N; k++) {
      const w = 0.5 - 0.5 * Math.cos(2 * Math.PI * k / (N - 1));   // finestra di Hann
      re[k] = campioni[off + k] * w;
      energia += re[k] * re[k];
    }
    if (energia < minEnergia) continue;      // troppo debole: è fondo di sala
    funFFT(re, im);
    let num = 0, den = 0;
    for (let k = 1; k < N / 2; k++) {
      const mag = Math.sqrt(re[k] * re[k] + im[k] * im[k]);
      num += (k / (N / 2)) * mag;
      den += mag;
    }
    if (den > 0) { somma += num / den; conta++; }
  }
  return conta ? somma / conta : 0.3;
}

// Estrae le caratteristiche. Restituite anche per la modalità diagnostica.
function funCaratteristiche(campioni, rate) {
  const n = campioni.length;
  const durata = n / rate;
  if (!n || durata < 0.15) return null;

  let somma = 0, picco = 0;
  for (let i = 0; i < n; i++) {
    const v = campioni[i]; somma += v * v;
    const a = Math.abs(v); if (a > picco) picco = a;
  }
  const rms = Math.sqrt(somma / n);
  if (rms < 1e-4 || picco < 1e-3) return null;      // praticamente silenzio

  // Segmentazione in "versi" con soglia RELATIVA al picco del pezzo, non assoluta.
  const win = Math.max(1, Math.round(rate * 0.02));
  const soglia = picco * 0.18;
  const inizi = [];
  let dentro = false, lung = 0, sonoro = 0, durate = [], energiaSonora = 0;
  for (let i = 0; i + win <= n; i += win) {
    let s2 = 0;
    for (let k = i; k < i + win; k++) s2 += campioni[k] * campioni[k];
    const e = Math.sqrt(s2 / win);
    if (e > soglia) {
      sonoro += win;
      energiaSonora += s2;              // serve al crest, vedi sotto
      if (!dentro) { dentro = true; lung = 0; inizi.push(i / rate); }
      lung += win;
    } else if (dentro) { dentro = false; durate.push(lung / rate); }
  }

  // ⚠️ crest calcolato SOLO sulle parti sonore.
  // Sul segnale intero il silenzio abbassa l'RMS e gonfia il rapporto: in cinque
  // secondi quasi vuoti anche un verso molle risultava "impulsivo". Così misurava
  // il vuoto attorno al suono, non il suono.
  const rmsSonoro = sonoro ? Math.sqrt(energiaSonora / sonoro) : rms;
  const crest = picco / Math.max(rmsSonoro, 1e-6);
  if (dentro) durate.push(lung / rate);
  const versi = durate.length;
  if (!versi) return null;

  // frazione di tempo sonoro: 0 = quasi tutto silenzio, 1 = suono continuo
  const duty = sonoro / n;
  const versiAlSec = versi / durata;
  const durataMedia = durate.reduce((a, b) => a + b, 0) / versi;
  const durataMax = Math.max.apply(null, durate);

  // regolarità del ritmo: 0 = irregolare, 1 = metronomo. Distingue l'abbaio di
  // guardia (cadenzato) da una serie di versi sparsi.
  let regolarita = 0;
  if (inizi.length >= 3) {
    const gap = [];
    for (let i = 1; i < inizi.length; i++) gap.push(inizi[i] - inizi[i - 1]);
    const m = gap.reduce((a, b) => a + b, 0) / gap.length;
    const va = gap.reduce((a, b) => a + (b - m) * (b - m), 0) / gap.length;
    regolarita = m > 0 ? Math.max(0, 1 - Math.sqrt(va) / m) : 0;
  }

  return {
    durata, crest, duty, versi, versiAlSec, durataMedia, durataMax, regolarita,
    acuto: funCentroide(campioni, rate)
  };
}

// Punteggi invece di una catena di if: un caso al confine non "cade" di netto in
// una categoria, e aggiungerne una non obbliga a riordinare le regole.
function funPunteggi(f) {
  const p = { fame: 0, gioco: 0, coccole: 0, guardia: 0, relax: 0, saluto: 0 };
  const su = (x, a, b) => Math.max(0, Math.min(1, (x - a) / (b - a)));   // rampa crescente
  const giu = (x, a, b) => 1 - su(x, a, b);                              // rampa calante

  // ⚠️ Il crest NON entra nei punteggi: sul solo sonoro dipende dalla forma d'onda,
  // non dall'impulsività del verso, e per suoni diversi vale sempre più o meno lo
  // stesso. Resta calcolato come dato diagnostico, ma non decide nulla.
  // Le caratteristiche che discriminano davvero sono cinque: quanti versi al secondo,
  // quanto sono regolari, quanta parte del tempo è sonora, quanto è acuto e quanto
  // durano i versi.

  // ⚠️ Dove una condizione è NECESSARIA il termine è un FATTORE, non un addendo.
  // Con soli addendi si prendono punti gratis: "gioco" premiava l'irregolarità anche
  // con un verso solo (dove la regolarità è 0 per definizione, non per irregolarità).

  // molti versi al secondo E cadenzati → abbaio di guardia
  p.guardia += 3.2 * su(f.versiAlSec, 0.9, 2.0) * (0.35 + 0.65 * f.regolarita);
  // versi frequenti MA irregolari → gioco
  p.gioco   += 3.0 * su(f.versiAlSec, 0.6, 1.6) * (0.3 + 0.7 * giu(f.regolarita, 0.4, 0.9));
  // un verso LUNGO e acuto, pochi in tutto → richiesta insistente
  p.fame    += 3.0 * su(f.durataMax, 0.5, 1.3) * (0.25 + 0.75 * su(f.acuto, 0.12, 0.3))
             * (0.4 + 0.6 * giu(f.versiAlSec, 0.35, 1.0));
  // buona parte del tempo sonora E grave → mugolio di richiesta coccole
  p.coccole += 3.0 * su(f.duty, 0.28, 0.6) * (0.3 + 0.7 * giu(f.acuto, 0.08, 0.3));
  // DUE-QUATTRO versi brevi e poco tempo sonoro → saluto.
  // Campana sul numero di versi: sette abbai non sono un saluto, sono un abbaio.
  p.saluto  += 3.0 * su(f.versi, 1.2, 2.2) * giu(f.versi, 3.5, 6)
             * (0.3 + 0.7 * giu(f.durataMedia, 0.14, 0.4)) * (0.4 + 0.6 * giu(f.duty, 0.05, 0.22));
  // quasi niente suono, e un verso solo → noia
  p.relax   += 3.0 * giu(f.duty, 0.03, 0.12) * (0.3 + 0.7 * giu(f.versi, 1, 2.5));
  return p;
}

function funAnalizza(campioni, sampleRate) {
  const f = funCaratteristiche(campioni, sampleRate);
  if (!f) return 'relax';                 // niente di misurabile: categoria neutra
  const p = funPunteggi(f);
  let best = 'saluto', bestV = -Infinity;
  for (const k in p) if (p[k] > bestV) { bestV = p[k]; best = k; }
  funUltimeCaratteristiche = { f, p, scelta: best };
  return best;
}
let funUltimeCaratteristiche = null;

function funPescaFrase(cat) {
  const lista = FUN_FRASI[cat] || FUN_FRASI.saluto;
  let f = lista[Math.floor(Math.random() * lista.length)];
  if (lista.length > 1 && f === funUltimaFrase) f = lista[(lista.indexOf(f) + 1) % lista.length];
  funUltimaFrase = f;
  return f;
}

function funDiagHtml() {
  if (!funDiagAttiva() || !funUltimeCaratteristiche) return '';
  const { f, p, scelta } = funUltimeCaratteristiche;
  const n = (x) => (Math.round(x * 100) / 100).toFixed(2);
  const punti = Object.keys(p).sort((a, b) => p[b] - p[a])
    .map(k => `${k === scelta ? '<b>' : ''}${k} ${n(p[k])}${k === scelta ? '</b>' : ''}`).join(' · ');
  return `<div class="fun-diag">versi ${f.versi} · ${n(f.versiAlSec)}/s · duty ${n(f.duty)} ·
    dur.media ${n(f.durataMedia)}s · dur.max ${n(f.durataMax)}s · regol ${n(f.regolarita)} ·
    acuto ${n(f.acuto)} · crest ${n(f.crest)}<br>${punti}</div>`;
}

function funCondividi(frase) {
  const nome = onboardingData.dogName || 'Il mio gatto';
  const testo = `${nome} dice: «${frase}»\n— tradotto per gioco da NeosCat AI`;
  try {
    if (navigator.share) { navigator.share({ text: testo }).catch(() => {}); return; }
    if (navigator.clipboard) {
      navigator.clipboard.writeText(testo).then(() => {
        const e = document.querySelector('.fun-left') || document.querySelector('.fun-actions');
        if (e) { const n = document.createElement('div'); n.className = 'fun-left'; n.textContent = 'Copiato!'; e.after(n); setTimeout(() => n.remove(), 2000); }
      }).catch(() => {});
    }
  } catch (e) { console.warn('[Fun] condivisione non riuscita', e); }
}

function funStato(html) { const b = document.getElementById('fun-body'); if (b) b.innerHTML = html; }

function funMostraRisultato(cat) {
  const nome = escapeHtml(onboardingData.dogName || 'Il tuo gatto');
  funStato(`<div class="fun-stage"><div class="fun-title">Sto traducendo…</div>
    <div class="fun-wave">${'<i style="height:26px"></i>'.repeat(11)}</div></div>`);
  setTimeout(() => {
    if (currentScreen !== 'fun') return;   // l'utente potrebbe essere uscito
    if (!isPremium()) funSegnaUso();
    const frase = funPescaFrase(cat);
    const d = funDisponibile();
    const residuo = (d.ok && !d.illimitato)
      ? `<div class="fun-left">Ti restano ${d.restanti} assagg${d.restanti === 1 ? 'io' : 'i'} questo mese</div>` : '';
    funStato(`<div class="fun-stage">
        <div class="fun-card">
          <div class="fun-card-who">${nome} dice</div>
          <div class="fun-quote">«${escapeHtml(frase)}»</div>
        </div>
        ${funDiagHtml()}
        <div class="fun-actions">
          <button class="fun-btn" onclick="renderFunScreen()">
            <svg class="npi" aria-hidden="true" focusable="false"><use href="#npi-refresh"/></svg> Riprova</button>
          <button class="fun-btn primary" onclick="funCondividi(${JSON.stringify(frase).replace(/"/g,'&quot;')})">
            <svg class="npi" aria-hidden="true" focusable="false"><use href="#npi-arrow-right"/></svg> Condividi</button>
        </div>
        ${residuo}
      </div>`);
  }, 1200);
}

function renderFunScreen() {
  const nome = escapeHtml(onboardingData.dogName || 'il tuo gatto');
  const d = funDisponibile();
  if (!d.ok) {
    const testo = d.motivo === 'premium'
      ? `Il gioco è incluso in Premium: registri un miagolio di ${nome} e Félicette inventa una «traduzione» — sempre allegra, sempre per gioco.`
      : `Hai usato tutti e ${FUN_QUOTA_FREE} gli assaggi di questo mese. Con Premium il gioco è senza limiti.`;
    funStato(`<div class="fun-stage">
        <div class="fun-title">Cosa dice ${nome}?</div>
        <p class="fun-sub">${testo}</p>
        <button class="fun-btn primary" style="max-width:240px" onclick="showUpgradePrompt()">Passa a Premium</button>
      </div>`);
    return;
  }
  const residuo = d.illimitato ? '' :
    `<div class="fun-left">Ti restano ${d.restanti} assagg${d.restanti === 1 ? 'io' : 'i'} questo mese</div>`;
  funStato(`<div class="fun-stage">
      <div class="fun-title">Cosa dice ${nome}?</div>
      <p class="fun-sub">Tocca il microfono e registra ${FUN_DURATA} secondi mentre ${nome} miagola.
      Poi ti dico cosa aveva da dire — o almeno, cosa mi piace pensare che abbia detto.</p>
      <button class="fun-mic" id="fun-mic" onclick="startFunRecording()" aria-label="Registra il miagolio">
        <svg class="npi" aria-hidden="true" focusable="false"><use href="#npi-mic"/></svg>
      </button>
      ${residuo}
      <div class="fun-err" id="fun-err"></div>
    </div>`);
}

function funNoMic(dettaglio) {
  const occupato = String(dettaglio || '').indexOf('NotReadable') === 0;
  const titolo = occupato ? 'Microfono occupato' : 'Serve il microfono';
  const testo = occupato
    ? `Il telefono non mi lascia aprire il microfono. Di solito è una di due cose:
       <br>• l'interruttore <b>Accesso al microfono</b> è spento nelle impostazioni rapide
       o in Privacy;<br>• un'altra app lo sta usando (una chiamata, un registratore,
       un assistente vocale).<br>Controlla e riprova.`
    : `Per giocare devo poter ascoltare. Concedi il permesso al microfono
       dalle impostazioni del telefono e riprova.<br>L'audio resta sul dispositivo.`;
  funStato(`<div class="fun-stage">
      <div class="fun-title">${titolo}</div>
      <p class="fun-sub">${testo}</p>
      <button class="fun-btn" style="max-width:200px" onclick="renderFunScreen()">Riprova</button>
      ${dettaglio ? `<div class="fun-left">Dettaglio tecnico: ${escapeHtml(String(dettaglio))}</div>` : ''}
    </div>`);
}

// Apertura del microfono con tentativi a cascata.
// `NotReadableError` NON è un problema di permessi: il sistema non riesce ad aprire la
// sorgente. Le due cause tipiche su Android sono (a) un'altra sessione audio ancora
// aperta — sintesi vocale o riconoscimento — e (b) i filtri audio di default
// (cancellazione d'eco, riduzione rumore, guadagno automatico), che alcuni dispositivi
// non sanno applicare alla WebView e fanno fallire l'apertura.
async function funApriMicrofono() {
  // (a) chiudere qualunque cosa stia usando l'audio, e dare tempo al sistema
  try { window.speechSynthesis && window.speechSynthesis.cancel(); } catch (e) {}
  try {
    const TTS = IS_NATIVE && avatarSpeaking && window.Capacitor?.Plugins?.TextToSpeech;
    if (TTS) await TTS.stop().catch(() => {});
  } catch (e) {}
  // ⚠️ SOLO se una dettatura è davvero in corso. Chiamare stop() sul plugin di
  // riconoscimento vocale quando non c'è alcuna sessione attiva fa terminare il
  // processo nativo: è un crash, non un'eccezione, e nessun .catch() lo intercetta.
  try {
    if (isListening) {
      const SRn = (typeof SRPlugin === 'function') ? SRPlugin() : null;
      if (SRn && SRn.stop) await SRn.stop().catch(() => {});
    }
  } catch (e) {}
  await new Promise(r => setTimeout(r, 250));   // il rilascio non è istantaneo

  // (b) dal vincolo più ricco al più povero: si tiene il primo che apre
  console.log('[Fun] sessioni audio rilasciate, provo ad aprire');
  const tentativi = [
    { nome: 'default',   c: { audio: true } },
    { nome: 'senza filtri', c: { audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false } } },
    { nome: 'mono 16k',  c: { audio: { channelCount: 1, sampleRate: 16000 } } },
    { nome: 'minimo',    c: { audio: {} } }
  ];
  let ultimo = null, permessoChiesto = false;
  for (const t of tentativi) {
    try {
      const st = await navigator.mediaDevices.getUserMedia(t.c);
      console.log('[Fun] microfono aperto con:', t.nome);
      return { ok: true, stream: st };
    } catch (e) {
      ultimo = e;
      console.warn('[Fun] tentativo "' + t.nome + '" fallito:', e && e.name, e && e.message);
      // Permesso mancante: lo si chiede UNA volta e si riprova questo stesso tentativo.
      if (e && e.name === 'NotAllowedError') {
        if (permessoChiesto || !IS_NATIVE) break;
        permessoChiesto = true;
        const SRn = (typeof SRPlugin === 'function') ? SRPlugin() : null;
        console.log('[Fun] chiedo il permesso · plugin:', SRn ? 'presente' : 'ASSENTE');
        if (!SRn || typeof ensureNativeMic !== 'function') break;
        const ok = await ensureNativeMic(SRn);
        console.log('[Fun] permesso RECORD_AUDIO:', ok ? 'concesso' : 'NEGATO');
        if (!ok) break;
        try {
          const st = await navigator.mediaDevices.getUserMedia(t.c);
          console.log('[Fun] microfono aperto dopo il permesso, con:', t.nome);
          return { ok: true, stream: st };
        } catch (e2) { ultimo = e2; console.warn('[Fun] anche col permesso:', e2 && e2.name); }
      }
      await new Promise(r => setTimeout(r, 150));
    }
  }
  return { ok: false, dettaglio: (ultimo && ultimo.name ? ultimo.name : 'errore sconosciuto') };
}

// ── Registrazione NATIVA (plugin) ──
// Su Android la WebView può rifiutare getUserMedia con NotReadableError anche con
// tutti i permessi concessi: il livello nativo apre il microfono, quello web no.
// Verificato su Samsung: dettatura funzionante, uso del microfono registrato dal
// sistema, e getUserMedia in errore su ogni combinazione di vincoli.
function funPluginRec() {
  return (IS_NATIVE && window.Capacitor?.Plugins?.VoiceRecorder) || null;
}

// base64 → campioni. Il plugin restituisce audio codificato (AAC/M4A su Android):
// lo si decodifica con l'AudioContext, che è lo stesso motore usato sul web.
async function funDecodifica(b64) {
  const bin = atob(b64);
  const buf = new ArrayBuffer(bin.length);
  const view = new Uint8Array(buf);
  for (let i = 0; i < bin.length; i++) view[i] = bin.charCodeAt(i);
  const AC = window.AudioContext || window.webkitAudioContext;
  const ctx = new AC();
  try {
    const audio = await ctx.decodeAudioData(buf);
    const dati = audio.getChannelData(0);
    return { campioni: Float32Array.from(dati), rate: audio.sampleRate };
  } finally { try { ctx.close(); } catch (e) {} }
}

async function startFunRecordingNativa(VR) {
  funRec = true;
  // Nessuna forma d'onda: senza flusso in tempo reale non ci sono livelli da mostrare,
  // e disegnare un'onda finta significherebbe rappresentare un dato che non esiste.
  funStato(`<div class="fun-stage">
      <div class="fun-title">In ascolto…</div>
      <div class="fun-wave" id="fun-wave">${'<i></i>'.repeat(11)}</div>
      <div class="fun-count" id="fun-count">${FUN_DURATA}</div>
      <button class="fun-mic rec" onclick="stopFunRecording(true)" aria-label="Interrompi la registrazione">
        <svg class="npi" aria-hidden="true" focusable="false"><use href="#npi-stop"/></svg>
      </button>
      <p class="fun-sub">Tocca per fermare prima</p>
    </div>`);
  // animazione di attesa, dichiaratamente decorativa
  const barre = Array.from(document.querySelectorAll('#fun-wave i'));
  funRaf = setInterval(() => {
    barre.forEach((b, i) => { b.style.height = (10 + 16 * Math.abs(Math.sin(Date.now() / 260 + i * 0.6))) + 'px'; });
  }, 90);

  try {
    if (VR.canDeviceVoiceRecord) {
      const cap = await VR.canDeviceVoiceRecord().catch(() => null);
      console.log('[Fun] il dispositivo può registrare:', cap && cap.value);
      if (cap && cap.value === false) { funRec = false; funFermaAnim(); funNoMic('registrazione non supportata'); return; }
    }
    const perm = await VR.requestAudioRecordingPermission();
    if (perm && perm.value === false) { funRec = false; funFermaAnim(); funNoMic('permesso negato'); return; }
    await VR.startRecording();
    console.log('[Fun] registrazione nativa avviata');
  } catch (e) {
    console.warn('[Fun] avvio nativo fallito:', e && e.message);
    funRec = false; funFermaAnim(); funNoMic('avvio non riuscito'); return;
  }

  let restano = FUN_DURATA;
  funTimer = setInterval(() => {
    restano--;
    const c = document.getElementById('fun-count');
    if (c) c.textContent = restano;
    if (restano <= 0) stopFunRecording(true);
  }, 1000);
}

function funFermaAnim() {
  if (funRaf) { clearInterval(funRaf); cancelAnimationFrame(funRaf); funRaf = null; }
}

async function stopFunRecordingNativa(VR, elabora) {
  funRec = false;
  let res = null;
  try { res = await VR.stopRecording(); } catch (e) { console.warn('[Fun] stop nativo:', e && e.message); }
  if (!elabora) return;
  const b64 = res && res.value && res.value.recordDataBase64;
  if (!b64) { funNoMic('registrazione vuota'); return; }
  let cat;
  try {
    const { campioni, rate } = await funDecodifica(b64);
    console.log('[Fun] formato:', (res.value && res.value.mimeType) || '?', '· decodificati', campioni.length, 'campioni a', rate, 'Hz');
    cat = funAnalizza(campioni, rate);
  } catch (e) {
    // Se la WebView non sa decodificare il formato del plugin, si ripiega sulla durata:
    // è un gioco, meglio una categoria approssimata che un errore.
    console.warn('[Fun] decodifica fallita, ripiego sulla durata:', e && e.message);
    const ms = (res.value && res.value.msDuration) || 0;
    cat = ms > 3000 ? 'relax' : (ms > 1200 ? 'gioco' : 'saluto');
  }
  funMostraRisultato(cat);
}

async function startFunRecording() {
  if (funRec) return;
  const d = funDisponibile();
  if (!d.ok) { renderFunScreen(); return; }
  // Il microfono è uno solo: se la dettatura è in corso, non si parte.
  try { if (isListening && typeof stopDictation === 'function') stopDictation(); } catch (e) {}
  try { window.speechSynthesis && window.speechSynthesis.cancel(); } catch (e) {}

  // Sul nativo si registra con il plugin: la WebView non concede l'audio al livello web.
  const VR = funPluginRec();
  if (VR) { await startFunRecordingNativa(VR); return; }
  if (IS_NATIVE) {
    console.warn('[Fun] plugin VoiceRecorder assente. Installalo con: npm i capacitor-voice-recorder && npx cap sync android');
    funNoMic('modulo di registrazione non installato'); return;
  }

  funStato(`<div class="fun-stage">
      <div class="fun-title">In ascolto…</div>
      <div class="fun-wave" id="fun-wave">${'<i></i>'.repeat(11)}</div>
      <div class="fun-count" id="fun-count">${FUN_DURATA}</div>
      <button class="fun-mic rec" id="fun-mic" onclick="stopFunRecording(true)" aria-label="Interrompi la registrazione">
        <svg class="npi" aria-hidden="true" focusable="false"><use href="#npi-stop"/></svg>
      </button>
      <p class="fun-sub">Tocca per fermare prima</p>
    </div>`);

  // Nella WebView getUserMedia fallisce se l'app non ha ancora il permesso RECORD_AUDIO
  // a runtime: il manifest da solo non basta. Si riusa la richiesta già scritta per la
  // dettatura invece di duplicarla.
  // ⚠️ Ordine: si PROVA prima, e si chiede il permesso solo se il tentativo dice che
  // manca. Interrogare il plugin di riconoscimento vocale ne provoca l'inizializzazione,
  // che a sua volta può prendere l'audio del dispositivo: chiederglielo "per sicurezza"
  // rischia di creare il problema che si voleva evitare.
  if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
    console.warn('[Fun] getUserMedia non disponibile in questo contesto');
    funRec = false; funNoMic('registrazione non disponibile'); return;
  }
  console.log('[Fun] apro il microfono…');
  const esito = await funApriMicrofono();
  if (!esito.ok) { funRec = false; funNoMic(esito.dettaglio); return; }
  funStream = esito.stream;

  funRec = true; funBuffer = [];
  const AC = window.AudioContext || window.webkitAudioContext;
  funCtx = new AC();
  funSampleRate = funCtx.sampleRate;
  const src = funCtx.createMediaStreamSource(funStream);
  const an = funCtx.createAnalyser();
  an.fftSize = 1024;
  src.connect(an);
  const dati = new Float32Array(an.fftSize);
  const barre = Array.from(document.querySelectorAll('#fun-wave i'));

  const leggi = () => {
    if (!funRec) return;
    an.getFloatTimeDomainData(dati);
    funBuffer.push(Float32Array.from(dati));   // resta in RAM: mai su disco, mai in rete
    let picco = 0;
    for (let i = 0; i < dati.length; i++) { const a = Math.abs(dati[i]); if (a > picco) picco = a; }
    const h = Math.min(44, 6 + picco * 190);
    barre.forEach((b, i) => { b.style.height = Math.max(6, h * (0.55 + 0.45 * Math.sin((Date.now() / 130) + i))) + 'px'; });
    funRaf = requestAnimationFrame(leggi);
  };
  leggi();

  let restano = FUN_DURATA;
  funTimer = setInterval(() => {
    restano--;
    const c = document.getElementById('fun-count');
    if (c) c.textContent = restano;
    if (restano <= 0) stopFunRecording(true);
  }, 1000);
}

// `elabora` false = uscita dalla schermata: si rilascia tutto senza mostrare nulla.
function stopFunRecording(elabora) {
  if (funTimer) { clearInterval(funTimer); funTimer = null; }
  const VR = funPluginRec();
  if (VR) { funFermaAnim(); if (funRec) stopFunRecordingNativa(VR, elabora); else funRec = false; return; }
  if (funRaf) { cancelAnimationFrame(funRaf); funRaf = null; }
  const stavaRegistrando = funRec;
  funRec = false;
  try { if (funStream) funStream.getTracks().forEach(t => t.stop()); } catch (e) {}
  funStream = null;
  try { if (funCtx) funCtx.close(); } catch (e) {}
  funCtx = null;
  if (!elabora || !stavaRegistrando) { funBuffer = []; return; }

  // Concatena e analizza; poi il buffer viene buttato subito.
  const tot = funBuffer.reduce((a, b) => a + b.length, 0);
  const tutto = new Float32Array(tot);
  let off = 0;
  for (const blocco of funBuffer) { tutto.set(blocco, off); off += blocco.length; }
  funBuffer = [];
  const cat = funAnalizza(tutto, funSampleRate);
  funMostraRisultato(cat);
}

let devTocchi = 0, devTimer = null;
/* Il tocco sul ritratto del gatto deve servire a DUE cose che non si escludono:
   ingrandire la foto (un tocco) e aprire il menu sviluppatore (cinque). Si
   concilia rinviando l'ingrandimento di 350 ms: se nel frattempo arriva un altro
   tocco, la raffica ha vinto e la foto non si apre.
   ⚠️ Senza `idFoto` (nessuna foto) non c'e' niente da ingrandire: si contano solo
   i tocchi, come sul logo dell'header. */
let ritrattoTimer = null;
function toccaRitratto(ev, idFoto) {
  devTocco(ev);                       // il conteggio e' lo stesso del logo
  if (!idFoto) return;
  clearTimeout(ritrattoTimer);
  if (devTocchi >= 2) return;         // raffica in corso: la foto non si apre
  ritrattoTimer = setTimeout(() => {
    if (devTocchi === 1 && typeof openPhotoLightbox === 'function') openPhotoLightbox(idFoto);
  }, 350);
}

function devTocco(ev) {
  devTocchi++;
  // ⚠️ Tracciato in console: se i tocchi non arrivano affatto, qui non compare nulla e
  // il problema è il bersaglio; se compaiono e si azzerano, è la finestra troppo corta.
  console.log('[Dev] tocco sul logo:', devTocchi, 'di 5');
  clearTimeout(devTimer);
  devTimer = setTimeout(() => { devTocchi = 0; }, 4000);   // 4 s: cinque tocchi con calma
  // Dal terzo tocco l'elemento reagisce, così si capisce di aver centrato il bersaglio
  // e di dover proseguire. Senza riscontro non si distingue «manco» da «non funziona».
  const el = (ev && ev.currentTarget) || document.getElementById('app-logo');
  if (el && devTocchi >= 3) {
    el.classList.remove('dev-eco');
    void el.offsetWidth;
    el.classList.add('dev-eco');
    setTimeout(() => el.classList.remove('dev-eco'), 400);
  }
  if (devTocchi < 5) return;
  devTocchi = 0;
  /* ⚠️ Il menu ha vinto: l'ingrandimento della foto era stato rinviato di 350 ms e
     va annullato, o si aprirebbe sotto il menu appena aperto. */
  clearTimeout(ritrattoTimer);
  openDevMenu();
}

// Via alternativa: pressione prolungata (1,5 s) sul logo. Più facile da azzeccare dei
// cinque tocchi, e indipendente dalla velocità con cui si tocca.
function devAggancia(el) {
  if (!el || el.dataset.dev === '1') return;
  el.dataset.dev = '1';
  let t = null;
  const parti = () => { clearTimeout(t); t = setTimeout(() => { devTocchi = 0; openDevMenu(); }, 1500); };
  const ferma = () => clearTimeout(t);
  el.addEventListener('touchstart', parti, { passive: true });
  ['touchend', 'touchmove', 'touchcancel'].forEach(e => el.addEventListener(e, ferma, { passive: true }));
  el.addEventListener('mousedown', parti);
  ['mouseup', 'mouseleave'].forEach(e => el.addEventListener(e, ferma));
}
document.addEventListener('DOMContentLoaded', () => {
  ['app-logo'].forEach(id => devAggancia(document.getElementById(id)));
  devAggancia(document.querySelector('.ws-logo'));
  devAggancia(document.querySelector('.onboarding-logo'));
  /* ⚠️ §30 — sulla Home rispondono alla descrizione «l'icona del gatto» DUE
     cose: il marchio nell'header e il ritratto grande. Il ritratto e'
     ricostruito da renderHome(), che riaggancia. */
  devAggancia(document.querySelector('.home-ava'));
});

function openDevMenu() {
  const m = document.getElementById('dev-modal');
  const c = document.getElementById('dev-corpo');
  if (!m || !c) return;
  c.innerHTML = `
    <button class="dev-btn" onclick="riallineaAdesso()">Riallinea adesso
      <small>Interroga subito il server sull'abbonamento, senza aspettare il ciclo dei 60 s.
      Serve a G1/G2 del collaudo e al supporto.</small></button>
    <button class="dev-btn" onclick="mostraDiagnostica()">Diagnostica finestre
      <small>Misure di viewport, contenitore, header e piè di pagina. Da copiare e inviare.</small></button>
    <button class="dev-btn" onclick="toggleDiagGioco()">Diagnostica del gioco audio: <b>${funDiagAttiva() ? 'ATTIVA' : 'spenta'}</b>
      <small>Mostra caratteristiche e punteggi sotto la frase, per tarare le soglie.</small></button>
    <button class="dev-btn pericolo" onclick="devCancellaTutto()">Cancella dati E abbonamento
      <small>Riporta il dispositivo allo stato di fabbrica, collegamento all'abbonamento compreso.
      Serve al collaudo: per un utente non avrebbe senso.</small></button>`;
  m.classList.add('open');
}
function closeDevMenu() { const m = document.getElementById('dev-modal'); if (m) m.classList.remove('open'); }

function toggleDiagGioco() {
  const nuovo = !funDiagAttiva();
  try { localStorage.setItem(APP_CONFIG.storagePrefix + 'fun_diag', nuovo ? '1' : '0'); } catch (e) {}
  openDevMenu();   // ridisegna con lo stato aggiornato
}

function devCancellaTutto() {
  const c = document.getElementById('dev-corpo');
  if (!c) return;
  c.innerHTML = `<button class="dev-btn pericolo" onclick="closeDevMenu();eseguiCancellazione(false)">
      Sì, cancella tutto definitivamente
      <small>Profilo, cronologia, preferenze e collegamento all'abbonamento.</small></button>
    <button class="dev-btn" onclick="openDevMenu()">Ripensaci</button>`;
}

// Raccoglie TUTTO ciò che serve a capire un problema di dimensioni. Le unità di viewport
// si misurano con un elemento di prova, perché non sono leggibili direttamente da JS.
function raccogliDiagnostica() {
  const q = (u) => {
    const d = document.createElement('div');
    d.style.cssText = `position:fixed;top:-9999px;height:100${u};width:1px`;
    document.body.appendChild(d);
    const h = d.getBoundingClientRect().height;
    d.remove();
    return Math.round(h);
  };
  const safe = (lato) => {
    const d = document.createElement('div');
    d.style.cssText = `position:fixed;top:-9999px;height:env(safe-area-inset-${lato});width:1px`;
    document.body.appendChild(d);
    const h = d.getBoundingClientRect().height;
    d.remove();
    return Math.round(h);
  };
  const box = (id) => {
    const e = document.getElementById(id);
    if (!e) return 'assente';
    const r = e.getBoundingClientRect();
    const st = getComputedStyle(e);
    return `${Math.round(r.top)}→${Math.round(r.bottom)} h=${Math.round(r.height)}`
         + (st.display === 'none' ? ' [nascosto]' : '');
  };
  const app = document.getElementById('app');
  const foot = document.getElementById('app-footer');
  const ar = app ? app.getBoundingClientRect() : null;
  const fr = foot ? foot.getBoundingClientRect() : null;
  const vv = window.visualViewport;
  const righe = [
    `NeosCat ${APP_CONFIG.version} · ${IS_NATIVE ? 'NATIVA' : (window.matchMedia('(display-mode: standalone)').matches ? 'PWA installata' : 'PWA browser')}`,
    `schermata: ${currentScreen} · orientamento: ${window.innerWidth > window.innerHeight ? 'orizzontale' : 'verticale'}`,
    '',
    `window.inner    ${window.innerWidth} × ${window.innerHeight}`,
    `screen          ${screen.width} × ${screen.height} · dpr ${window.devicePixelRatio}`,
    vv ? `visualViewport  ${Math.round(vv.width)} × ${Math.round(vv.height)} · offsetTop ${Math.round(vv.offsetTop)} · scale ${vv.scale.toFixed(2)}` : 'visualViewport  non disponibile',
    '',
    `100vh = ${q('vh')}   100svh = ${q('svh')}   100lvh = ${q('lvh')}   100dvh = ${q('dvh')}`,
    `safe-area  top ${safe('top')}  bottom ${safe('bottom')}  left ${safe('left')}  right ${safe('right')}`,
    '',
    `.app       ${ar ? `${Math.round(ar.top)}→${Math.round(ar.bottom)} h=${Math.round(ar.height)}` : 'assente'}`,
    `header     ${box('app-header')}`,
    `screens    ${box('screens')}`,
    `footer     ${box('app-footer')}`,
    '',
    `footer dentro l'app:      ${fr && ar ? (fr.bottom <= ar.bottom + 1 ? 'sì' : 'NO, sborda di ' + Math.round(fr.bottom - ar.bottom) + 'px') : '—'}`,
    `footer dentro la finestra: ${fr ? (fr.bottom <= window.innerHeight + 1 ? 'sì' : 'NO, sborda di ' + Math.round(fr.bottom - window.innerHeight) + 'px') : '—'}`,
    `app dentro la finestra:    ${ar ? (ar.bottom <= window.innerHeight + 1 ? 'sì' : 'NO, sborda di ' + Math.round(ar.bottom - window.innerHeight) + 'px') : '—'}`,
    '',
    `body overflow ${getComputedStyle(document.body).overflow} · scrollTop ${Math.round(window.scrollY)}`,
    `.app height (CSS) ${app ? getComputedStyle(app).height : '—'}`,
    '',
    navigator.userAgent
  ];
  return righe.join('\n');
}

/* ═══ IDENTITÀ E ABBONAMENTO ═══
   Le due righe che servono a rispondere a «di chi è questo Premium?»: l'id con
   cui l'app parla al server, e cosa il server risponde su quell'id. Affiancate,
   perché il difetto è sempre nella differenza fra le due. */
let mostraIdInteri = false;
/* Prime 8 cifre e ultime 4: bastano a riconoscere un id e a confrontarlo con la
   Manutenzione, senza lasciare il codice intero a schermo. */
function mascheraId(id) {
  if (!id) return '(nessuno)';
  if (mostraIdInteri) return id;   // come nella gemella: la bandiera si legge qui
  return id.length > 16 ? id.slice(0, 8) + '\u2026' + id.slice(-4) + '   [mascherato]' : id;
}

async function diagnosticaAbbonamento() {
  const pre = APP_CONFIG.storagePrefix;
  const L = [];
  const riga = (k, v) => L.push(('  ' + k).padEnd(26) + v);
  /* ⚠️ Dalla v1.0.47 il clientId E' il codice di ripristino, cioe' una credenziale:
     chi lo ha puo' usare l'abbonamento. Mostrarlo in chiaro in un pannello che si
     apre con cinque tocchi e' un rischio NUOVO — prima quel valore non serviva a
     niente. Mascherato per difetto, si scopre con un tocco.
     REGOLA: quando un dato cambia RUOLO — da identificativo tecnico a credenziale —
     vanno riviste tutte le schermate che lo mostravano. Il valore e' lo stesso, il
     rischio no. */
  riga('clientId', mascheraId(clientId));
  riga('deviceId', mascheraId(deviceId));
  let sub = null;
  try { sub = JSON.parse(localStorage.getItem(pre + 'subscription') || 'null'); } catch (e) {}
  riga('copia locale', sub
    ? (sub.active ? 'attiva' : 'non attiva') + ' · ' + (sub.plan || '?') +
      ' · da ' + (sub.source || '?') + ' · fino al ' + (sub.expiresAt || '?').slice(0, 10) +
      (sub.source ? '' :
       '\n  ⚠️ FONTE IGNOTA: «Gestisci abbonamento» potrebbe portare nel posto sbagliato')
    : '(assente)');
  riga('isPremium()', (typeof isPremium === 'function') ? isPremium() : '?');
  /* Gli id PRECEDENTI di questo dispositivo. Servono esattamente al caso di E3:
     se il server non riconosce l'id attuale, l'abbonamento può essere legato a
     uno vecchio — e senza questo elenco non c'è modo di saperlo dopo. */
  /* ⚠️ In nativa il deviceId viene da Device.getId() ed e' stabile: «id precedenti»
     non ha senso li', e interrogare /status su id inesistenti produce righe
     «premium: no» che sembrano un problema e non lo sono. */
  /* In nativa il deviceId viene da Device.getId() ed e' stabile: id precedenti non
     ce ne sono per costruzione. */
  let storico = IS_NATIVE ? [] : storicoId();
  /* ⚠️ I giri passati hanno lasciato l'id attuale nell'elenco: si ripulisce una
     volta, passando dalla funzione che conosce la regola. */
  if (storico.includes(clientId)) {
    aggiungiAStorico(null, clientId);
    storico = storicoId();
  }
  /* ⚠️ Mascherati anche qui: sono gli stessi codici che altrove ho dichiarato
     credenziali, e mostrarli in chiaro in questa riga vanificava la maschera. */
  riga('id precedenti', storico.length ? storico.map(mascheraId).join(', ') : '(nessuno)');
  /* ⚠️ Qui c'era `versioneWorker`, popolata dalla chiamata a /status fatta
     ALL'AVVIO — mentre poche righe sotto la diagnostica ne fa una nuova.
     Risultato visto su un iPhone reale: «worker: ancora ignota» sopra e
     `"worker":"1.9.7-cat"` nel grezzo, nello STESSO pannello. Due sorgenti per
     lo stesso dato, e si leggeva la piu' vecchia: e' il §19.2 in miniatura.
     Ora e' un segnaposto, sostituito alla fine col valore appena ottenuto. */
  riga('app', APP_CONFIG.version + '  ·  worker: @@WORKER@@');
  /* In una WebView non c'e' lo sfratto per mancanza di spazio che quella richiesta
     previene: la riga ha senso solo in PWA. */
  if (!IS_NATIVE) {
    riga('archivio persistente', archivioPersistente === null ? 'non richiesto'
         : (archivioPersistente ? 'sì' : 'no'));
  }
  const da = ultimoControlloStatus ? Math.round((Date.now() - ultimoControlloStatus) / 1000) : null;
  riga('ultimo /status', da === null ? 'mai in questa sessione'
       : da + 's fa · prossimo automatico fra ' + Math.max(0, 60 - (da % 60)) + 's');
  if (IS_NATIVE) {
    /* ⚠️ Ciò che serve DAVVERO sulla nativa, e che finora non c'era: lo stato del
       billing di Google. Senza, un problema di acquisto su Play non ha nessun
       appiglio diagnostico — e «rischio iOS» lì è sempre «no». */
    const cdv = window.CdvPurchase;
    riga('billing Play', !cdv ? '\u26a0\ufe0f plugin ASSENTE (app non installata dallo store?)'
         : (cdv.store ? 'plugin presente \u00b7 store pronto' : 'plugin presente \u00b7 store non pronto'));
    try {
      const pid = APP_CONFIG.playProductId || '';
      const prod = cdv && cdv.store && typeof cdv.store.get === 'function' && pid
        ? cdv.store.get(pid) : null;
      riga('prodotto', prod ? (prod.id || pid) + ' \u00b7 ' + (prod.owned ? 'POSSEDUTO' : 'non posseduto')
           : '(non interrogabile: ' + (pid || 'nessun id prodotto') + ')');
    } catch (e) { riga('prodotto', 'errore: ' + e.message); }
  } else {
    riga('rischio iOS', serveAvvisoArchivio()
         ? '⚠️ SÌ — rimuovere l\'app cancellerebbe il legame con l\'abbonamento'
         : 'no');
  }
  L.push('');
  L.push('  interrogazione di /status …');
  const out = L.join('\n');

  /* ⚠️ Si stampa la risposta GREZZA, non i campi che sembrano utili: quando manca
     un campo — `source`, la versione del Worker — il difetto si vede solo se la
     risposta si legge per intero. Tre volte in questa indagine la diagnosi si e'
     fermata su un valore che la diagnostica non mostrava. */
  const chiedi = async (id) => {
    try {
      const r = await chiediAlWorker('/status', { clientId: id });
      if (!r.ok) return 'HTTP ' + r.status;
      const d = await r.json();
      const sintesi = d.premium
        ? 'premium: SÌ · ' + (d.plan || '?') + ' · da ' + (d.source || 'FONTE NON RESTITUITA ⚠️') +
          ' · fino al ' + (d.expiresAt || '?').slice(0, 10)
        : 'premium: no';
      /* ⚠️ `source` non c'e' nei rami negativi, e giustamente: senza abbonamento non
         ha una fonte. Guardare quello faceva gridare al lupo su risposte corrette.
         La firma del Worker invece c'e' SEMPRE: e' quella che dice se e' aggiornato. */
      const nota = ('worker' in d) ? '' :
        '\n     ⚠️ il Worker non si firma: va ridistribuito (serve almeno la 1.9.10-cat)';
      return sintesi + nota + '\n     grezzo: ' + JSON.stringify(d);
    } catch (e) { return 'non raggiungibile (' + e.message + ')'; }
  };
  const righe = [out.replace('  interrogazione di /status …', '  /status sull\'id attuale')];
  righe.push(('  → ' + clientId).slice(0, 46) + '\n     ' + await chiedi(clientId));
  for (const vecchio of storico) {
    const esito = await chiedi(vecchio);
    righe.push('  → ' + vecchio + '\n     ' + esito +
               (esito.includes('SÌ') ? '   ⚠️ ABBONAMENTO SU UN ID PRECEDENTE' : ''));
  }
  /* Il segnaposto si sostituisce alla fine, quando la versione e' quella appena
     ottenuta: cosi' le due righe non possono contraddirsi. */
  return righe.join('\n')
              .replace('@@WORKER@@', versioneWorker || 'nessuna risposta dal server');
}

/* Interroga /status SUBITO, senza aspettare il ciclo dei 60 s. Serve al collaudo
   (G1, G2: revoca e riattivazione) e al supporto, quando bisogna sapere adesso se
   il server e l'app dicono la stessa cosa.
   ⚠️ Azzera anche la grazia e il contatore dei negativi: si vuole la risposta
   VERA del server, non quella filtrata dalle difese del §19.1. */
async function riallineaAdesso() {
  const c = document.getElementById('dev-corpo');
  if (c) c.innerHTML = '<div class="dev-out">Interrogo il server…</div>';
  const prima = isPremium();
  ultimaAttivazioneLocale = 0;
  negativiConsecutivi = 0;
  try {
    await refreshPremiumStatus();
    /* ⚠️ La doppia conferma del §19.1 vuole DUE letture negative prima di revocare,
       e la seconda arriva 6 s dopo. In uno strumento diagnostico quell'attesa non
       ha senso: si fa subito la seconda lettura, cosi' la regola e' rispettata —
       due letture, non una — senza far aspettare chi sta collaudando. */
    if (negativiConsecutivi > 0) await refreshPremiumStatus();
    if (typeof refreshQuota === 'function') await refreshQuota();
  } catch (e) {}
  const dopo = isPremium();
  aggiornaDopoCambioPiano();
  const esito = prima === dopo
    ? 'Nessun cambiamento: ' + (dopo ? 'Premium attivo' : 'piano gratuito') + '.'
    : (dopo ? 'Premium ATTIVATO adesso.' : 'Premium REVOCATO adesso.');
  console.log('[Riallineo] manuale:', esito);
  if (c) {
    c.innerHTML = '<div class="dev-out">' + escapeHtml(esito) + '</div>' +
      '<button class="dev-btn" onclick="mostraDiagnostica()">Vedi la diagnostica</button>' +
      '<button class="dev-btn" onclick="riallineaAdesso()">Riallinea di nuovo</button>' +
      '<button class="dev-btn" onclick="openDevMenu()">Indietro</button>';
  }
}

async function mostraDiagnostica() {
  const testo = raccogliDiagnostica();
  const c = document.getElementById('dev-corpo');
  if (!c) return;
  const disegna = (extra) => {
    c.innerHTML = `<div class="dev-out" id="dev-out">${escapeHtml(testo + '\n\n' + extra)}</div>
      <button class="dev-btn" onclick="mostraIdInteri=!mostraIdInteri;mostraDiagnostica()">
        ${mostraIdInteri ? 'Nascondi i codici' : 'Mostra i codici per intero'}
        <small>${mostraIdInteri
          ? '\u26a0\ufe0f Il codice di ripristino vale come una password: non lasciarlo a schermo.'
          : 'Servono a confrontarli con la Manutenzione o per il ripristino.'}</small></button>
      <button class="dev-btn" onclick="copiaDiagnostica()">Copia negli appunti
        <small>${mostraIdInteri ? '\u26a0\ufe0f i codici verranno copiati per intero'
                                      : 'i codici restano mascherati'}</small></button>
      <button class="dev-btn" onclick="mostraDiagnostica()">Rimisura adesso</button>
      <button class="dev-btn" onclick="openDevMenu()">Indietro</button>`;
  };
  disegna('IDENTITÀ E ABBONAMENTO\n  interrogazione in corso…');
  try { disegna('IDENTITÀ E ABBONAMENTO\n' + await diagnosticaAbbonamento()); }
  catch (e) { disegna('IDENTITÀ E ABBONAMENTO\n  errore: ' + e.message); }
}

function copiaDiagnostica() {
  const t = document.getElementById('dev-out');
  if (!t) return;
  const testo = t.textContent;
  const fatto = () => { const b = document.querySelector('#dev-corpo .dev-btn'); if (b) b.textContent = 'Copiato ✓'; };
  try {
    if (navigator.clipboard) navigator.clipboard.writeText(testo).then(fatto, () => {});
    else { const ta = document.createElement('textarea'); ta.value = testo; document.body.appendChild(ta);
           ta.select(); document.execCommand('copy'); ta.remove(); fatto(); }
  } catch (e) { console.log('[Dev]\n' + testo); }
}

// ── Modalità diagnostica (nascosta) ──
// Cinque tocchi sul numero di versione nel footer. Serve a tarare le soglie con versi
// veri invece che con i profili sintetici della suite: la card mostra le caratteristiche
// estratte e i punteggi. L'utente non la incontra mai per caso.

function funDiagAttiva() {
  try { return localStorage.getItem(APP_CONFIG.storagePrefix + 'fun_diag') === '1'; } catch (e) { return false; }
}

// ═══ SERVICE WORKER (PWA: offline + installabilità) ═══
// Funziona solo su HTTPS (es. Cloudflare Pages), non aprendo il file in locale.
// SOLO in PWA. Nella nativa la WebView Capacitor parte da http://localhost, quindi il
// Service Worker si installerebbe davvero e metterebbe in cache un'app shell che è GIÀ
// locale: nessun vantaggio, e un livello di cache in più che può servire un index.html
// vecchio dopo un `cap copy` — facendo sembrare che una correzione non sia arrivata.
if ('serviceWorker' in navigator && !IS_NATIVE) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('sw.js')
      .then(reg => console.log('[SW] Registrato:', reg.scope))
      .catch(err => console.warn('[SW] Registrazione fallita:', err));
  });
} else if ('serviceWorker' in navigator && IS_NATIVE) {
  // Chi ha già l'app installata ha un Service Worker REGISTRATO da una versione
  // precedente: smettere di registrarlo non lo rimuove. Va disinstallato attivamente,
  // altrimenti continua a servire la sua cache per sempre.
  window.addEventListener('load', () => {
    navigator.serviceWorker.getRegistrations()
      .then(regs => {
        if (!regs.length) return;
        console.log('[SW] Nativa: rimuovo', regs.length, 'registrazione/i residua/e');
        return Promise.all(regs.map(r => r.unregister()))
          .then(() => caches && caches.keys ? caches.keys() : [])
          .then(keys => Promise.all((keys || []).map(k => caches.delete(k))));
      })
      .catch(err => console.warn('[SW] Pulizia nativa fallita:', err));
  });
}
</script>
<div class="rip-modal" id="rip-modal" onclick="if(event.target===this)closeRipModal()">
  <div class="rip-content">
    <h3 id="rip-titolo">Codice di ripristino</h3>
    <div id="rip-blocco-mostra">
      <p id="rip-testo"></p>
      <div class="rip-codice" id="rip-codice"></div>
      <button class="rip-btn" id="rip-copia" onclick="copiaCodiceRipristino()">Copia il codice</button>
    </div>
    <div id="rip-blocco-usa" style="display:none">
      <p>Incolla il codice di ripristino del dispositivo su cui l'abbonamento era attivo.</p>
      <div class="rip-avviso"><b>Chi ha il codice usa l'abbonamento.</b>
        Se lo usi su due dispositivi, questi condividono la stessa quota di risposte.</div>
      <input type="text" id="rip-input" placeholder="0000aaaa-0000-0000-0000-000000000000"
             autocomplete="off" spellcheck="false" aria-label="Codice di ripristino">
      <div class="rip-msg" id="rip-msg"></div>
      <button class="rip-btn" id="rip-usa-btn" onclick="usaCodiceRipristino()">Ripristina</button>
      <p class="rip-aiuto">Non hai più accesso a quel dispositivo? Scrivi a
        <b id="rip-supporto">assistenza</b> indicando l'email usata al pagamento:
        ripristiniamo noi l'abbonamento dopo una verifica.</p>
    </div>
    <button class="rip-annulla" onclick="closeRipModal()">Chiudi</button>
  </div>
</div>

<div class="dev-modal" id="dev-modal" onclick="if(event.target===this)closeDevMenu()">
  <div class="dev-content">
    <h3>Menu sviluppatore</h3>
    <p class="dev-sub">Non raggiungibile per caso: cinque tocchi sul logo.</p>
    <div id="dev-corpo"></div>
    <button class="dev-chiudi" onclick="closeDevMenu()">Chiudi</button>
  </div>
</div>

<div class="code-modal" id="code-modal" onclick="if(event.target===this)closeCodeModal()">
  <div class="code-modal-content">
    <h3>Ho un codice di attivazione</h3>
    <p>Inserisci il codice che hai ricevuto per attivare NeosCat Premium su questo dispositivo.</p>
    <input type="text" id="free-code-input" placeholder="Inserisci il codice" autocapitalize="characters"
           autocomplete="off" spellcheck="false" maxlength="40" aria-label="Codice di attivazione">
    <button class="cm-go" id="free-code-btn" onclick="redeemFreeCode()">Riscatta</button>
    <div class="free-code-msg" id="free-code-msg"></div>
    <button class="cm-close" onclick="closeCodeModal()">Annulla</button>
  </div>
</div>

<div class="del-modal" id="del-modal" onclick="if(event.target===this)closeDeleteModal()">
  <div class="del-content">
    <h3>Cancella i miei dati</h3>
    <p id="del-intro">Verranno eliminati da questo dispositivo il profilo del tuo gatto,
    la cronologia delle conversazioni e le preferenze. L'operazione è irreversibile.</p>
    <div id="del-scelte"></div>
    <button class="del-annulla" onclick="closeDeleteModal()">Non cancellare nulla</button>
  </div>
</div>

<div class="photo-lightbox" id="photo-lightbox" onclick="if(event.target===this)closePhotoLightbox()">
  <div class="pl-inner">
    <button class="pl-close" onclick="closePhotoLightbox()" aria-label="Chiudi i promemoria"><svg class="npi" aria-hidden="true" focusable="false"><use href="#npi-x"/></svg></button>
    <img id="pl-img" src="" alt="Foto del gatto">
    <div class="pl-name" id="pl-name"></div>
    <div class="pl-actions" id="pl-actions"></div>
  </div>
</div>
</body>
</html>
