scripts/analyze-panel.jsjavascript
/** @module analyze-panel */
import { els } from './dom.js';
import { state } from './state.js';
import { setLog } from './utils.js';
import { stopEffectLoop, startEffectLoop } from './camera.js';
import { runEffectPass, hasActivePlugin } from './engine.js';
import { getFaceEmbedding, cosineSimilarity } from './engine-3d.js';
import { captureThumbnail, getThumbnail } from './face-thumbnails.js';
import { ANALYZE_PANEL_MAX_WIDTH_DESKTOP, DETECTOR_OPTIONS } from './config.js';
import {
   seekFaceInDb,
   decideMatchState,
   distanceToDiversity,
} from './landmark-analysis.js';
import { t } from './i18n.js';

const EXPLAINERS = {
   age: 'analysis_explainer_age',
   gender: 'analysis_explainer_gender',
   emotion: 'analysis_explainer_emotion',
   confidence: 'analysis_explainer_confidence',
   distanceClosest: 'analysis_explainer_distance',
   threshold: 'analysis_explainer_threshold',
   embedder: 'analysis_explainer_embedder',
};

let modalEls = null;
let activeSnapshot = null;
let latestReportData = null;
let isOpen = false;
let escHandler = null;

function ensureModalEls() {
   if (modalEls) return modalEls;
   modalEls = {
      root: document.getElementById('analyzeModal'),
      backdrop: document.getElementById('analyzeBackdrop'),
      panel: document.getElementById('analyzePanel'),
      canvas: document.getElementById('analyzeCanvas'),
      info: document.getElementById('analyzeInfo'),
      copyBtn: document.getElementById('analyzeCopyBtn'),
      closeBtn: document.getElementById('analyzeCloseBtn'),
   };
   return modalEls;
}

function getDominantEmotion(expressions) {
   if (!expressions) return null;
   let key = null;
   let best = -Infinity;
   for (const [k, v] of Object.entries(expressions)) {
      if (v > best) {
         key = k;
         best = v;
      }
   }
   return key;
}

function pct(score) {
   if (!Number.isFinite(score)) return '-';
   return `${Math.round(score * 100)}%`;
}

function clearCanvas() {
   const ui = ensureModalEls();
   if (!ui.canvas) return;
   const ctx = ui.canvas.getContext('2d');
   ctx.clearRect(0, 0, ui.canvas.width, ui.canvas.height);
}

function drawSnapshotToPanelCanvas(snapshotCanvas) {
   const ui = ensureModalEls();
   if (!ui.canvas) return;
   ui.canvas.width = snapshotCanvas.width;
   ui.canvas.height = snapshotCanvas.height;
   const ctx = ui.canvas.getContext('2d');
   ctx.drawImage(snapshotCanvas, 0, 0, ui.canvas.width, ui.canvas.height);
}

function computeEmbedderStats(embedding, closestId) {
   if (!embedding || !state.db3d || !Array.isArray(state.db3d.faces) || state.db3d.faces.length === 0) {
      return { bestId: null, bestSimilarity: null, closestIdSimilarity: null };
   }

   let bestId = null;
   let bestSimilarity = -Infinity;
   for (const rec of state.db3d.faces) {
      const sim = cosineSimilarity(embedding, rec.descriptor3d);
      if (sim > bestSimilarity) {
         bestSimilarity = sim;
         bestId = rec.id;
      }
   }

   let closestIdSimilarity = null;
   if (typeof closestId === 'number') {
      const sameId = state.db3d.faces.find((f) => f.id === closestId);
      if (sameId) closestIdSimilarity = cosineSimilarity(embedding, sameId.descriptor3d);
   }

   return {
      bestId,
      bestSimilarity: Number.isFinite(bestSimilarity) ? bestSimilarity : null,
      closestIdSimilarity,
   };
}

function snapshotFromVideo() {
   const canvas = document.createElement('canvas');
   canvas.width = els.video.videoWidth || 1280;
   canvas.height = els.video.videoHeight || 720;
   const ctx = canvas.getContext('2d');
   ctx.drawImage(els.video, 0, 0, canvas.width, canvas.height);
   return canvas;
}

