scripts/landmark-analysis.jsjavascript
/** @module landmark-analysis */
import { state } from './state.js';
import { distance } from './utils.js';
import { t } from './i18n.js';

/**
 * Converts a face-api descriptor distance into the "diversity" percentage used
 * by the Ghostmaxxing UI copy. Lower descriptor distances mean closer biometric
 * matches, so this display value is a user-facing scale for how different the
 * live or Ghostyle-composited face appears from the stored reference.
 *
 * @param {number} dist - face-api descriptor distance to display.
 * @returns {number} Rounded diversity percentage capped at 100.
 * @see decideMatchState - Formats match and elusion headlines with this scale.
 * @see renderInfo - Shows closest-distance diversity in the analysis panel.
 */
export function distanceToDiversity(dist) {
   if (!Number.isFinite(dist) || dist <= 0) return 0;
   return Math.min(100, Math.round(dist * 100));
}

/**
 * Finds the closest saved 2D biometric face for a live face-api result. This is
 * the baseline "before Ghostyle" lookup used to decide whether the camera view
 * currently matches a stored identity.
 *
 * @param {object|null|undefined} liveResult - face-api detection result from the live camera or analysis snapshot.
 * @param {object} [liveResult.detection] - Detection metadata containing the confidence score.
 * @param {number} [liveResult.detection.score] - face-api confidence score for the live face.
 * @param {Array<number>} [liveResult.descriptor] - face-api descriptor for nearest-neighbor matching.
 * @param {Array<object>} [dbFaces=state.db.faces] - Saved 2D face records to compare against.
 * @returns {{liveScore: number|null, liveMinDist: number|null, liveMinId: number|null}} Closest live match metrics.
 * @see findFace - Uses the result before evaluating an active Ghostyle.
 * @see autoFindLoop - Reuses this lookup for repeated automatic face searches.
 * @see composeAnalysisData - Uses this lookup for the analysis panel nearest-face summary.
 */
export function seekFaceInDb(liveResult, dbFaces = state.db?.faces || []) {
   const liveScore = liveResult?.detection?.score ?? null;
   const descriptor = liveResult?.descriptor;
   if (!descriptor || !Array.isArray(dbFaces) || dbFaces.length === 0) {
      return { liveScore, liveMinDist: null, liveMinId: null };
   }

   const distances = dbFaces.map((e) => ({
      id: e.id,
      distance: distance(descriptor, e.descriptor),
   })).sort((a, b) => a.distance - b.distance);

   const liveMinDist = distances.length ? distances[0].distance : null;
   const liveMinId = distances.length ? distances[0].id : null;
   return { liveScore, liveMinDist, liveMinId };
}

/**
 * Computes nearest-match metrics for the Ghostyle-composited face-api result.
 * In the project flow this measures whether makeup or an adversarial overlay
 * still resolves to a saved identity, produces only a weak detection, or makes
 * face-api fail to find a face at all.
 *
 * @param {object|null|undefined} composite - Output from compositing the live frame with the active Ghostyle.
 * @param {object} [composite.obfuscatedResult] - face-api result detected on the composited Ghostyle image.
 * @param {object} [composite.obfuscatedResult.detection] - Detection metadata for the composited image.
 * @param {number} [composite.obfuscatedResult.detection.score] - face-api confidence score for the composited image.
 * @param {Array<number>} [composite.obfuscatedResult.descriptor] - face-api descriptor extracted from the composited image.
 * @param {boolean} [composite.weakDetection] - Whether the composited image was detected only weakly.
 * @param {Array<object>} [dbFaces=state.db.faces] - Saved 2D face records to compare against.
 * @returns {{obfScore: number|null, obfMinDist: number|null, obfMinId: number|null, weakDetection: boolean, detectionTotallyFailed: boolean}} Ghostyle-composite match metrics.
 * @see compositeAndDetect - Produces the composite object passed into this helper.
 * @see evaluateMatch - Calls this before building the final match/elusion detail.
 */
