"""
shoot_service.py — shooting session driven from the photographer's phone (the /shoot web app).

Why this exists: the schools may not give us names, so a child cannot be known in advance, and photographing a
card before each child (the HEC way) is not acceptable for the client.

How a photo finds its child — by ORDER and by what the photographer DECLARES, never by clocks. An earlier design
matched photos to phone events by time; one FTP delay and a child's photos landed on the wrong family without
anyone noticing. This one cannot do that silently:

  1. The photographer scans the CLASS QR on the phone (a "run" starts).
  2. After each child they tap how many photos that child had: [1] [2] [3]. (Test shots: "ignore N".)
  3. Closing the run (next class, pause, end) takes a FENCE: the exact list of files that were in the FTP
     folder at that moment and belong to the run. The run therefore owns exactly those files, whatever their
     delay, and a mistake in one run can never shift the next one.
  4. The server turns the run into children ONLY if the declared total equals the files of the fence. If it does
     not, nothing is assigned: the files are parked and the operator is alerted. The phone shows
     "declared N · received M" live, so the mismatch is fixed while the child is still in front of the camera.

Files inside a run are ordered by the camera's own capture order (EXIF, then file name): that is a sequence,
not a comparison with any other device's clock. Faces are never compared (biometric data of minors).
"""
import hmac
import json
import logging
import re
import secrets
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional

from core.db import get_db, ensure_column, TABLE_OPTIONS
from core.timeutil import utcnow_iso

logger = logging.getLogger("meeconic.shoot")

KIND_CLASS, KIND_CHILD, KIND_SKIP, KIND_UNDO, KIND_END = "class", "child", "skip", "undo", "end"
EVENT_RETENTION_SECS = 7 * 86400
MAX_SKIP = 20

_CLASS_QR_RE = re.compile(r"^MEEC:CLASS:([a-z0-9][a-z0-9-]{0,63}):(.{1,40})$")


# ── Schema ─────────────────────────────────────────────────────────────────────

def init_shoot_tables() -> None:
    with get_db() as conn:
        conn.execute(f"""
        CREATE TABLE IF NOT EXISTS shoot_events (
            id           BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
            school_id    VARCHAR(64) NOT NULL,
            photographer VARCHAR(64) NOT NULL,
            at_ts        DOUBLE NOT NULL,
            kind         VARCHAR(10) NOT NULL,
            target       VARCHAR(64),
            meta         MEDIUMTEXT,
            created_at   VARCHAR(40) NOT NULL,
            KEY idx_shoot_events (school_id, photographer, id)
        ) {TABLE_OPTIONS}""")
        ensure_column(conn, "shoot_events", "meta", "MEDIUMTEXT")
        conn.commit()


# ── Access token (the link the photographer opens on the phone) ─────────────────────

def get_token(school_id: str) -> Optional[str]:
    return _kv_get(f"shoot_token:{school_id}")


def rotate_token(school_id: str) -> str:
    token = secrets.token_urlsafe(24)
    _kv_set(f"shoot_token:{school_id}", token)
    return token


def school_for_token(token: str) -> Optional[str]:
    """The school a token opens, or None. Compared in constant time against every stored token."""
    token = (token or "").strip()
    if len(token) < 20 or len(token) > 80:
        return None
    with get_db() as conn:
        rows = conn.execute("SELECT `key`, value FROM app_kv WHERE `key` LIKE 'shoot_token:%'").fetchall()
    found = None
    for r in rows:
        if hmac.compare_digest(str(r["value"]), token):
            found = r["key"].split(":", 1)[1]
    return found


# ── Class QR ───────────────────────────────────────────────────────────────────────────

def class_qr_payload(school_id: str, class_name: str) -> str:
    return f"MEEC:CLASS:{school_id}:{class_name}"


def parse_class_qr(text: str) -> Optional[tuple[str, str]]:
    m = _CLASS_QR_RE.match((text or "").strip())
    return (m.group(1), " ".join(m.group(2).split())) if m else None


# ── key/value helpers ─────────────────────────────────────────────────────────────────

