"""
face_gallery.py -- Persistent face embedding gallery backed by numpy .npz + JSON.

Thread-safe gallery storage for face recognition.  Embeddings are stored in a
.npz file, with a sidecar metadata.json containing names, sample counts, and
enrollment timestamps.  Auto-increment IDs start at 1.
"""

import json
import logging
import os
import threading
import time
from typing import Optional

import numpy as np

logger = logging.getLogger(__name__)


class FaceGallery:
    """Persistent, thread-safe face embedding gallery.

    Args:
        gallery_dir: Directory for gallery.npz and metadata.json files.
    """

    def __init__(self, gallery_dir: str):
        self._gallery_dir = gallery_dir
        self._npz_path = os.path.join(gallery_dir, 'gallery.npz')
        self._meta_path = os.path.join(gallery_dir, 'metadata.json')
        self._gallery: dict[int, dict] = {}
        self._next_id = 1
        self._lock = threading.Lock()

    def load(self) -> None:
        """Load gallery from disk. Populates internal gallery dict."""
        with self._lock:
            self._gallery = {}
            self._next_id = 1

            if not os.path.isfile(self._npz_path):
                logger.info('No gallery file at %s, starting empty.', self._npz_path)
                return

            data = np.load(self._npz_path, allow_pickle=False)
            meta: dict = {}
            if os.path.isfile(self._meta_path):
                with open(self._meta_path, 'r') as f:
                    meta = json.load(f)

            for key in data.files:
                pid = int(key)
                embedding = data[key].astype(np.float32)
                norm = np.linalg.norm(embedding)
                if norm > 0:
                    embedding = embedding / norm
                info = meta.get(str(pid), {})
                self._gallery[pid] = {
                    'name': info.get('name', f'person_{pid}'),
                    'embedding': embedding,
                    'samples': info.get('samples', 1),
                    'enrolled_at': info.get('enrolled_at', 0.0),
                }
                if pid >= self._next_id:
                    self._next_id = pid + 1

            logger.info('Gallery loaded: %d persons from %s',
                        len(self._gallery), self._npz_path)

    def save(self) -> None:
        """Save gallery to disk (npz + JSON sidecar)."""
        with self._lock:
            os.makedirs(self._gallery_dir, exist_ok=True)

            arrays = {}
            meta = {}
            for pid, info in self._gallery.items():
                arrays[str(pid)] = info['embedding']
                meta[str(pid)] = {
                    'name': info['name'],
                    'samples': info['samples'],
                    'enrolled_at': info['enrolled_at'],
                }

            np.savez(self._npz_path, **arrays)
            with open(self._meta_path, 'w') as f:
                json.dump(meta, f, indent=2)

            logger.info('Gallery saved: %d persons to %s',
                        len(self._gallery), self._npz_path)

    def add_person(self, name: str, embedding: np.ndarray, samples: int = 1) -> int:
        """Add a new person to the gallery.

        Args:
            name: Person's name.
            embedding: L2-normalized 512-dim embedding.
            samples: Number of samples used to compute the embedding.

        Returns:
            Assigned person_id (auto-increment integer).
        """
        with self._lock:
            pid = self._next_id
            self._next_id += 1
            emb = embedding.astype(np.float32)
            norm = np.linalg.norm(emb)
            if norm > 0:
                emb = emb / norm
            self._gallery[pid] = {
                'name': name,
                'embedding': emb,
                'samples': samples,
                'enrolled_at': time.time(),
            }
            logger.info('Added person %d (%s), %d samples.', pid, name, samples)
            return pid

    def update_name(self, person_id: int, new_name: str) -> bool:
        """Update a person's name.

        Args:
            person_id: The ID of the person to update.
            new_name: New name string.

        Returns:
            True if the person was found and updated.
        """
        with self._lock:
            if person_id not in self._gallery:
                return False
            self._gallery[person_id]['name'] = new_name
            return True

    def delete_person(self, person_id: int) -> bool:
        """Remove a person from the gallery.

        Args:
            person_id: The ID of the person to delete.

        Returns:
            True if the person was found and removed.
        """
        with self._lock:
            if person_id not in self._gallery:
                return False
            del self._gallery[person_id]
            logger.info('Deleted person %d.', person_id)
            return True

    def get_all(self) -> list[dict]:
        """Get all gallery entries.

        Returns:
            List of dicts with keys: person_id, name, embedding, samples, enrolled_at.
        """
        with self._lock:
            result = []
            for pid, info in self._gallery.items():
                result.append({
                    'person_id': pid,
                    'name': info['name'],
                    'embedding': info['embedding'].copy(),
                    'samples': info['samples'],
                    'enrolled_at': info['enrolled_at'],
                })
            return result

    def match(self, query_embedding: np.ndarray, threshold: float = 0.35) -> tuple[int, str, float]:
        """Match a query embedding against the gallery using cosine similarity.

        Args:
            query_embedding: L2-normalized 512-dim embedding.
            threshold: Minimum cosine similarity for a match.

        Returns:
            (person_id, name, score) or (-1, '', 0.0) if no match.
        """
        with self._lock:
            if not self._gallery:
                return (-1, '', 0.0)

            best_pid = -1
            best_name = ''
            best_score = 0.0

            query = query_embedding.astype(np.float32)
            norm = np.linalg.norm(query)
            if norm > 0:
                query = query / norm

            for pid, info in self._gallery.items():
                score = float(np.dot(query, info['embedding']))
                if score > best_score:
                    best_score = score
                    best_pid = pid
                    best_name = info['name']

            if best_score >= threshold:
                return (best_pid, best_name, best_score)
            return (-1, '', 0.0)

    def __len__(self) -> int:
        with self._lock:
            return len(self._gallery)
