#!/usr/bin/env python3
"""
Autotimbratura Obolo: sleep random nella finestra oraria, anti-duplicato ±3h stesso tipo, poi POST movimento.
Solo libreria standard (urllib, json, zoneinfo). Ubuntu: Python 3.9+.
"""
from __future__ import annotations

import argparse
import json
import math
import os
import random
import ssl
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any, Iterable

try:
    from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
except ImportError:
    print("Serve Python 3.9+ con zoneinfo.", file=sys.stderr)
    sys.exit(1)

_rome_tz: ZoneInfo | None = None


def rome_tz() -> ZoneInfo:
    global _rome_tz
    if _rome_tz is not None:
        return _rome_tz
    try:
        _rome_tz = ZoneInfo("Europe/Rome")
    except ZoneInfoNotFoundError:
        print(
            "Fuso Europe/Rome non trovato (manca tzdata). "
            "Su Ubuntu: sudo apt install -y tzdata. "
            "Su Windows (test): pip install tzdata",
            file=sys.stderr,
        )
        sys.exit(1)
    return _rome_tz

SLOTS: dict[str, dict[str, Any]] = {
    "mattina": {"tipo": 1, "start": (8, 30), "end": (9, 0)},
    "pranzo": {"tipo": 2, "start": (13, 0), "end": (13, 15)},
    "pomeriggio": {"tipo": 1, "start": (14, 0), "end": (14, 10)},
    "sera": {"tipo": 2, "start": (18, 0), "end": (18, 30)},
}


def load_env_file(path: str) -> None:
    if not path or not os.path.isfile(path):
        return
    with open(path, encoding="utf-8") as f:
        for raw in f:
            line = raw.strip()
            if not line or line.startswith("#"):
                continue
            key, _, val = line.partition("=")
            key, val = key.strip(), val.strip().strip('"').strip("'")
            if key and key not in os.environ:
                os.environ[key] = val


def env_int(name: str, default: int) -> int:
    v = os.environ.get(name)
    if v is None or v == "":
        return default
    return int(v)


def env_float(name: str, default: float) -> float:
    v = os.environ.get(name)
    if v is None or v == "":
        return default
    return float(v)


def env_str(name: str, default: str) -> str:
    v = os.environ.get(name)
    return default if v is None or v == "" else v


def sleep_random_within_window(
    start_hm: tuple[int, int], end_hm: tuple[int, int], now: datetime, skip: bool
) -> datetime:
    """
    Se non skip: attende fino all'inizio finestra (se siamo in anticipo), poi un tempo casuale
    fino alla fine finestra (inclusivo verso la fine). Ritorna l'ora dopo gli sleep.
    """
    d = now.date()
    tz = rome_tz()
    t0 = datetime(d.year, d.month, d.day, start_hm[0], start_hm[1], 0, tzinfo=tz)
    t1 = datetime(d.year, d.month, d.day, end_hm[0], end_hm[1], 0, tzinfo=tz)
    if t1 <= t0:
        raise ValueError("Finestra oraria non valida (end <= start)")
    if skip:
        return datetime.now(tz=rome_tz())
    cur = datetime.now(tz=rome_tz())
    if cur < t0:
        time.sleep((t0 - cur).total_seconds())
        cur = datetime.now(tz=rome_tz())
    remaining = (t1 - cur).total_seconds()
    if remaining <= 0:
        return cur
    time.sleep(random.randint(0, int(remaining)))
    return datetime.now(tz=rome_tz())


def http_post_form(url: str, fields: dict[str, Any], timeout: int = 60) -> tuple[int, str]:
    data = urllib.parse.urlencode(fields, doseq=True).encode("utf-8")
    req = urllib.request.Request(url, data=data, method="POST")
    req.add_header("Content-Type", "application/x-www-form-urlencoded")
    ctx = ssl.create_default_context()
    try:
        with urllib.request.urlopen(req, context=ctx, timeout=timeout) as resp:
            body = resp.read().decode("utf-8", errors="replace")
            return resp.getcode(), body
    except urllib.error.HTTPError as e:
        body = e.read().decode("utf-8", errors="replace") if e.fp else ""
        return e.code, body


def http_get(url: str, timeout: int = 60) -> tuple[int, str]:
    ctx = ssl.create_default_context()
    req = urllib.request.Request(url, method="GET")
    try:
        with urllib.request.urlopen(req, context=ctx, timeout=timeout) as resp:
            return resp.getcode(), resp.read().decode("utf-8", errors="replace")
    except urllib.error.HTTPError as e:
        body = e.read().decode("utf-8", errors="replace") if e.fp else ""
        return e.code, body