export function computeCompositeMetrics(composite, dbFaces = state.db?.faces || []) {
   if (!composite || !composite.obfuscatedResult) {
      return {
         obfScore: null,
         obfMinDist: null,
         obfMinId: null,
         weakDetection: !!composite?.weakDetection,
         detectionTotallyFailed: true,
      };
   }

   const obfScore = composite.obfuscatedResult?.detection?.score ?? null;
   const descriptor = composite.obfuscatedResult?.descriptor;
   const distances = Array.isArray(dbFaces)
      ? dbFaces.map((e) => ({ id: e.id, distance: distance(descriptor, e.descriptor) })).sort((a, b) => a.distance - b.distance)
      : [];

   const obfMinDist = distances.length ? distances[0].distance : null;
   const obfMinId = distances.length ? distances[0].id : null;

   return {
      obfScore,
      obfMinDist,
      obfMinId,
      weakDetection: !!composite.weakDetection,
      detectionTotallyFailed: false,
   };
}

/**
 * Converts live and Ghostyle-composited 2D match metrics into the app's final
 * match state and Italian user-facing headline. The branches distinguish a
 * clear live match, no saved-face match, a Ghostyle that still matches a saved
 * identity (`unclear`), weak composited detection, and total face-api failure on
 * the composited image.
 *
 * @param {object} metrics - Combined live and obfuscated face-api metrics.
 * @param {number|null} metrics.liveMinDist - Closest live descriptor distance before applying a Ghostyle.
 * @param {number|null} metrics.liveMinId - Saved face id closest to the live descriptor.
 * @param {number|null} metrics.obfMinDist - Closest descriptor distance after Ghostyle compositing.
 * @param {number|null} metrics.obfMinId - Saved face id closest to the Ghostyle-composited descriptor.
 * @param {boolean} metrics.weakDetection - Whether face-api only weakly detected the composited image.
 * @param {boolean} metrics.detectionTotallyFailed - Whether face-api found no face in the composited image.
 * @param {number} [metrics.matchThreshold=state.MATCH_THRESHOLD] - face-api distance threshold for a saved-face match.
 * @returns {{detectionState: 'matched'|'eluded'|'unclear', headline: string, distance: number|null, matchedId: number|null}} Final UI state for the 2D match readout.
 * @see evaluateMatch - Uses this decision for scan and efficacy result details.
 * @see composeAnalysisData - Uses this decision for analysis-panel narrative text.
 */
export function decideMatchState({
   liveMinDist,
   liveMinId,
   obfMinDist,
   obfMinId,
   weakDetection,
   detectionTotallyFailed,
   matchThreshold = state.MATCH_THRESHOLD,
}) {
   const thresholdDiversity = distanceToDiversity(matchThreshold);

   if (obfMinDist != null || weakDetection || detectionTotallyFailed) {
      if (typeof obfMinId === 'number' && obfMinDist <= matchThreshold) {
         const obfDiversity = distanceToDiversity(obfMinDist);
         return {
            detectionState: 'unclear',
            headline: t('match_2d_ghostyle_unclear_headline', { id: obfMinId, diversity: obfDiversity }),
            distance: obfMinDist,
            matchedId: obfMinId,
         };
      }
      if (detectionTotallyFailed) {
         return {
            detectionState: 'eluded',
            headline: t('match_2d_detector_failed_headline'),
            distance: obfMinDist,
            matchedId: null,
         };
      }
      if (weakDetection) {
         return {
            detectionState: 'eluded',
            headline: t('match_2d_weak_detection_headline'),
            distance: obfMinDist,
            matchedId: null,
         };
      }

      const obfDiversity = distanceToDiversity(obfMinDist);
      return {
         detectionState: 'eluded',
         headline: t('match_2d_ghostyle_eluded_headline', { diversity: obfDiversity, threshold: thresholdDiversity }),
         distance: obfMinDist,
         matchedId: null,
      };
   }

   if (typeof liveMinDist === 'number' && liveMinDist <= matchThreshold) {
      const liveDiversity = distanceToDiversity(liveMinDist);
      return {
         detectionState: 'matched',
         headline: t('match_2d_live_matched_headline', { id: liveMinId, diversity: liveDiversity, threshold: thresholdDiversity }),
         distance: liveMinDist,
         matchedId: liveMinId,
      };
   }

   const liveDiversity = distanceToDiversity(liveMinDist);
   return {
      detectionState: 'eluded',
      headline: t('match_2d_live_eluded_headline', { diversity: liveDiversity, threshold: thresholdDiversity }),
      distance: liveMinDist,
      matchedId: null,
   };
}