from __future__ import annotations

import json
import math
import shutil
import time
import zipfile
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Optional

import cv2
import numpy as np

from Core import settings as config


PERSON_JSON = "person.json"
FACE_REF_NAME = "face_ref.jpg"
SCENE_REF_NAME = "scene_ref.jpg"
CAPTURE_PREFIX = "capture_"
SUPPORTED_IMAGE_EXTS = (".jpg", ".jpeg", ".png", ".webp")


def people_dir() -> Path:
    path = Path(config.PEOPLE_DIR).resolve()
    path.mkdir(parents=True, exist_ok=True)
    return path


def _now_ts() -> float:
    return time.time()


def _fmt_date(ts: float) -> str:
    return datetime.fromtimestamp(float(ts)).strftime("%Y-%m-%d")


def _person_dirs() -> list[Path]:
    root = people_dir()
    return [p for p in sorted(root.iterdir()) if p.is_dir()]


def _person_json_path(person_dir: Path) -> Path:
    return person_dir / PERSON_JSON


def _read_person(person_dir: Path) -> Optional[Dict[str, Any]]:
    try:
        raw = json.loads(_person_json_path(person_dir).read_text(encoding="utf-8"))
        if not isinstance(raw, dict):
            return None
        return raw
    except Exception:
        return None


def _write_person(person_dir: Path, data: Dict[str, Any]) -> None:
    person_dir.mkdir(parents=True, exist_ok=True)
    _person_json_path(person_dir).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")


def _list_existing_sequences() -> list[int]:
    out: list[int] = []
    for person_dir in _person_dirs():
        meta = _read_person(person_dir)
        if not meta:
            continue
        try:
            out.append(int(meta.get("sequence", 0)))
        except Exception:
            continue
    return out


def _next_sequence() -> int:
    seqs = _list_existing_sequences()
    return (max(seqs) if seqs else 0) + 1


def _make_person_id(sequence: int, ts: float) -> str:
    return f"guest_{int(sequence):04d}_{datetime.fromtimestamp(ts).strftime('%Y%m%d_%H%M%S')}"


def _display_label(meta: Dict[str, Any]) -> str:
    short_id = str(meta.get("short_id") or meta.get("person_id") or "guest").replace("_", " ")
    created_date = str(meta.get("created_date") or "")
    return f"{short_id} from {created_date}".strip()


def _clip_box(box: Dict[str, Any], frame_shape: tuple[int, ...], pad_ratio: float = 0.18) -> Optional[tuple[int, int, int, int]]:
    if not isinstance(box, dict):
        return None
    try:
        h, w = frame_shape[:2]
        x = float(box.get("x", 0.0))
        y = float(box.get("y", 0.0))
        bw = max(1.0, float(box.get("w", 0.0)))
        bh = max(1.0, float(box.get("h", 0.0)))
        pad_w = bw * float(pad_ratio)
        pad_h = bh * float(pad_ratio)
        x1 = max(0, int(math.floor(x - pad_w)))
        y1 = max(0, int(math.floor(y - pad_h)))
        x2 = min(w, int(math.ceil(x + bw + pad_w)))
        y2 = min(h, int(math.ceil(y + bh + pad_h)))
        if x2 <= x1 or y2 <= y1:
            return None
        return x1, y1, x2, y2
    except Exception:
        return None


def detect_face_box(image_bgr: np.ndarray) -> Optional[Dict[str, int]]:
    if image_bgr is None or getattr(image_bgr, "size", 0) == 0:
        return None
    try:
        cascade = cv2.CascadeClassifier(str(Path(cv2.data.haarcascades) / "haarcascade_frontalface_default.xml"))
        if cascade.empty():
            return None
        gray = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY)
        faces = cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5, minSize=(48, 48))
        if len(faces) == 0:
            return None
        x, y, w, h = max(faces, key=lambda item: int(item[2]) * int(item[3]))
        return {"x": int(x), "y": int(y), "w": int(w), "h": int(h)}
    except Exception:
        return None