def parse_api_datetime(s: str | None) -> datetime | None:
    if not s:
        return None
    s = s.strip()
    # ISO 8601 da .NET, eventualmente con Z
    if s.endswith("Z"):
        s = s[:-1] + "+00:00"
    try:
        dt = datetime.fromisoformat(s)
    except ValueError:
        return None
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=rome_tz())
    else:
        dt = dt.astimezone(rome_tz())
    return dt


def get_movement_field(m: dict[str, Any], *names: str) -> Any:
    for n in names:
        if n in m:
            return m[n]
    return None


def movements_list(payload: Any) -> list[dict[str, Any]]:
    if isinstance(payload, list):
        return [x for x in payload if isinstance(x, dict)]
    return []


def has_conflict(
    movements: Iterable[dict[str, Any]], tipo: int, r: datetime, hours: float = 3.0
) -> bool:
    lo = r - timedelta(hours=hours)
    hi = r + timedelta(hours=hours)
    for m in movements:
        cid = get_movement_field(m, "causaleMovimentoId", "CausaleMovimentoId")
        if cid is None:
            continue
        try:
            if int(cid) != int(tipo):
                continue
        except (TypeError, ValueError):
            continue
        raw_dm = get_movement_field(m, "dataMov", "DataMov")
        if raw_dm is None:
            continue
        dm = parse_api_datetime(str(raw_dm))
        if dm is None:
            continue
        if lo <= dm <= hi:
            return True
    return False


def login_token(
    base_url: str,
    api_key_cred: str,
    azienda: int,
    codice_dipendente: str,
) -> str:
    url = base_url.rstrip("/") + "/api/authtoken"
    fields = {
        "ServiceName": "credenziali",
        "apiKey": api_key_cred,
        "azienda": str(azienda),
        "codiceDipendente": codice_dipendente,
    }
    code, body = http_post_form(url, fields)
    if code != 200:
        raise RuntimeError(f"authtoken HTTP {code}: {body[:500]}")
    data = json.loads(body)
    tok = data.get("token") or data.get("Token")
    if not tok:
        raise RuntimeError(f"authtoken: token mancante nel JSON: {body[:300]}")
    return str(tok)


def fetch_movements(
    base_url: str,
    api_key_scarico: str,
    token: str,
    azienda: int,
    codice_dipendente: str,
    datainizio: datetime,
) -> list[dict[str, Any]]:
    url = base_url.rstrip("/") + "/api/movimento"
    datainizio_iso = datainizio.astimezone(rome_tz()).isoformat(timespec="milliseconds")
    qs = urllib.parse.urlencode(
        {
            "serviceName": "scaricomov",
            "apiKey": api_key_scarico,
            "token": token,
            "datainizio": datainizio_iso,
            "azienda": str(azienda),
            "codiceDipendente": codice_dipendente,
        }
    )
    code, body = http_get(url + "?" + qs)
    if code != 200:
        raise RuntimeError(f"GET movimento HTTP {code}: {body[:500]}")
    data = json.loads(body)
    return movements_list(data)


def post_movimento(
    base_url: str,
    api_key_inviomov: str,
    token: str,
    azienda: int,
    codice_dipendente: str,
    data_mov: datetime,
    tipo: int,
    lat: float,
    lon: float,
) -> None:
    url = base_url.rstrip("/") + "/api/movimento"
    dm = data_mov.astimezone(rome_tz()).isoformat(timespec="milliseconds")
    fields = {
        "servicename": "inviomov",
        "APIKEY": api_key_inviomov,
        "token": token,
        "azienda": str(azienda),
        "codiceDipendente": codice_dipendente,
        "dataMov": dm,
        "tipoMovimento": str(tipo),
        "latitudine": str(lat),
        "longitudine": str(lon),
    }
    code, body = http_post_form(url, fields)
    if code != 200:
        raise RuntimeError(f"POST movimento HTTP {code}: {body[:500]}")


def start_of_day_rome(d: datetime) -> datetime:
    return datetime(d.year, d.month, d.day, 0, 0, 0, tzinfo=rome_tz())


def jitter_gps(lat: float, lon: float, radius_m: float) -> tuple[float, float]:
    """
    Coordinate casuali entro un disco di raggio `radius_m` metri attorno al punto base
    (simula dispersione GPS da smartphone nello stesso edificio).
    """
    if radius_m <= 0:
        return lat, lon
    u, v = random.random(), random.random()
    r = radius_m * math.sqrt(u)
    theta = 2 * math.pi * v
    dn = r * math.cos(theta)
    de = r * math.sin(theta)
    dlat = dn / 111_320.0
    dlon = de / (111_320.0 * max(0.2, math.cos(math.radians(lat))))
    return lat + dlat, lon + dlon


