#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Script      : analyses_scraper.py
Chemin      : /var/www/html/analyses/analyses_scraper.py
Description : Récupère les analyses de biotoxines sur le site INTECMAR
              (https://www.intecmar.gal/Informacion/biotoxinas/ratos/)
              pour chaque site présent dans la table `lieux`, puis insère
              ou met à jour les données dans la base MySQL `analyses`
              (table `analyses`), avec fusion et priorisation des valeurs
              en cas de doublons (même id_lieu / date / profondeur).
Options     : aucune (exécution simple, prévue pour un cron)
Exemple     : python3 analyses_scraper.py
Prérequis   : - Python 3
              - Modules : requests, bs4, mysql-connector-python
              - Fichier .env dans le même répertoire avec :
                    DB_HOST=localhost
                    DB_PORT=3306
                    DB_USER=...
                    DB_PASSWORD=...
                    DB_NAME=analyses
                    LOG_MODE=normal   (ou debug)
              - Accès HTTP sortant vers intecmar.gal
Auteur      : Sylvain SCATTOLINI
Date        : 27/11/2025
Version     : 1.1
"""

import os
import re
import sys
import math
import time
import logging
import traceback
from pathlib import Path
from datetime import datetime, date

import requests
from bs4 import BeautifulSoup
import mysql.connector


# ---------------------------------------------------------------------------
# Configuration de base
# ---------------------------------------------------------------------------

BASE_DIR = Path(__file__).resolve().parent
ENV_PATH = BASE_DIR / ".env"
LOG_DIR = BASE_DIR / "logs"
LOG_DIR.mkdir(parents=True, exist_ok=True)

LOG_FILE = LOG_DIR / "analyses_scraper.log"
BASE_URL = "https://www.intecmar.gal"
START_URL = BASE_URL + "/Informacion/biotoxinas/ratos/Default.aspx?sm=a4"

# ID du dropdown et de la grille sur la page ASP.NET
DROPDOWN_NAME = "ctl00$Contenido$dpZonas2"
GRID_ID = "ctl00_Contenido_GridView2"


# ---------------------------------------------------------------------------
# Chargement du .env (sans dépendance externe)
# ---------------------------------------------------------------------------

def load_env(env_path: Path) -> dict:
    env = {}
    if not env_path.is_file():
        return env

    with env_path.open("r", encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if not line or line.startswith("#") or "=" not in line:
                continue
            key, value = line.split("=", 1)
            env[key.strip()] = value.strip()
    return env


ENV = load_env(ENV_PATH)


# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------

def setup_logging() -> logging.Logger:
    log_mode = ENV.get("LOG_MODE", "normal").strip().lower()
    level = logging.DEBUG if log_mode == "debug" else logging.INFO

    logger = logging.getLogger("analyses_scraper")
    logger.setLevel(level)
    logger.handlers.clear()

    fmt = logging.Formatter(
        "%(asctime)s [%(levelname)s] %(name)s - %(message)s",
        datefmt="%Y-%m-%d %H:%M:%S"
    )

    # Fichier
    fh = logging.FileHandler(LOG_FILE, encoding="utf-8")
    fh.setLevel(level)
    fh.setFormatter(fmt)
    logger.addHandler(fh)

    # Console
    ch = logging.StreamHandler(sys.stdout)
    ch.setLevel(level)
    ch.setFormatter(fmt)
    logger.addHandler(ch)

    return logger


logger = setup_logging()


# ---------------------------------------------------------------------------
# Connexion MySQL
# ---------------------------------------------------------------------------

def get_db_connection():
    try:
        conn = mysql.connector.connect(
            host=ENV.get("DB_HOST", "localhost"),
            port=int(ENV.get("DB_PORT", "3306")),
            user=ENV.get("DB_USER", "root"),
            password=ENV.get("DB_PASSWORD", ""),
            database=ENV.get("DB_NAME", "analyses"),
            charset="utf8mb4",
            use_unicode=True,
        )
        return conn
    except Exception as e:
        logger.error("Erreur de connexion MySQL : %s", e)
        raise


# ---------------------------------------------------------------------------
# Normalisation / parsing des valeurs numériques (+ tolérance)
# ---------------------------------------------------------------------------

def truncate(value: float, decimals: int = 3) -> float:
    """Tronque sans arrondir (ex : 0.0719 -> 0.071)."""
    factor = 10 ** decimals
    if value >= 0:
        return math.floor(value * factor) / factor
    return math.ceil(value * factor) / factor


def parse_value_with_tolerance(raw: str, context: str) -> tuple:
    """
    Retourne (valeur, tolérance) à partir d'une chaîne brute.

    Règles :
      - '---'           -> (None, None)
      - ''              -> (None, None)
      - '< LQ', '< 2,0' -> (0.0, None)
      - '146,8 ±30,1'   -> (146.8, 30.1)
      - '21,8 ­±2,7'    -> (21.8, 2.7)
      - '0,071'         -> (0.071, None)
    """
    if raw is None:
        return None, None

    txt = raw.strip()
    if not txt:
        return None, None

    # Valeurs "non mesurées"
    if txt == "---":
        return None, None

    # Valeurs "sous LQ" ou similaires => 0
    if txt.startswith("<"):
        return 0.0, None

    # Normalisation des caractères bizarres
    txt = txt.replace("\xa0", " ")   # NBSP
    txt = txt.replace("­", "")       # soft hyphen éventuel

    # Séparation valeur / tolérance
    if "±" in txt:
        v_part, t_part = txt.split("±", 1)
    elif "+/-" in txt:
        v_part, t_part = txt.split("+/-", 1)
    else:
        v_part, t_part = txt, None

    def to_float(part: str):
        if part is None:
            return None
        part = part.strip()
        if not part:
            return None
        # On garde uniquement chiffres, virgule, point, signe -
        part = re.sub(r"[^0-9,.\-]", "", part)
        if not part:
            return None
        # Virgule décimale -> point
        part = part.replace(",", ".")
        try:
            val = float(part)
            return truncate(val, 3)
        except Exception:
            logger.warning(
                "Échec conversion numérique pour '%s' (%s)", raw, context
            )
            return None

    value = to_float(v_part)
    tolerance = to_float(t_part) if t_part else None
    return value, tolerance


# ---------------------------------------------------------------------------
# Fusion/priorisation des valeurs (en cas de doublons prof./date)
# ---------------------------------------------------------------------------

def merge_numeric(existing, new):
    """
    Priorisation :
      - None < 0 < décimal > 0
      - si existing décimal > 0, on le conserve
      - si existing 0 et new décimal > 0 -> on prend new
    """
    if existing is None:
        return new
    if new is None:
        return existing

    # On considère qu'il s'agit souvent de valeurs >= 0
    if existing == 0 and new > 0:
        return new
    if existing > 0 and new == 0:
        return existing

    # les deux > 0 -> garder le premier (plus récent dans notre flux)
    return existing


def merge_rows(existing: dict, new: dict) -> dict:
    """Fusionne deux enregistrements pour la même clé (id_lieu/date/profondeur)."""
    for field in ("psp", "asp", "ao", "aza", "ytx"):
        existing[field] = merge_numeric(existing.get(field), new.get(field))

    # Tolérances : on garde la première non-nulle
    for field in ("psp_tolerance", "asp_tolerance",
                  "ao_tolerance", "aza_tolerance", "ytx_tolerance"):
        if existing.get(field) is None and new.get(field) is not None:
            existing[field] = new[field]

    # Résultat : si l'un des deux est positif => positif
    if existing.get("resultat", 0) == 1 or new.get("resultat", 0) == 1:
        existing["resultat"] = 1
        existing["resultat_texte"] = "positif"
    else:
        existing["resultat"] = 0
        existing["resultat_texte"] = "négatif"

    return existing


# ---------------------------------------------------------------------------
# HTTP / scraping ASP.NET
# ---------------------------------------------------------------------------

def get_datos_url(session: requests.Session) -> str:
    """
    Charge la page Default.aspx?sm=a4 puis récupère le lien
    'Análises de biotoxinas por zonas de produción' -> DatosMSZona.aspx
    """
    resp = session.get(START_URL, timeout=20)
    resp.raise_for_status()
    soup = BeautifulSoup(resp.text, "html.parser")

    link = soup.find("a", href=True, string=lambda t: t and "biotoxinas por zonas" in t)
    if not link:
        # fallback : chercher directement DatosMSZona.aspx
        link = soup.find("a", href=lambda h: h and "DatosMSZona.aspx" in h)

    if not link:
        raise RuntimeError("Impossible de trouver le lien vers DatosMSZona.aspx")

    href = link["href"]
    if href.startswith("http"):
        return href
    return requests.compat.urljoin(START_URL, href)


def get_zone_page(session: requests.Session, datos_url: str, zone_value: str) -> BeautifulSoup:
    """
    Charge la page DatosMSZona.aspx puis effectue un POST ASP.NET
    simulant le changement de sélection sur le dropdown des zones.
    """
    # 1. GET pour récupérer VIEWSTATE, etc.
    resp = session.get(datos_url, timeout=20)
    resp.raise_for_status()
    soup = BeautifulSoup(resp.text, "html.parser")

    form = soup.find("form")
    if not form:
        raise RuntimeError("Formulaire ASP.NET introuvable sur DatosMSZona.aspx")

    def get_input_value(name: str) -> str:
        tag = form.find("input", {"name": name})
        return tag["value"] if tag and tag.has_attr("value") else ""

    viewstate = get_input_value("__VIEWSTATE")
    event_validation = get_input_value("__EVENTVALIDATION")
    viewstate_generator = get_input_value("__VIEWSTATEGENERATOR")

    payload = {
        "__EVENTTARGET": DROPDOWN_NAME,
        "__EVENTARGUMENT": "",
        "__VIEWSTATE": viewstate,
        "__EVENTVALIDATION": event_validation,
        "__VIEWSTATEGENERATOR": viewstate_generator,
        DROPDOWN_NAME: zone_value,
    }

    # On embarque les autres inputs cachés pour être plus fidèle
    for inp in form.find_all("input"):
        name = inp.get("name")
        if not name:
            continue
        if name in payload:
            continue
        payload.setdefault(name, inp.get("value", ""))

    headers = {
        "User-Agent": "Mozilla/5.0 (compatible; analyses_scraper/1.1)",
        "Referer": datos_url,
    }

    resp_post = session.post(datos_url, data=payload, headers=headers, timeout=20)
    resp_post.raise_for_status()
    return BeautifulSoup(resp_post.text, "html.parser")


# ---------------------------------------------------------------------------
# Parsing de la grille
# ---------------------------------------------------------------------------

def parse_grid_for_site(
    soup: BeautifulSoup,
    id_lieu: int,
    code_lieu: str,
    nom_lieu: str,
) -> list:
    """
    Retourne une liste d'enregistrements (dict) pour un site donné.
    Fusion des doublons (même date/profondeur) faite ensuite au niveau appelant.
    """
    table = soup.find("table", id=GRID_ID)
    if not table:
        logger.warning(
            "Tableau des analyses introuvable pour le lieu %s (%s)", code_lieu, nom_lieu
        )
        return []

    rows = table.find_all("tr")
    if len(rows) <= 1:
        logger.info(
            "Aucune donnée (hors en-tête) pour le lieu %s (%s)", code_lieu, nom_lieu
        )
        return []

    data_rows = []
    # on ignore la première ligne (header)
    for tr in rows[1:]:
        tds = tr.find_all("td")
        if len(tds) < 9:
            continue

        # Date d'analyse
        date_txt = tds[0].get_text(strip=True)
        try:
            d = datetime.strptime(date_txt, "%d/%m/%Y").date()
        except Exception:
            logger.warning(
                "Date invalide '%s' pour le lieu %s (%s)", date_txt, code_lieu, nom_lieu
            )
            continue

        # Profondeur
        prof_txt = tds[2].get_text(strip=True)
        # On récupère toutes les profondeurs numériques trouvées (1, 5, 10)
        prof_numbers = re.findall(r"\d+", prof_txt)
        if not prof_numbers:
            logger.warning(
                "Profondeur introuvable '%s' pour le lieu %s (%s) - date %s",
                prof_txt, code_lieu, nom_lieu, d,
            )
            continue
        profondeurs = [int(x) for x in prof_numbers]

        # Résultat (positif / négatif)
        res_txt = tds[8].get_text(strip=True).lower()
        if "positiv" in res_txt:
            resultat = 1
            resultat_texte = "positif"
        else:
            resultat = 0
            resultat_texte = "négatif"

        context_base = f"lieu={code_lieu}, date={d}"

        # Colonnes toxines
        psp_val, psp_tol = parse_value_with_tolerance(
            tds[3].get_text(strip=True), context_base + ", toxine=PSP"
        )
        asp_val, asp_tol = parse_value_with_tolerance(
            tds[4].get_text(strip=True), context_base + ", toxine=ASP"
        )
        ao_val, ao_tol = parse_value_with_tolerance(
            tds[5].get_text(strip=True), context_base + ", toxine=AO"
        )
        aza_val, aza_tol = parse_value_with_tolerance(
            tds[6].get_text(strip=True), context_base + ", toxine=AZA"
        )
        ytx_val, ytx_tol = parse_value_with_tolerance(
            tds[7].get_text(strip=True), context_base + ", toxine=YTX"
        )

        for prof in profondeurs:
            data_rows.append(
                {
                    "id_lieu": id_lieu,
                    "date_analyse": d,
                    "profondeur": prof,
                    "psp": psp_val,
                    "psp_tolerance": psp_tol,
                    "asp": asp_val,
                    "asp_tolerance": asp_tol,
                    "ao": ao_val,
                    "ao_tolerance": ao_tol,
                    "aza": aza_val,
                    "aza_tolerance": aza_tol,
                    "ytx": ytx_val,
                    "ytx_tolerance": ytx_tol,
                    "resultat": resultat,
                    "resultat_texte": resultat_texte,
                }
            )

    return data_rows


# ---------------------------------------------------------------------------
# Récupération des lieux depuis la base
# ---------------------------------------------------------------------------

def fetch_lieux(conn) -> list:
    sql = """
        SELECT id_lieu, code_lieu, nom_lieu
        FROM lieux
        ORDER BY ordre_lieu, id_lieu
    """
    with conn.cursor(dictionary=True) as cur:
        cur.execute(sql)
        rows = cur.fetchall()
    return rows


# ---------------------------------------------------------------------------
# Insertion / mise à jour en base
# ---------------------------------------------------------------------------

UPSERT_SQL = """
INSERT INTO analyses (
    id_lieu, date_analyse, profondeur,
    psp, psp_tolerance,
    asp, asp_tolerance,
    ao, ao_tolerance,
    aza, aza_tolerance,
    ytx, ytx_tolerance,
    resultat, resultat_texte,
    created_at, updated_at
)
VALUES (
    %(id_lieu)s, %(date_analyse)s, %(profondeur)s,
    %(psp)s, %(psp_tolerance)s,
    %(asp)s, %(asp_tolerance)s,
    %(ao)s, %(ao_tolerance)s,
    %(aza)s, %(aza_tolerance)s,
    %(ytx)s, %(ytx_tolerance)s,
    %(resultat)s, %(resultat_texte)s,
    NOW(), NOW()
)
ON DUPLICATE KEY UPDATE
    psp = VALUES(psp),
    psp_tolerance = VALUES(psp_tolerance),
    asp = VALUES(asp),
    asp_tolerance = VALUES(asp_tolerance),
    ao = VALUES(ao),
    ao_tolerance = VALUES(ao_tolerance),
    aza = VALUES(aza),
    aza_tolerance = VALUES(aza_tolerance),
    ytx = VALUES(ytx),
    ytx_tolerance = VALUES(ytx_tolerance),
    resultat = VALUES(resultat),
    resultat_texte = VALUES(resultat_texte),
    updated_at = NOW();
"""


def save_analyses(conn, analyses_list: list) -> int:
    if not analyses_list:
        return 0

    inserted = 0
    with conn.cursor() as cur:
        for rec in analyses_list:
            cur.execute(UPSERT_SQL, rec)
            inserted += 1
        conn.commit()
    return inserted


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

def main():
    start_time = datetime.now()
    logger.info("Démarrage du script analyses_scraper (mode logs=%s)", ENV.get("LOG_MODE", "normal"))

    try:
        conn = get_db_connection()
    except Exception:
        logger.error("Arrêt du script : impossible de se connecter à la base.")
        return

    try:
        lieux = fetch_lieux(conn)
        if not lieux:
            logger.warning("Aucun lieu trouvé dans la table `lieux`.")
            return

        logger.info("Nombre de lieux à traiter : %d", len(lieux))

        session = requests.Session()
        datos_url = get_datos_url(session)
        logger.debug("URL DatosMSZona.aspx : %s", datos_url)

        for lieu in lieux:
            id_lieu = lieu["id_lieu"]
            code_lieu = lieu["code_lieu"]
            nom_lieu = lieu["nom_lieu"]

            logger.info("Traitement du lieu %s (%s)", code_lieu, nom_lieu)

            try:
                soup_zone = get_zone_page(session, datos_url, code_lieu)
            except Exception as e:
                logger.error(
                    "Erreur HTTP/ASP.NET pour le lieu %s (%s) : %s",
                    code_lieu, nom_lieu, e
                )
                continue

            # Parsing brut
            raw_rows = parse_grid_for_site(soup_zone, id_lieu, code_lieu, nom_lieu)

            # Fusion par (id_lieu, date, profondeur)
            merged = {}
            for row in raw_rows:
                key = (row["id_lieu"], row["date_analyse"], row["profondeur"])
                if key in merged:
                    merged[key] = merge_rows(merged[key], row)
                else:
                    merged[key] = row

            final_rows = list(merged.values())

            logger.info(
                "Lieu %s (%s) : %d lignes brutes, %d lignes fusionnées",
                code_lieu, nom_lieu, len(raw_rows), len(final_rows)
            )

            # Sauvegarde en base
            try:
                count = save_analyses(conn, final_rows)
                logger.info(
                    "Lieu %s (%s) : %d lignes insérées/mises à jour",
                    code_lieu, nom_lieu, count
                )
            except Exception as e:
                logger.error(
                    "Erreur lors de l'insertion en base pour le lieu %s (%s) : %s",
                    code_lieu, nom_lieu, e
                )
                logger.debug("Traceback : %s", traceback.format_exc())

        session.close()

    finally:
        try:
            conn.close()
        except Exception:
            pass

        end_time = datetime.now()
        duration = (end_time - start_time).total_seconds()
        logger.info(
            "Fin du script analyses_scraper - début=%s fin=%s durée=%.2f s",
            start_time.strftime("%Y-%m-%d %H:%M:%S"),
            end_time.strftime("%Y-%m-%d %H:%M:%S"),
            duration,
        )


if __name__ == "__main__":
    main()