def _extract_face_crop(frame_bgr: np.ndarray, face_box: Dict[str, Any]) -> Optional[np.ndarray]:
    clipped = _clip_box(face_box, frame_bgr.shape, pad_ratio=0.18)
    if clipped is None:
        return None
    x1, y1, x2, y2 = clipped
    crop = frame_bgr[y1:y2, x1:x2]
    if crop is None or crop.size == 0:
        return None
    return crop.copy()


def _extract_scene_crop(frame_bgr: np.ndarray, subject_box: Optional[Dict[str, Any]]) -> np.ndarray:
    if frame_bgr is None or getattr(frame_bgr, "size", 0) == 0:
        raise ValueError("empty frame")
    if not subject_box:
        return frame_bgr.copy()
    clipped = _clip_box(subject_box, frame_bgr.shape, pad_ratio=0.28)
    if clipped is None:
        return frame_bgr.copy()
    x1, y1, x2, y2 = clipped
    crop = frame_bgr[y1:y2, x1:x2]
    if crop is None or crop.size == 0:
        return frame_bgr.copy()
    return crop.copy()


def _compute_embedding(face_bgr: np.ndarray) -> np.ndarray:
    gray = cv2.cvtColor(face_bgr, cv2.COLOR_BGR2GRAY)
    gray = cv2.resize(gray, (48, 48), interpolation=cv2.INTER_AREA)
    gray = cv2.equalizeHist(gray)
    vec = gray.astype(np.float32).reshape(-1)
    vec -= float(vec.mean())
    norm = float(np.linalg.norm(vec))
    if norm <= 1e-6:
        return vec
    return vec / norm


def _embedding_similarity(a: np.ndarray, b: np.ndarray) -> float:
    if a.size == 0 or b.size == 0:
        return 0.0
    denom = float(np.linalg.norm(a) * np.linalg.norm(b))
    if denom <= 1e-6:
        return 0.0
    return float(np.dot(a, b) / denom)


def _load_embedding(meta: Dict[str, Any]) -> np.ndarray:
    raw = meta.get("embedding") or []
    try:
        arr = np.asarray(raw, dtype=np.float32)
    except Exception:
        arr = np.asarray([], dtype=np.float32)
    return arr


def _save_image(path: Path, image_bgr: np.ndarray) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    cv2.imwrite(str(path), image_bgr)


def _append_sample(person_dir: Path, meta: Dict[str, Any], face_bgr: np.ndarray, scene_bgr: np.ndarray, embedding: np.ndarray, source: str) -> Dict[str, Any]:
    now = _now_ts()
    sample_count = int(meta.get("sample_count", 0)) + 1
    prev_embedding = _load_embedding(meta)
    if prev_embedding.size and prev_embedding.shape == embedding.shape:
        new_embedding = ((prev_embedding * float(sample_count - 1)) + embedding) / float(sample_count)
        norm = float(np.linalg.norm(new_embedding))
        if norm > 1e-6:
            new_embedding = new_embedding / norm
    else:
        new_embedding = embedding

    face_name = f"face_{sample_count:03d}.jpg"
    scene_name = f"scene_{sample_count:03d}.jpg"
    _save_image(person_dir / face_name, face_bgr)
    _save_image(person_dir / scene_name, scene_bgr)

    meta["sample_count"] = sample_count
    meta["times_seen"] = int(meta.get("times_seen", 0)) + 1
    meta["last_seen_at"] = now
    meta["embedding"] = new_embedding.astype(float).tolist()
    meta["last_source"] = str(source or "vision")
    meta["face_images"] = sorted(
        [p.name for p in person_dir.iterdir() if p.is_file() and p.name.startswith("face_") and p.suffix.lower() in SUPPORTED_IMAGE_EXTS]
    )
    meta["scene_images"] = sorted(
        [p.name for p in person_dir.iterdir() if p.is_file() and p.name.startswith("scene_") and p.suffix.lower() in SUPPORTED_IMAGE_EXTS]
    )
    if not (person_dir / FACE_REF_NAME).exists():
        _save_image(person_dir / FACE_REF_NAME, face_bgr)
    if not (person_dir / SCENE_REF_NAME).exists():
        _save_image(person_dir / SCENE_REF_NAME, scene_bgr)
    _write_person(person_dir, meta)
    return meta