@dataclass
class Config:
    base_url: str
    api_key_cred: str
    api_key_inviomov: str
    api_key_scarico: str
    azienda: int
    codice_dipendente: str
    lat: float
    lon: float
    jitter_radius_m: float
    conflict_hours: float


def load_config() -> Config:
    return Config(
        base_url=env_str("OBOL_BASE_URL", "https://obolo.mypantarei.net/APP"),
        api_key_cred=env_str(
            "OBOL_API_KEY_CRED",
            "eo0SSYMMR1KD1xW9zgKO3vjS3SDH3xFezFWciwHy",
        ),
        api_key_inviomov=env_str(
            "OBOL_API_KEY_INVIOMOV",
            "Vv97N6sFAN2LxQQe6zuRAFXIc4KU4jHcXRdPuCVN",
        ),
        api_key_scarico=env_str(
            "OBOL_API_KEY_SCARICO",
            "zg2eu0JojdwT5Hi2WzozYbF8YRlQI4c9ryKcC8KpBzVriKs5eZdBxx3Vw7BneKwm",
        ),
        azienda=env_int("OBOL_AZIENDA", 3050),
        codice_dipendente=env_str("OBOL_CODICE_DIPENDENTE", "171083"),
        lat=env_float("OBOL_LAT", 43.634856),
        lon=env_float("OBOL_LON", 11.456268),
        jitter_radius_m=env_float("OBOL_GPS_JITTER_M", 35.0),
        conflict_hours=float(env_str("OBOL_CONFLICT_HOURS", "3") or "3"),
    )


def main() -> int:
    parser = argparse.ArgumentParser(description="Obolo autotimbratura per slot giornaliero.")
    parser.add_argument(
        "slot",
        choices=list(SLOTS.keys()),
        help="mattina | pranzo | pomeriggio | sera",
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="Non invia POST; mostra solo cosa farebbe",
    )
    parser.add_argument(
        "--no-sleep",
        action="store_true",
        help="Salta lo sleep random (solo test)",
    )
    args = parser.parse_args()

    env_path = os.environ.get("OBOL_ENV_FILE")
    if not env_path:
        here = os.path.dirname(os.path.abspath(__file__))
        env_path = os.path.join(here, "local.env")
    load_env_file(env_path)

    cfg = load_config()
    now = datetime.now(tz=rome_tz())

    if now.weekday() >= 5:
        print(f"{now.isoformat()} skip: weekend")
        return 0

    spec = SLOTS[args.slot]
    tipo = int(spec["tipo"])
    start_hm: tuple[int, int] = spec["start"]
    end_hm: tuple[int, int] = spec["end"]

    r = sleep_random_within_window(start_hm, end_hm, now, skip=args.no_sleep)

    print(f"{r.isoformat()} slot={args.slot} tipo={tipo} dry_run={args.dry_run}")

    token = login_token(cfg.base_url, cfg.api_key_cred, cfg.azienda, cfg.codice_dipendente)

    day_start = start_of_day_rome(r)
    movements = fetch_movements(
        cfg.base_url,
        cfg.api_key_scarico,
        token,
        cfg.azienda,
        cfg.codice_dipendente,
        day_start,
    )

    if has_conflict(movements, tipo, r, cfg.conflict_hours):
        print(f"{r.isoformat()} skip: conflitto stesso tipo entro ±{cfg.conflict_hours}h")
        return 0

    use_lat, use_lon = jitter_gps(cfg.lat, cfg.lon, cfg.jitter_radius_m)
    print(
        f"{datetime.now(tz=rome_tz()).isoformat()} "
        f"GPS {use_lat:.7f},{use_lon:.7f} (base {cfg.lat:.7f},{cfg.lon:.7f}, raggio {cfg.jitter_radius_m}m)"
    )

    if args.dry_run:
        print("dry-run: nessun POST inviato")
        return 0

    # Token fresco dopo GET; reinserimento login se sleep lungo ha superato 10 min (raro)
    token2 = login_token(cfg.base_url, cfg.api_key_cred, cfg.azienda, cfg.codice_dipendente)
    post_movimento(
        cfg.base_url,
        cfg.api_key_inviomov,
        token2,
        cfg.azienda,
        cfg.codice_dipendente,
        r,
        tipo,
        use_lat,
        use_lon,
    )
    print(f"{datetime.now(tz=rome_tz()).isoformat()} OK movimento inviato tipo={tipo}")
    return 0


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except Exception as e:
        print(f"ERRORE: {e}", file=sys.stderr)
        raise SystemExit(1)