function panelStateFromAnalysis(analysis) {
   const closestDiversity = Number.isFinite(analysis.closestDistance)
      ? distanceToDiversity(analysis.closestDistance)
      : null;
   const thresholdDiversity = distanceToDiversity(state.MATCH_THRESHOLD);

   return {
      ...analysis,
      closestDiversity,
      thresholdDiversity,
   };
}

function buildVisualComparisonSection(analysis) {
   const visual = analysis.visualComparison;
   if (!visual || !visual.currentDataUrl || typeof visual.closestId !== 'number') {
      return '';
   }

   const leftPreview = visual.baseDataUrl
      ? `<img class="analyze-compare-image" src="${visual.baseDataUrl}" alt="${t('baseline_thumbnail_alt', { id: visual.closestId })}" />`
      : `<div class="history-placeholder analyze-compare-placeholder">${t('no_preview')}</div>`;

   return `
      <section class="analyze-section">
         <h3>${t('visual_comparison_title')}</h3>
         <div class="analyze-compare-row" aria-label="${t('visual_comparison_label')}">
            <div class="analyze-compare-col">
               ${leftPreview}
               <div class="analyze-compare-label">ID ${visual.closestId}</div>
            </div>
            <div class="analyze-compare-arrow" aria-hidden="true">↔</div>
            <div class="analyze-compare-col">
               <img class="analyze-compare-image" src="${visual.currentDataUrl}" alt="${t('current_thumbnail_alt')}" />
               <div class="analyze-compare-label">${t('current_you_label')}</div>
            </div>
         </div>
      </section>
   `;
}

function renderInfo(analysis) {
   const ui = ensureModalEls();
   if (!ui.info) return;

   const hasFace = !!analysis.faceResult;

   if (!hasFace) {
      ui.info.innerHTML = `
         <section class="analyze-section">
            <h3>${t('face_detected_title')}</h3>
            <p class="analyze-empty">${t('no_face_snapshot')}</p>
         </section>
         <section class="analyze-section">
            <h3>${t('recognition_title')}</h3>
            <p>${analysis.matchHeadline || t('no_recognition_data')}</p>
         </section>
      `;
      return;
   }

   const closestLine = typeof analysis.closestId === 'number'
      ? `ID ${analysis.closestId}`
      : t('no_id_found');
   const diversityLine = analysis.closestDiversity != null
      ? `${analysis.closestDiversity}%`
      : '-';

   const section3dId = typeof analysis.closestId === 'number' ? analysis.closestId : analysis.embedderBestId;
   const section3dValue = Number.isFinite(analysis.embedderClosestSimilarity)
      ? analysis.embedderClosestSimilarity
      : analysis.embedderBestSimilarity;

   const visualComparisonSection = buildVisualComparisonSection(analysis);

   ui.info.innerHTML = `
      <section class="analyze-section">
         <h3>${t('face_detected_title')}</h3>
         <div class="analyze-metric"><strong>${t('estimated_age_label')}</strong> ${Math.round(analysis.faceResult.age || 0)}</div>
         <p>${t(EXPLAINERS.age)}</p>
         <div class="analyze-metric"><strong>${t('predicted_gender_label')}</strong> ${analysis.faceResult.gender || '-'}</div>
         <p>${t(EXPLAINERS.gender)}</p>
         <div class="analyze-metric"><strong>${t('dominant_emotion_label')}</strong> ${analysis.dominantEmotion || '-'}</div>
         <p>${t(EXPLAINERS.emotion)}</p>
         <div class="analyze-metric"><strong>${t('detection_confidence_label')}</strong> ${pct(analysis.faceResult.detection?.score)}</div>
         <p>${t(EXPLAINERS.confidence)}</p>
      </section>

      <section class="analyze-section">
         <h3>${t('recognition_title')}</h3>
         <div class="analyze-metric"><strong>${t('state_label')}</strong> ${analysis.matchHeadline || '-'}</div>
         <div class="analyze-metric"><strong>${t('match_with_id_label')}</strong> ${closestLine}</div>
         <div class="analyze-metric"><strong>${t('diversity_from_baseline_label')}</strong> ${diversityLine}</div>
         <p>${t(EXPLAINERS.distanceClosest)}</p>
         <div class="analyze-metric"><strong>${t('recognition_threshold_label')}</strong> ${analysis.thresholdDiversity}%</div>
         <p>${t(EXPLAINERS.threshold)}</p>
         <div class="analyze-metric"><strong>${t('interpretation_label')}</strong> ${t('analysis_interpretation', { diversity: diversityLine, id: typeof analysis.closestId === 'number' ? `ID ${analysis.closestId}` : '', threshold: analysis.thresholdDiversity })}</div>
         <div class="analyze-metric"><strong>Embedder 3D:</strong> similarity con ${section3dId != null ? `ID ${section3dId}` : 'closest match'}: ${Number.isFinite(section3dValue) ? section3dValue.toFixed(3) : '-'}</div>
         <p>${t(EXPLAINERS.embedder)}</p>
      </section>

      ${visualComparisonSection}
   `;
}