def _kv_get(key: str) -> Optional[str]:
    with get_db() as conn:
        row = conn.execute("SELECT value FROM app_kv WHERE key=?", (key,)).fetchone()
    return row["value"] if row else None


def _kv_set(key: str, value: str) -> None:
    with get_db() as conn:
        conn.execute("INSERT OR REPLACE INTO app_kv (key, value) VALUES (?,?)", (key, value))
        conn.commit()


def get_state(school_id: str, photographer: str) -> dict:
    """`processed`: id of the last run already turned into children (or parked)."""
    raw = _kv_get(f"shoot_state:{school_id}:{photographer}")
    base = {"processed": 0, "blocked": []}
    try:
        return {**base, **(json.loads(raw) if raw else {})}
    except ValueError:
        return base


def set_state(school_id: str, photographer: str, state: dict) -> None:
    _kv_set(f"shoot_state:{school_id}:{photographer}", json.dumps(state))


def get_progress(school_id: str, photographer: str, run_id: int) -> Optional[dict]:
    raw = _kv_get(f"shoot_run:{school_id}:{photographer}:{run_id}")
    try:
        return json.loads(raw) if raw else None
    except ValueError:
        return None


def set_progress(school_id: str, photographer: str, run_id: int, progress: dict) -> None:
    _kv_set(f"shoot_run:{school_id}:{photographer}:{run_id}", json.dumps(progress))


# ── Events ───────────────────────────────────────────────────────────────────────────

def record_event(school_id: str, photographer: str, kind: str, target: Optional[str] = None,
                 meta: Optional[dict] = None) -> dict:
    with get_db() as conn:
        cur = conn.execute(
            "INSERT INTO shoot_events (school_id, photographer, at_ts, kind, target, meta, created_at) "
            "VALUES (?,?,?,?,?,?,?)",
            (school_id, photographer, time.time(), kind, target, json.dumps(meta) if meta else None, utcnow_iso()))
        conn.commit()
        return {"id": cur.lastrowid, "kind": kind, "target": target}


def events_for(school_id: str, photographer: str, since: Optional[float] = None) -> list[dict]:
    since = (time.time() - 48 * 3600) if since is None else since
    with get_db() as conn:
        rows = conn.execute(
            "SELECT id, at_ts, kind, target, meta FROM shoot_events WHERE school_id=? AND photographer=? AND at_ts>=? "
            "ORDER BY id", (school_id, photographer, since)).fetchall()
    out = []
    for r in rows:
        try:
            meta = json.loads(r["meta"]) if r["meta"] else {}
        except ValueError:
            meta = {}
        out.append({"id": r["id"], "at": r["at_ts"], "kind": r["kind"], "target": r["target"], "meta": meta})
    return out


def recent_events(school_id: str, limit: int = 12) -> list[dict]:
    with get_db() as conn:
        rows = conn.execute(
            "SELECT id, photographer, at_ts, kind, target FROM shoot_events WHERE school_id=? "
            "ORDER BY id DESC LIMIT ?", (school_id, int(limit))).fetchall()
    return [{"id": r["id"], "photographer": r["photographer"], "at": r["at_ts"], "kind": r["kind"],
             "target": r["target"]} for r in rows]


def prune_events() -> int:
    with get_db() as conn:
        n = conn.execute("DELETE FROM shoot_events WHERE at_ts<?", (time.time() - EVENT_RETENTION_SECS,)).rowcount
        conn.commit()
    return n


# ── Runs (pure: events in, runs out — that is what makes the rules testable) ─────────────────────────

@dataclass
class Run:
    id: int                               # id of the "class" event that started it
    class_name: str
    before: list[str] = field(default_factory=list)    # files already in the folder when it started (strays)
    items: list[tuple[str, int]] = field(default_factory=list)   # ("child", n) | ("skip", n), in tap order
    closed: bool = False
    files: Optional[list[str]] = None     # the fence: exactly the files this run owns (None = never fenced)

    @property
    def declared(self) -> int:
        return sum(n for _, n in self.items)

    @property
    def children(self) -> int:
        return sum(1 for k, _ in self.items if k == KIND_CHILD)