def _create_person(face_bgr: np.ndarray, scene_bgr: np.ndarray, embedding: np.ndarray, source: str) -> Dict[str, Any]:
    now = _now_ts()
    seq = _next_sequence()
    person_id = _make_person_id(seq, now)
    short_id = f"guest_{seq:04d}"
    person_dir = people_dir() / person_id
    _save_image(person_dir / FACE_REF_NAME, face_bgr)
    _save_image(person_dir / SCENE_REF_NAME, scene_bgr)
    meta = {
        "person_id": person_id,
        "short_id": short_id,
        "display_name": f"{short_id} from {_fmt_date(now)}",
        "sequence": seq,
        "created_at": now,
        "created_date": _fmt_date(now),
        "last_seen_at": now,
        "sample_count": 0,
        "times_seen": 0,
        "source": str(source or "vision"),
        "last_source": str(source or "vision"),
        "embedding": embedding.astype(float).tolist(),
        "face_images": [],
        "scene_images": [],
        "captured_photos": [],
    }
    meta = _append_sample(person_dir, meta, face_bgr, scene_bgr, embedding, source=source)
    meta["new_person"] = True
    meta["known_person"] = False
    meta["person_dir"] = str(person_dir)
    meta["display_label"] = _display_label(meta)
    return meta


def recognize_or_enroll(frame_bgr: np.ndarray, face_box: Optional[Dict[str, Any]], subject_box: Optional[Dict[str, Any]] = None, threshold: Optional[float] = None, source: str = "vision") -> Dict[str, Any]:
    if frame_bgr is None or getattr(frame_bgr, "size", 0) == 0:
        return {"ok": False, "error": "empty frame"}
    if face_box is None:
        return {"ok": False, "error": "no face"}

    try:
        face_bgr = _extract_face_crop(frame_bgr, face_box)
        if face_bgr is None:
            return {"ok": False, "error": "invalid face crop"}
        scene_bgr = _extract_scene_crop(frame_bgr, subject_box)
        embedding = _compute_embedding(face_bgr)
        if embedding.size == 0:
            return {"ok": False, "error": "invalid embedding"}

        match_threshold = float(threshold if threshold is not None else config.read_vision_face_recognition_threshold())
        best_meta = None
        best_dir = None
        best_score = -1.0

        for person_dir in _person_dirs():
            meta = _read_person(person_dir)
            if not meta:
                continue
            stored = _load_embedding(meta)
            if stored.size == 0 or stored.shape != embedding.shape:
                continue
            score = _embedding_similarity(embedding, stored)
            if score > best_score:
                best_score = score
                best_meta = meta
                best_dir = person_dir

        if best_meta is not None and best_dir is not None and best_score >= match_threshold:
            best_meta = _append_sample(best_dir, best_meta, face_bgr, scene_bgr, embedding, source=source)
            best_meta["ok"] = True
            best_meta["known_person"] = True
            best_meta["new_person"] = False
            best_meta["match_score"] = float(best_score)
            best_meta["display_label"] = _display_label(best_meta)
            return best_meta

        created = _create_person(face_bgr, scene_bgr, embedding, source=source)
        created["ok"] = True
        created["match_score"] = float(best_score if best_score >= 0.0 else 0.0)
        return created
    except Exception as e:
        return {"ok": False, "error": str(e)}


def _load_image_from_bytes(data: bytes) -> Optional[np.ndarray]:
    if not data:
        return None
    arr = np.frombuffer(data, dtype=np.uint8)
    image = cv2.imdecode(arr, cv2.IMREAD_COLOR)
    return image


def import_person_photo(data: bytes, filename: str = "", person_id: str = "") -> Dict[str, Any]:
    image = _load_image_from_bytes(data)
    if image is None:
        return {"ok": False, "error": "invalid image"}
    face_box = detect_face_box(image)
    if face_box is None:
        return {"ok": False, "error": "no face detected in uploaded image"}
    person_id = str(person_id or "").strip()
    if person_id:
        person_dir = people_dir() / person_id
        meta = _read_person(person_dir)
        if not meta:
            return {"ok": False, "error": "person not found"}
        face_bgr = _extract_face_crop(image, face_box)
        if face_bgr is None:
            return {"ok": False, "error": "invalid face crop"}
        scene_bgr = image.copy()
        embedding = _compute_embedding(face_bgr)
        meta = _append_sample(person_dir, meta, face_bgr, scene_bgr, embedding, source="upload")
        meta["ok"] = True
        meta["known_person"] = True
        meta["new_person"] = False
        meta["display_label"] = _display_label(meta)
        return meta
    return recognize_or_enroll(image, face_box, None, source="upload")