function wireModalEvents() {
   const ui = ensureModalEls();
   if (!ui.copyBtn || !ui.closeBtn || !ui.backdrop) return;

   ui.copyBtn.onclick = async () => {
      const text = generateReportText();
      try {
         await navigator.clipboard.writeText(text);
         setLog(t('report_copied_log'));
      } catch {
         setLog(t('report_copy_failed_log'));
      }
   };

   ui.closeBtn.onclick = () => closeAnalyzePanel();
   ui.backdrop.onclick = (ev) => {
      if (ev.target === ui.backdrop) closeAnalyzePanel();
   };
}

function showModal(snapshot) {
   const ui = ensureModalEls();
   if (!ui.root || !ui.backdrop || !ui.panel) return;

   const dataUrl = snapshot.toDataURL('image/png');
   ui.backdrop.style.backgroundImage = `linear-gradient(rgba(0,0,0,0.55), rgba(0,0,0,0.55)), url('${dataUrl}')`;
   ui.panel.style.maxWidth = `${ANALYZE_PANEL_MAX_WIDTH_DESKTOP}px`;
   ui.root.hidden = false;
   requestAnimationFrame(() => ui.root.classList.add('open'));
   isOpen = true;

   escHandler = (ev) => {
      if (ev.key === 'Escape') closeAnalyzePanel();
   };
   window.addEventListener('keydown', escHandler);
}

async function detectOnSnapshot(snapshotCanvas) {
   if (!faceapi || !faceapi.detectSingleFace) return null;
   try {
      return await faceapi
         .detectSingleFace(snapshotCanvas, DETECTOR_OPTIONS)
         .withFaceLandmarks()
         .withFaceDescriptor()
         .withAgeAndGender()
         .withFaceExpressions();
   } catch (err) {
      setLog(t('analyze_error_log', { message: err.message || String(err) }));
      return null;
   }
}

async function buildVisualComparison(faceResult, closestId) {
   if (!faceResult || typeof closestId !== 'number') return null;

   const box = faceResult?.detection?.box;
   if (!box) return null;

   let currentDataUrl = null;
   try {
      currentDataUrl = await captureThumbnail(els.video, box);
   } catch {
      currentDataUrl = null;
   }

   if (!currentDataUrl) return null;

   return {
      closestId,
      baseDataUrl: getThumbnail(closestId),
      currentDataUrl,
   };
}

async function composeAnalysisData(faceResult, embedding) {
   const dbHasFaces = !!(state.db && Array.isArray(state.db.faces) && state.db.faces.length > 0);
   const dominantEmotion = faceResult ? getDominantEmotion(faceResult.expressions) : null;

   let closestId = null;
   let closestDistance = null;
   let matchHeadline = null;
   let visualComparison = null;

   if (!faceResult) {
      if (hasActivePlugin()) {
         matchHeadline = decideMatchState({
            liveMinDist: null,
            liveMinId: null,
            obfMinDist: null,
            obfMinId: null,
            weakDetection: false,
            detectionTotallyFailed: true,
         }).headline;
      }
   } else if (dbHasFaces) {
      const nearest = seekFaceInDb(faceResult);
      closestId = nearest.liveMinId;
      closestDistance = nearest.liveMinDist;
      matchHeadline = decideMatchState({
         liveMinDist: nearest.liveMinDist,
         liveMinId: nearest.liveMinId,
         obfMinDist: null,
         obfMinId: null,
         weakDetection: false,
         detectionTotallyFailed: false,
      }).headline;

      visualComparison = await buildVisualComparison(faceResult, closestId);
   }

   const embedderStats = computeEmbedderStats(embedding, closestId);

   return panelStateFromAnalysis({
      faceResult,
      dominantEmotion,
      dbHasFaces,
      closestId,
      closestDistance,
      matchHeadline,
      visualComparison,
      embedderBestId: embedderStats.bestId,
      embedderBestSimilarity: embedderStats.bestSimilarity,
      embedderClosestSimilarity: embedderStats.closestIdSimilarity,
   });
}