def build_runs(events: list[dict]) -> list[Run]:
    """Fold the event stream (in insertion order, never by time) into runs."""
    runs: list[Run] = []
    cur: Optional[Run] = None
    for e in sorted(events, key=lambda x: x["id"]):
        kind = e["kind"]
        if kind == KIND_CLASS:
            if cur is not None and not cur.closed:      # a new class always closes the previous one
                cur.closed = True
            cur = Run(id=e["id"], class_name=e["target"] or "", before=list(e.get("meta", {}).get("before", [])))
            runs.append(cur)
        elif cur is None or cur.closed:
            continue
        elif kind in (KIND_CHILD, KIND_SKIP):
            try:
                n = int(e["target"])
            except (TypeError, ValueError):
                continue
            if n >= 1:
                cur.items.append((kind, n))
        elif kind == KIND_UNDO:
            if cur.items:
                cur.items.pop()
        elif kind == KIND_END:
            cur.closed = True
            files = e.get("meta", {}).get("files")
            cur.files = list(files) if isinstance(files, list) else None
    return runs


def open_run(events: list[dict]) -> Optional[Run]:
    runs = build_runs(events)
    return runs[-1] if runs and not runs[-1].closed else None


def claimed_names(runs: list[Run], processed: int) -> set[str]:
    """Files already owned by a closed run that has not been turned into children yet."""
    out: set[str] = set()
    for r in runs:
        if r.closed and r.id > processed and r.files:
            out.update(r.files)
    return out


def sort_capture_order(paths: list[Path], exif_time) -> list[Path]:
    """The camera's own capture order: EXIF time when present, then the (natural) file name."""
    def natural(name: str):
        return [int(t) if t.isdigit() else t.lower() for t in re.split(r"(\d+)", name)]
    return sorted(paths, key=lambda p: (exif_time(p) or 0, natural(p.name)))


# ── The folder ─────────────────────────────────────────────────────────────────────────────────────

def pool_names(school_id: str, photographer: str) -> list[str]:
    """Image files currently waiting in the school's FTP folder that belong to this photographer."""
    from core import config
    from services import photographer_service
    from services.organizer_service import incoming_dir_for
    exts = {e.lower() for e in config.IMAGE_EXTENSIONS}
    folder = incoming_dir_for(school_id)
    names = []
    try:
        for f in folder.iterdir():
            if (f.is_file() and f.suffix.lower() in exts and not f.name.startswith(".")
                    and photographer_service.resolve_photographer(school_id, f.name) == photographer):
                names.append(f.name)
    except OSError:
        return []
    return sorted(names)


def live_counts(school_id: str, photographer: str) -> dict:
    """What the phone shows while shooting: the open run, what was declared and what has arrived."""
    events = events_for(school_id, photographer)
    runs = build_runs(events)
    processed = get_state(school_id, photographer)["processed"]
    run = runs[-1] if runs and not runs[-1].closed else None
    if run is None:
        return {"active": None, "declared": 0, "received": 0, "children": 0, "ok": True}
    pool = set(pool_names(school_id, photographer))
    own = pool - claimed_names(runs, processed) - set(run.before)
    return {"active": {"class_name": run.class_name, "run": run.id}, "declared": run.declared,
            "received": len(own), "children": run.children, "ok": run.declared == len(own)}


def create_child_family(school_id: str, class_name: str) -> str:
    """A new family code attached to `class_name`, created by the shooting itself (nobody types it)."""
    from services import school_service
    from utils.code_generator import generate_unique_code
    with get_db() as conn:
        code = generate_unique_code(conn, school_service.code_prefix_for(school_id))
        conn.execute("INSERT INTO families (code, family_name, email, created_at, school_id) VALUES (?,?,?,?,?)",
                     (code, "", None, utcnow_iso(), school_id))
        conn.execute("INSERT INTO students (family_code, class_name) VALUES (?,?)", (code, class_name))
        conn.commit()
    return code
