/**
 * Lego 02 / Lego 11 / Lego 13 — Vision Tab (Scanner)
 *
 * Three-engine "Snap & Analyze" architecture. No continuous inference loops.
 *
 * Engines:
 *   tflite  — Browser TFLite WASM (local, file upload)
 *   onnx    — Browser ONNX Runtime (local, file upload)
 *   socket  — NestJS /vision socket (raw Base64 snap via vision:analyze)
 *
 * Snap workflow (socket engine):
 *   1. User starts webcam → live preview plays (no inference)
 *   2. User clicks "Snap & Analyze"
 *   3. Component captures the current video frame as 640×640 Base64
 *   4. VisionSocketService emits vision:analyze to NestJS
 *   5. NestJS returns vision:result → bounding boxes drawn on frozen snapshot
 *
 * Snap workflow (browser engines):
 *   1. User uploads an image file
 *   2. User clicks "Run Inference"
 *   3. InferenceService runs TFLite or ONNX locally
 *   4. Detections drawn on the canvas
 *
 * Preservation rule: TFLite/ONNX tensor math in LocalInferenceService is untouched.
 */

import {
  Component,
  OnInit,
  OnDestroy,
  ElementRef,
  ViewChild,
  signal,
  computed,
  effect,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { LocalHistoryService } from '../../services/local-history.service';
import { InferenceService, LocalEngine } from '../../core/services/inference.service';
import { VisionSocketService } from '../../services/vision-socket.service';
import { SurveillanceService } from '../../services/surveillance.service';
import {
  BatchResult,
  FullSessionReport,
} from '../../core/interfaces/palm-analysis.interface';

export type SnapEngine = 'tflite' | 'onnx' | 'socket';

const GRADE_COLORS: Record<string, string> = {
  'Empty_Bunch': '#6C757D', 'Underripe': '#F9A825', 'Abnormal': '#DC3545',
  'Ripe': '#00A651', 'Unripe': '#9E9D24', 'Overripe': '#5D4037',
};

@Component({
  selector: 'app-analyzer',
  standalone: true,
  imports: [CommonModule, FormsModule],
  templateUrl: './analyzer.component.html',
  styleUrls: ['./analyzer.component.scss'],
})
export class AnalyzerComponent implements OnInit, OnDestroy {

  // ── Canvas refs ────────────────────────────────────────────────────────────
  /** Result canvas for browser-engine mode (drawn after inference) */
  @ViewChild('resultCanvas') resultCanvasRef!: ElementRef<HTMLCanvasElement>;
  /** Snapshot canvas for socket-engine mode (frozen frame + bounding boxes) */
  @ViewChild('snapCanvas') snapCanvasRef!: ElementRef<HTMLCanvasElement>;
  /** Evidence canvas for batch audit drill-down (separate from snapCanvas) */
  @ViewChild('evidenceCanvas') evidenceCanvasRef!: ElementRef<HTMLCanvasElement>;
  /** Live webcam video feed */
  @ViewChild('videoEl') videoElRef!: ElementRef<HTMLVideoElement>;

  // ── Engine selection ───────────────────────────────────────────────────────
  engine = signal<SnapEngine>('onnx');
  /** 'local' for browser engines, 'backend' for socket */
  engineMode = computed<'local' | 'backend'>(() =>
    this.engine() === 'socket' ? 'backend' : 'local'
  );

  // ── Browser-engine state ───────────────────────────────────────────────────
  selectedFile: File | null = null;
  previewUrl: string | null = null;
  results: any = null;
  loading = false;
  isDragging = false;

  // ── Batch ingestion state (backend/socket mode only) ───────────────────────
  batchQueue = signal<File[]>([]);
  currentBatchIndex = signal<number>(0);
  private _batchRunning = signal<boolean>(false);
  isBatchActive = computed(() => this._batchRunning() && this.batchQueue().length > 0);

  sessionManifest: BatchResult[] = [];
  completedReport = signal<FullSessionReport | null>(null);
  selectedAuditEntry = signal<BatchResult | null>(null);

  /** Wall-clock timestamp of when sendBase64 was called for the current image */
  private _pingTime = 0;
  /** Wall-clock timestamp of when startBatchProcessing was called */
  private _batchStartTime = 0;
  /** Total files in the current batch run — stored separately so it survives queue drain */
  private _totalBatchCount = 0;
  get totalBatchCount(): number { return this._totalBatchCount; }
  /** Blob URL for the file currently being processed — stored so the pong effect can attach it to the record */
  private _currentBlobUrl: string | null = null;
  /** UUID shared across all frames in a batch session — sent to the gateway so DB rows can be grouped in the Vault */
  private _batchId: string = '';

  // ── Socket-engine state ────────────────────────────────────────────────────
  /** 'webcam' = live camera snap | 'gallery' = file from device storage */
  socketInputMode = signal<'webcam' | 'gallery'>('webcam');
  /** Base64 of the last snapped/gallery frame — displayed as frozen background */
  snappedFrame: string | null = null;
  /** File selected in gallery mode */
  socketGalleryFile: File | null = null;
  private webcamStream: MediaStream | null = null;

  constructor(
    public inferenceService: InferenceService,
    private localHistory: LocalHistoryService,
    public visionSocket: VisionSocketService,
    public surveillance: SurveillanceService,
  ) {
    // When NestJS goes offline, force the engine back to a local mode
    effect(() => {
      if (surveillance.nestStatus() === 'OFFLINE' && this.engine() === 'socket') {
        this.engine.set('onnx');
        this.stopWebcam();
      }
    });

    // Safety: switching to local while a batch is queued must clear the queue
    // immediately — local WASM cannot handle multi-file sequential load
    effect(() => {
      if (this.engineMode() === 'local' && this.isBatchActive()) {
        this.abortBatch();
      }
    });

    // "Pong" — fires on every lastResult change. Guards on non-null result AND
    // active batch so it never interferes with single-image gallery/snap flows.
    effect(() => {
      const res = this.visionSocket.lastResult();
      if (!res || !this.isBatchActive()) return;

      const idx = this.currentBatchIndex();
      const queue = this.batchQueue();
      const roundTripMs = performance.now() - this._pingTime;

      const record: BatchResult = {
        image_id: queue[idx]?.name ?? `file_${idx}`,
        timestamp: new Date().toISOString(),
        status: 'ok',
        detections: res.detections.map(d => ({
          bunch_id: d.bunch_id,
          ripeness_class: d.class,
          confidence_pct: Math.round(d.confidence * 10000) / 100,
          is_health_alert: d.is_health_alert,
          bounding_box: { x1: d.box[0], y1: d.box[1], x2: d.box[2], y2: d.box[3] },
          norm_box: d.norm_box
            ? { x1: d.norm_box[0], y1: d.norm_box[1], x2: d.norm_box[2], y2: d.norm_box[3] }
            : null,
        })),
        performance: {
          inference_ms: res.inference_ms,
          processing_ms: res.processing_ms,
          round_trip_ms: Math.round(roundTripMs),
        },
        technical_evidence: {
          engine: 'NestJS-ONNX',
          archive_id: res.archive_id,
          total_count: res.total_count,
          threshold: res.current_threshold,
          industrial_summary: res.industrial_summary,
          raw_tensor_sample: res.technical_evidence?.raw_tensor_sample ?? res.raw_tensor_sample ?? [],
        },
        localBlobUrl: this._currentBlobUrl ?? undefined,
      };
      this._currentBlobUrl = null;
      this.sessionManifest.push(record);

      const next = idx + 1;
      if (next < queue.length) {
        this.currentBatchIndex.set(next);
        this.processNextInBatch();
      } else {
        this.finalizeBatch();
      }
    });

    // "Error pong" — if a socket error fires mid-batch, log it and advance
    effect(() => {
      const err = this.visionSocket.lastError();
      if (!err || !this.isBatchActive()) return;

      const idx = this.currentBatchIndex();
      const queue = this.batchQueue();

      const record: BatchResult = {
        image_id: queue[idx]?.name ?? `file_${idx}`,
        timestamp: new Date().toISOString(),
        status: 'error',
        detections: [],
        performance: {
          inference_ms: 0,
          processing_ms: 0,
          round_trip_ms: Math.round(performance.now() - this._pingTime),
        },
        technical_evidence: {
          engine: 'NestJS-ONNX',
          archive_id: '',
          total_count: 0,
          threshold: 0,
          industrial_summary: {},
          raw_tensor_sample: [],
        },
        error: err,
      };
      this.sessionManifest.push(record);

      const next = idx + 1;
      if (next < queue.length) {
        this.currentBatchIndex.set(next);
        this.processNextInBatch();
      } else {
        this.finalizeBatch();
      }
    });

    // Draw evidence canvas whenever the user selects an audit entry
    effect(() => {
      const entry = this.selectedAuditEntry();
      if (!entry) return;
      // defer one tick so #evidenceCanvas is rendered in the DOM
      setTimeout(() => this.drawEvidence(entry), 0);
    });
  }

  ngOnInit(): void {}

  ngOnDestroy(): void {
    this.stopWebcam();
  }

  // ── Engine switcher ────────────────────────────────────────────────────────

  onEngineChange(e: SnapEngine): void {
    if (e === this.engine()) return;
    if (this.engine() === 'socket') this.stopWebcam();
    this.engine.set(e);
    this.resetState();
  }

  isBrowserEngine(): boolean {
    return this.engine() === 'tflite' || this.engine() === 'onnx';
  }

  isSocketEngine(): boolean {
    return this.engine() === 'socket';
  }

  // ── Browser engine — file upload ───────────────────────────────────────────

  onFileSelected(event: any): void {
    const files: FileList = event.target.files;
    if (!files || files.length === 0) return;

    if (this.engineMode() === 'local') {
      this.processSingleFile(files[0]);
    } else {
      this.batchQueue.set(Array.from(files));
      this.sessionManifest = [];
      this.startBatchProcessing();
    }
  }

  private processSingleFile(file: File): void {
    this.handleFile(file);
  }

  private startBatchProcessing(): void {
    this._batchId = crypto.randomUUID();
    this._totalBatchCount = this.batchQueue().length;
    this._batchStartTime = performance.now();
    this.sessionManifest = [];
    this.completedReport.set(null);
    this.currentBatchIndex.set(0);
    this._batchRunning.set(true);
    this.processNextInBatch();
  }

  private processNextInBatch(): void {
    const file = this.batchQueue()[this.currentBatchIndex()];
    if (!file) return;

    // Create a blob URL for the evidence canvas (revoked in finalizeBatch/abortBatch)
    const blobUrl = URL.createObjectURL(file);
    this._currentBlobUrl = blobUrl;

    const img = new Image();
    img.onerror = () => { URL.revokeObjectURL(blobUrl); this.advanceBatchOnError(file.name, 'Image failed to decode'); };
    img.onload = () => {
      const offscreen = document.createElement('canvas');
      offscreen.width = 640;
      offscreen.height = 640;
      offscreen.getContext('2d')!.drawImage(img, 0, 0, 640, 640);
      const base64 = offscreen.toDataURL('image/jpeg');
      this._pingTime = performance.now();
      this.visionSocket.sendBase64(base64, this._batchId);
    };
    img.src = blobUrl;
  }


  /** Log a client-side read/decode error and advance to the next file */
  private advanceBatchOnError(fileName: string, reason: string): void {
    const record: BatchResult = {
      image_id: fileName,
      timestamp: new Date().toISOString(),
      status: 'error',
      detections: [],
      performance: { inference_ms: 0, processing_ms: 0, round_trip_ms: 0 },
      technical_evidence: {
        engine: 'NestJS-ONNX',
        archive_id: '',
        total_count: 0,
        threshold: 0,
        industrial_summary: {},
        raw_tensor_sample: [],
      },
      error: reason,
    };
    this.sessionManifest.push(record);
    const next = this.currentBatchIndex() + 1;
    if (next < this.batchQueue().length) {
      this.currentBatchIndex.set(next);
      this.processNextInBatch();
    } else {
      this.finalizeBatch();
    }
  }

  private finalizeBatch(): void {
    const totalMs = Math.round(performance.now() - this._batchStartTime);
    const ok = this.sessionManifest.filter(r => r.status === 'ok');
    const avgInference = ok.length
      ? Math.round(ok.reduce((s, r) => s + r.performance.inference_ms, 0) / ok.length)
      : 0;
    const avgRoundTrip = ok.length
      ? Math.round(ok.reduce((s, r) => s + r.performance.round_trip_ms, 0) / ok.length)
      : 0;

    const report: FullSessionReport = {
      session_id: crypto.randomUUID(),
      generated_at: new Date().toISOString(),
      meta: {
        total_images: this._totalBatchCount,
        successful: ok.length,
        failed: this.sessionManifest.length - ok.length,
        total_time_ms: totalMs,
        avg_inference_ms: avgInference,
        avg_round_trip_ms: avgRoundTrip,
      },
      results: this.sessionManifest,
    };

    this.completedReport.set(report);
    this.batchQueue.set([]);
    this.currentBatchIndex.set(0);
    this._batchRunning.set(false);
  }

  abortBatch(): void {
    // Revoke any pending blob URL for the in-flight image
    if (this._currentBlobUrl) { URL.revokeObjectURL(this._currentBlobUrl); this._currentBlobUrl = null; }
    // Revoke all stored evidence URLs
    this.sessionManifest.forEach(r => { if (r.localBlobUrl) URL.revokeObjectURL(r.localBlobUrl); });
    this.batchQueue.set([]);
    this.currentBatchIndex.set(0);
    this._batchRunning.set(false);
  }

  /** Draw the source image + bounding boxes onto the dedicated evidence canvas */
  private drawEvidence(entry: BatchResult): void {
    const canvas = this.evidenceCanvasRef?.nativeElement;
    if (!canvas || !entry.localBlobUrl) return;

    const img = new Image();
    img.onload = () => {
      const size = canvas.parentElement!.clientWidth || 640;
      canvas.width = size;
      canvas.height = size;
      const ctx = canvas.getContext('2d')!;
      ctx.drawImage(img, 0, 0, size, size);

      const scaleX = size / 640;
      const scaleY = size / 640;

      entry.detections.forEach(det => {
        const { x1, y1, x2, y2 } = det.bounding_box;
        const color = GRADE_COLORS[det.ripeness_class] || '#00A651';
        ctx.strokeStyle = color;
        ctx.lineWidth = 3;
        ctx.strokeRect(x1 * scaleX, y1 * scaleY, (x2 - x1) * scaleX, (y2 - y1) * scaleY);
        ctx.fillStyle = color;
        ctx.font = 'bold 13px Outfit';
        const label = `${det.ripeness_class} ${det.confidence_pct.toFixed(1)}%`;
        const tw = ctx.measureText(label).width;
        ctx.fillRect(x1 * scaleX, y1 * scaleY - 22, tw + 8, 22);
        ctx.fillStyle = '#fff';
        ctx.fillText(label, x1 * scaleX + 4, y1 * scaleY - 6);
      });
    };
    img.src = entry.localBlobUrl;
  }

  onDragOver(event: DragEvent): void {
    event.preventDefault();
    event.stopPropagation();
    this.isDragging = true;
  }

  onDragLeave(event: DragEvent): void {
    event.preventDefault();
    event.stopPropagation();
    this.isDragging = false;
  }

  onDrop(event: DragEvent): void {
    event.preventDefault();
    event.stopPropagation();
    this.isDragging = false;
    if (event.dataTransfer?.files?.length) {
      const file = event.dataTransfer.files[0];
      if (file.type.startsWith('image/')) this.handleFile(file);
    }
  }

  private handleFile(file: File): void {
    this.selectedFile = file;
    const reader = new FileReader();
    reader.onload = (e: any) => {
      this.previewUrl = e.target.result;
      this.resetState();
    };
    reader.readAsDataURL(file);
  }

  /** Run local TFLite or ONNX inference on the uploaded file */
  async runLocalInference(): Promise<void> {
    if (!this.selectedFile || !this.previewUrl) return;
    this.loading = true;
    const start = performance.now();

    // Map engine toggle to InferenceService's LocalEngine type
    const localEngine: LocalEngine = this.engine() === 'tflite' ? 'tflite' : 'onnx';
    this.inferenceService.localEngine.set(localEngine);
    this.inferenceService.mode.set('local');

    try {
      const img = await this.loadImageDimensions(this.selectedFile);
      this.inferenceService.analyze(this.previewUrl, img.width, img.height).subscribe({
        next: (detections) => {
          this.results = {
            industrial_summary: this.inferenceService.summary(),
            inference_ms: performance.now() - start,
            detections,
            original_dimensions: img,
          };
          if (true) {  // always save local runs to history
            this.localHistory.saveRecord(
              this.results,
              this.selectedFile!.name,
              'local',
              this.previewUrl!,
              img,
            );
          }
          this.loading = false;
          setTimeout(() => this.drawBrowserDetections(), 100);
        },
        error: (err) => {
          console.error('Local inference failed:', err);
          this.loading = false;
        },
      });
    } catch (err) {
      console.error('Local inference pipeline error:', err);
      this.loading = false;
    }
  }

  /** Draw detections onto the browser-mode result canvas */
  private drawBrowserDetections(): void {
    const detections = this.inferenceService.detections();
    if (!detections || !this.resultCanvasRef || !this.previewUrl) return;
    const ctx = this.resultCanvasRef.nativeElement.getContext('2d');
    if (!ctx) return;

    const img = new Image();
    img.src = this.previewUrl;
    img.onload = () => {
      const canvas = this.resultCanvasRef.nativeElement;
      const containerWidth = canvas.parentElement!.clientWidth;
      const scale = containerWidth / img.width;
      canvas.width = containerWidth;
      canvas.height = img.height * scale;
      ctx.drawImage(img, 0, 0, canvas.width, canvas.height);

      detections.forEach((det: any) => {
        const [x1, y1, x2, y2] = det.box.map((v: number) => v * scale);
        const color = det.color || '#00A651';
        ctx.strokeStyle = color;
        ctx.lineWidth = 3;
        ctx.strokeRect(x1, y1, x2 - x1, y2 - y1);
        ctx.fillStyle = color;
        ctx.font = 'bold 14px Outfit';
        const label = `${det.class} ${Math.round(det.confidence * 100)}%`;
        const tw = ctx.measureText(label).width;
        ctx.fillRect(x1, y1 - 25, tw + 10, 25);
        ctx.fillStyle = '#fff';
        ctx.fillText(label, x1 + 5, y1 - 7);
      });
    };
  }

  // ── Socket engine — Snap & Analyze ────────────────────────────────────────

  async startWebcam(): Promise<void> {
    try {
      this.webcamStream = await navigator.mediaDevices.getUserMedia({
        video: { width: 640, height: 640, facingMode: 'environment' },
        audio: false,
      });
      const videoEl = this.videoElRef.nativeElement;
      videoEl.srcObject = this.webcamStream;
      await videoEl.play();
    } catch (err: any) {
      console.error('Webcam access failed:', err.message);
    }
  }

  stopWebcam(): void {
    if (this.webcamStream) {
      this.webcamStream.getTracks().forEach(t => t.stop());
      this.webcamStream = null;
    }
    this.snappedFrame = null;
    this.visionSocket.clearResult();
  }

  get webcamActive(): boolean {
    return !!this.webcamStream;
  }

  onSocketInputModeChange(mode: 'webcam' | 'gallery'): void {
    if (mode === this.socketInputMode()) return;
    if (this.socketInputMode() === 'webcam') this.stopWebcam();
    this.socketInputMode.set(mode);
    this.snappedFrame = null;
    this.socketGalleryFile = null;
    this.visionSocket.clearResult();
  }

  onGalleryFileSelected(event: Event): void {
    const input = event.target as HTMLInputElement;
    const files = input.files;
    if (!files || files.length === 0) return;
    this.snappedFrame = null;
    this.visionSocket.clearResult();
    if (files.length > 1) {
      this.batchQueue.set(Array.from(files));
      this.currentBatchIndex.set(0);
      this.sessionManifest = [];
      this.socketGalleryFile = null;
    } else {
      this.socketGalleryFile = files[0];
      this.batchQueue.set([]);
    }
    // Reset input so the same files can be re-selected if needed
    input.value = '';
  }

  onGalleryDrop(event: DragEvent): void {
    event.preventDefault();
    event.stopPropagation();
    this.isDragging = false;
    const files = event.dataTransfer?.files;
    if (!files || files.length === 0) return;
    this.snappedFrame = null;
    this.visionSocket.clearResult();
    if (files.length > 1) {
      this.batchQueue.set(Array.from(files).filter(f => f.type.startsWith('image/')));
      this.currentBatchIndex.set(0);
      this.sessionManifest = [];
      this.socketGalleryFile = null;
    } else {
      this.socketGalleryFile = files[0];
      this.batchQueue.set([]);
    }
  }

  analyzeGalleryImage(): void {
    if (this.batchQueue().length > 1) {
      this.startBatchProcessing();
      return;
    }
    if (!this.socketGalleryFile) return;
    const reader = new FileReader();
    reader.onload = (e) => {
      const base64 = e.target?.result as string;
      if (!base64) return;

      // Rescale the gallery image to 640×640 before sending and storing as
      // snappedFrame. The backend always runs inference in 640×640 space, so
      // the canvas background must match that same square crop to keep bounding
      // boxes aligned with the displayed image.
      const img = new Image();
      img.onload = () => {
        const offscreen = document.createElement('canvas');
        offscreen.width = 640;
        offscreen.height = 640;
        offscreen.getContext('2d')!.drawImage(img, 0, 0, 640, 640);
        const scaled640 = offscreen.toDataURL('image/jpeg');
        this.snappedFrame = scaled640;
        this.visionSocket.sendBase64(scaled640);
        this.waitForSocketResult();
      };
      img.src = base64;
    };
    reader.readAsDataURL(this.socketGalleryFile);
  }

  /**
   * Capture one frame from the live webcam, freeze it as a snapshot,
   * and send it to NestJS via vision:analyze.
   * The live video feed continues playing behind the scenes.
   */
  snapAndAnalyze(): void {
    const videoEl = this.videoElRef?.nativeElement;
    if (!videoEl || videoEl.readyState < 2) return;

    // Capture current frame to an offscreen canvas → File → batchQueue of 1
    const offscreen = document.createElement('canvas');
    offscreen.width = 640;
    offscreen.height = 640;
    offscreen.getContext('2d')!.drawImage(videoEl, 0, 0, 640, 640);
    offscreen.toBlob((blob) => {
      if (!blob) return;
      const file = new File([blob], `webcam-snap-${Date.now()}.jpg`, { type: 'image/jpeg' });
      this.batchQueue.set([file]);
      this.sessionManifest = [];
      this.completedReport.set(null);
      this.startBatchProcessing();
    }, 'image/jpeg');
  }

  /**
   * Polls the visionSocket.lastResult signal until a new result arrives,
   * then draws the bounding boxes on the frozen snapshot canvas.
   * Uses a single-fire check via requestAnimationFrame polling — no setInterval.
   */
  private waitForSocketResult(): void {
    const check = () => {
      const result = this.visionSocket.lastResult();
      if (result && !this.visionSocket.analyzing()) {
        this.drawSnapDetections(result.detections);
        return;
      }
      if (this.visionSocket.analyzing()) {
        requestAnimationFrame(check);
      }
    };
    requestAnimationFrame(check);
  }

  /** Draw detections on the frozen snapshot canvas (socket engine result) */
  private drawSnapDetections(detections: any[]): void {
    const canvas = this.snapCanvasRef?.nativeElement;
    if (!canvas || !this.snappedFrame) return;

    const img = new Image();
    img.src = this.snappedFrame;
    img.onload = () => {
      // The canvas display width matches the container.
      // The canvas logical size is always set to 640×640 because the backend
      // always runs inference in 640×640 space — coords are always 640-relative
      // regardless of whether the source was a webcam snap (already 640×640) or
      // a gallery image (arbitrary size sent as-is; backend rescales internally).
      const containerWidth = canvas.parentElement!.clientWidth || 640;
      canvas.width = containerWidth;
      canvas.height = containerWidth; // square: 640px inference space

      const ctx = canvas.getContext('2d');
      if (!ctx) return;
      // Draw the source image stretched to fill the square canvas
      ctx.drawImage(img, 0, 0, canvas.width, canvas.height);

      // Map 640-space coords → canvas pixels via percentage
      const scaleX = canvas.width / 640;
      const scaleY = canvas.height / 640;

      detections.forEach((det: any) => {
        const [x1, y1, x2, y2] = det.box;
        const color = GRADE_COLORS[det.class] || '#00A651';

        ctx.strokeStyle = color;
        ctx.lineWidth = 3;
        ctx.strokeRect(x1 * scaleX, y1 * scaleY, (x2 - x1) * scaleX, (y2 - y1) * scaleY);
        ctx.fillStyle = color;
        ctx.font = 'bold 13px Outfit';
        const label = `${det.class} ${Math.round(det.confidence * 100)}%`;
        const tw = ctx.measureText(label).width;
        ctx.fillRect(x1 * scaleX, y1 * scaleY - 22, tw + 8, 22);
        ctx.fillStyle = '#fff';
        ctx.fillText(label, x1 * scaleX + 4, y1 * scaleY - 6);
      });
    };
  }

  // ── Shared helpers ─────────────────────────────────────────────────────────

  private resetState(): void {
    this.results = null;
    this.snappedFrame = null;
    this.inferenceService.detections.set([]);
    this.inferenceService.summary.set({});
    this.visionSocket.clearResult();
    if (this.resultCanvasRef?.nativeElement) {
      const ctx = this.resultCanvasRef.nativeElement.getContext('2d');
      ctx?.clearRect(0, 0, this.resultCanvasRef.nativeElement.width, this.resultCanvasRef.nativeElement.height);
    }
  }

  private loadImageDimensions(file: File): Promise<{ width: number; height: number }> {
    return new Promise((resolve) => {
      const img = new Image();
      img.onload = () => {
        resolve({ width: img.width, height: img.height });
        URL.revokeObjectURL(img.src);
      };
      img.src = URL.createObjectURL(file);
    });
  }

  getSummaryKeys(): string[] {
    return Object.keys(this.inferenceService.summary() ?? {});
  }

  /** Aggregate industrial_summary across all successful batch results */
  getBatchDistribution(): { class: string; count: number; pct: number; color: string }[] {
    const report = this.completedReport();
    if (!report) return [];
    const totals: Record<string, number> = {};
    report.results.filter(r => r.status === 'ok').forEach(r => {
      Object.entries(r.technical_evidence.industrial_summary).forEach(([cls, n]) => {
        totals[cls] = (totals[cls] ?? 0) + n;
      });
    });
    const grand = Object.values(totals).reduce((s, n) => s + n, 0) || 1;
    return Object.entries(totals)
      .sort((a, b) => b[1] - a[1])
      .map(([cls, count]) => ({
        class: cls,
        count,
        pct: Math.round((count / grand) * 100),
        color: GRADE_COLORS[cls] ?? '#888',
      }));
  }
}