export async function openAnalyzePanel() {
   const ui = ensureModalEls();
   if (!ui.root) return;

   wireModalEvents();
   stopEffectLoop();

   activeSnapshot = snapshotFromVideo();
   showModal(activeSnapshot);
   drawSnapshotToPanelCanvas(activeSnapshot);

   const [faceResult, embedding] = await Promise.all([
      detectOnSnapshot(activeSnapshot),
      getFaceEmbedding(activeSnapshot).catch(() => null),
   ]);

   latestReportData = await composeAnalysisData(faceResult, embedding);

   if (faceResult) {
      drawSnapshotToPanelCanvas(activeSnapshot);
   } else {
      clearCanvas();
      drawSnapshotToPanelCanvas(activeSnapshot);
   }

   renderInfo(latestReportData);
}

export function closeAnalyzePanel() {
   const ui = ensureModalEls();
   if (!ui.root || !isOpen) return;

   ui.root.classList.remove('open');
   window.setTimeout(() => {
      ui.root.hidden = true;
   }, 150);

   isOpen = false;
   if (escHandler) {
      window.removeEventListener('keydown', escHandler);
      escHandler = null;
   }

   startEffectLoop();
   runEffectPass();
}

export function generateReportText() {
   const data = latestReportData;
   if (!data) {
      return [
         t('report_title_markdown'),
         '',
         t('report_face_detected_line', { value: t('no_value') }),
         '',
         t('report_recognition_2d_heading'),
         '',
         t('no_data_available'),
      ].join('\n');
   }

   const lines = [];
   lines.push(t('report_title_markdown'));
   lines.push('');
   lines.push(t('report_face_detected_line', { value: data.faceResult ? t('yes_value') : t('no_value') }));

   if (data.faceResult) {
      lines.push('');
      lines.push(t('report_estimated_age_line', { value: Math.round(data.faceResult.age || 0) }));
      lines.push('');
      lines.push(t('report_predicted_gender_line', { value: data.faceResult.gender || '-' }));
      lines.push('');
      lines.push(t('report_dominant_emotion_line', { value: data.dominantEmotion || '-' }));
      lines.push('');
      lines.push(t('report_detection_confidence_line', { value: pct(data.faceResult.detection?.score) }));
   }

   lines.push('');
   lines.push(t('report_recognition_2d_heading'));
   lines.push('');

   if (!data.faceResult) {
      lines.push(t('no_face_snapshot'));
   } else if (!data.dbHasFaces) {
      lines.push(t('no_baseline_face_database'));
   } else {
      lines.push(t('report_match_with_id_line', { value: data.closestId ?? '-' }));
      lines.push('');
      lines.push(t('report_diversity_line', { value: data.closestDiversity != null ? `${data.closestDiversity}%` : '-' }));
      lines.push('');
      lines.push(t('report_recognition_threshold_line', { value: `${data.thresholdDiversity}%` }));
      lines.push('');
      lines.push(t('report_status_line', { value: data.matchHeadline || '-' }));
   }

   lines.push('');
   lines.push(t('report_embedder_3d_heading'));
   lines.push('');
   if (Number.isFinite(data.embedderClosestSimilarity) && typeof data.closestId === 'number') {
      lines.push(t('report_cosine_similarity_line', { id: data.closestId, value: data.embedderClosestSimilarity.toFixed(3) }));
   } else if (Number.isFinite(data.embedderBestSimilarity)) {
      lines.push(t('report_cosine_similarity_line', { id: data.embedderBestId, value: data.embedderBestSimilarity.toFixed(3) }));
   } else {
      lines.push(t('no_embedding_data_available'));
   }

   return lines.join('\n');
}