def attach_captured_photo(person_id: str, photo_path: str | Path) -> bool:
    person_id = str(person_id or "").strip()
    if not person_id:
        return False
    person_dir = people_dir() / person_id
    meta = _read_person(person_dir)
    if not meta:
        return False
    src = Path(photo_path).expanduser()
    if not src.exists() or not src.is_file():
        return False
    dst = person_dir / f"{CAPTURE_PREFIX}{src.name}"
    try:
        shutil.copy2(src, dst)
        photos = meta.get("captured_photos") or []
        if not isinstance(photos, list):
            photos = []
        if dst.name not in photos:
            photos.append(dst.name)
        meta["captured_photos"] = sorted(photos)
        meta["last_seen_at"] = _now_ts()
        _write_person(person_dir, meta)
        return True
    except Exception:
        return False


def list_people() -> list[Dict[str, Any]]:
    items: list[Dict[str, Any]] = []
    for person_dir in _person_dirs():
        meta = _read_person(person_dir)
        if not meta:
            continue
        face_thumb = person_dir / FACE_REF_NAME
        scene_thumb = person_dir / SCENE_REF_NAME
        items.append(
            {
                "person_id": str(meta.get("person_id") or person_dir.name),
                "short_id": str(meta.get("short_id") or ""),
                "display_name": str(meta.get("display_name") or _display_label(meta)),
                "created_at": float(meta.get("created_at", 0.0) or 0.0),
                "created_date": str(meta.get("created_date") or ""),
                "last_seen_at": float(meta.get("last_seen_at", 0.0) or 0.0),
                "sample_count": int(meta.get("sample_count", 0) or 0),
                "times_seen": int(meta.get("times_seen", 0) or 0),
                "source": str(meta.get("source") or meta.get("last_source") or ""),
                "last_source": str(meta.get("last_source") or ""),
                "face_thumb": face_thumb.name if face_thumb.exists() else "",
                "scene_thumb": scene_thumb.name if scene_thumb.exists() else "",
                "captured_photos": list(meta.get("captured_photos") or []),
                "known": True,
            }
        )
    items.sort(key=lambda item: float(item.get("last_seen_at", 0.0) or 0.0), reverse=True)
    return items


def get_person(person_id: str) -> Optional[Dict[str, Any]]:
    person_dir = people_dir() / str(person_id or "").strip()
    meta = _read_person(person_dir)
    if not meta:
        return None
    meta["person_dir"] = str(person_dir)
    meta["display_label"] = _display_label(meta)
    return meta


def read_person_image(person_id: str, kind: str = "face") -> Optional[tuple[bytes, str, str]]:
    person_id = str(person_id or "").strip()
    if not person_id:
        return None
    person_dir = people_dir() / person_id
    if kind == "scene":
        target = person_dir / SCENE_REF_NAME
    else:
        target = person_dir / FACE_REF_NAME
    if not target.exists():
        return None
    data = target.read_bytes()
    return data, "image/jpeg", target.name


def delete_person(person_id: str) -> bool:
    person_dir = people_dir() / str(person_id or "").strip()
    if not person_dir.exists() or not person_dir.is_dir():
        return False
    shutil.rmtree(person_dir)
    return True


def reset_people() -> int:
    count = 0
    for person_dir in _person_dirs():
        shutil.rmtree(person_dir)
        count += 1
    return count


def export_person_zip(person_id: str, out_path: Path) -> Optional[Path]:
    person_id = str(person_id or "").strip()
    if not person_id:
        return None
    person_dir = people_dir() / person_id
    if not person_dir.exists():
        return None
    out_path.parent.mkdir(parents=True, exist_ok=True)
    with zipfile.ZipFile(out_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
        for child in sorted(person_dir.rglob("*")):
            if not child.is_file():
                continue
            zf.write(child, arcname=str(child.relative_to(person_dir.parent)))
    return out_path
