"""
tools/analysis.py — Estimation immobilière géographique sans LLM.

Sources :
  - data/{annee}/59.csv  (ventes DVF individuelles 2015-2025, Nord-59)
  - MySQL table prix_evolution — évolution annuelle du marché

Algorithme :
  1. Au démarrage : centroïdes communes via l'API Geo + chargement des ventes DVF
                    + chargement de prix_evolution depuis SQLite
  2. Par requête : géocodage BAN de l'adresse d'entrée
  3. Filtrage par type de bien (Maison / Appartement)
  4. Distance Haversine vers chaque vente (lat/lon DVF si dispo, sinon centroïde commune)
  5. Sélection des 20 ventes les plus proches
  6. Pour chaque vente de l'année Y, le prix/m² est projeté au marché cible (_ANNEE_CIBLE)
     par composition multiplicative des évolutions annuelles de prix_evolution :
       prix_ajusté = prix_m2_vente × ∏(1 + evol_y/100)  pour y = Y+1 … _ANNEE_CIBLE
  7. Pondération = (1/distance) × poids_récence  (2025=32 … 2015=1)
  8. Prix marché = moyenne pondérée → × (coef_renovation × coef_orientation × coef_terrain × coef_surface)

"""

from __future__ import annotations

import csv
import json
import logging
import math
import os
import pickle
import time
import unicodedata
from enum import Enum
from typing import Any
from urllib.parse import quote_plus

import httpx

from domain.db import get_db

logger = logging.getLogger("tools.analysis")

_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))

_ANNEES_DVF   = [2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025]
_ANNEE_CIBLE  = 2025  # marché cible — mettre à jour avec chaque nouveau millésime DVF

_CACHE_DIR            = os.path.join(_ROOT, "data", "cache")
_TRANSACTIONS_CACHE   = os.path.join(_CACHE_DIR, "transactions_59.pkl")
_COMMUNES_CACHE       = os.path.join(_CACHE_DIR, "communes_59.json")
_COMMUNES_TTL_S       = 30 * 86_400  # refresh commune centroids every 30 days

# ---------------------------------------------------------------------------
# Constantes numériques
# ---------------------------------------------------------------------------
RAYON_TERRE_KM  = 6371.0    # rayon moyen de la Terre (formule Haversine)
TIMEOUT_GEO_S   = 15.0      # timeout HTTP pour l'API Geo (centroïdes communes)
TIMEOUT_BAN_S   = 10.0      # timeout HTTP pour l'API BAN (géocodage d'adresse)
GEOCODE_LIMITE  = 1         # nombre max de résultats retournés par l'API BAN
PRIX_M2_MIN     = 500.0     # seuil bas : en-dessous = erreur de saisie DVF
PRIX_M2_MAX     = 15_000.0  # seuil haut : au-dessus = bien atypique ou erreur DVF
MIN_VENTES      = 5         # nombre minimum de ventes pour produire une estimation fiable
TOP_N_VENTES    = 20        # nombre de ventes les plus proches retenues pour la pondération
DIST_EPSILON_KM = 0.001     # epsilon ajouté à la distance pour éviter la division par zéro
CENT            = 100.0     # conversion pourcentage ↔ facteur multiplicatif


class TypeBien(str, Enum):
    maison      = "maison"
    appartement = "appartement"


class Orientation(str, Enum):
    nord       = "nord"
    nord_est   = "nord-est"
    est        = "est"
    sud_est    = "sud-est"
    sud        = "sud"
    sud_ouest  = "sud-ouest"
    ouest      = "ouest"
    nord_ouest = "nord-ouest"


STATUTS_VALIDES = frozenset({
    "terrible", "bad", "light renovation", "medium",
    "renovated", "good", "premium", "premium plus",
})

COEFS_RENOVATION: dict[str, float] = {
    "terrible":         0.50,
    "bad":              0.70,
    "light renovation": 0.85,
    "medium":           0.95,
    "renovated":        1.00,
    "good":             1.10,
    "premium":          1.15,
    "premium plus":     1.30,
}

COEFS_ORIENTATION: dict[str, float] = {
    "sud":       1.10,
    "sud-est":   1.05,
    "sud-ouest": 1.05,
    "est":       1.00,
    "ouest":     1.00,
    "nord-est":  0.95,
    "nord-ouest": 0.95,
    "nord":      0.90,
}

_TYPE_LOCAL: dict[str, str] = {
    "maison":      "Maison",
    "appartement": "Appartement",
}

# Poids de récence : les ventes récentes comptent davantage, les anciennes restent utiles
# pour les IRIS peu liquides. Ratio ~32:1 entre 2025 et 2015 (doublement tous les ~3 ans).
_RECENCY_WEIGHTS: dict[int, int] = {
    2015: 1, 2016: 1, 2017: 2, 2018: 2, 2019: 3,
    2020: 4, 2021: 6, 2022: 9, 2023: 13, 2024: 20, 2025: 32,
}

_transactions:   list[dict[str, Any]] = []
_commune_coords: dict[str, tuple[float, float]] = {}
_prix_evolution: list[dict[str, Any]] = []


# ---------------------------------------------------------------------------
# Utilitaires
# ---------------------------------------------------------------------------

def _normaliser_commune(nom: str) -> str:
    n = unicodedata.normalize("NFD", nom).encode("ascii", "ignore").decode()
    return n.upper().replace("-", " ").replace("'", " ").replace("  ", " ").strip()


def _google_maps_url(adresse: str) -> str:
    return f"https://www.google.com/maps/search/?api=1&query={quote_plus(adresse)}"


def _coef_terrain(surface_terrain: float) -> float:
    if surface_terrain <= 0:
        return 1.00
    if surface_terrain <= 100:
        return 1.01
    if surface_terrain <= 300:
        return 1.03
    if surface_terrain <= 500:
        return 1.05
    if surface_terrain <= 700:
        return 1.08
    if surface_terrain <= 900:
        return 1.10
    if surface_terrain <= 1000:
        return 1.11
    if surface_terrain <= 1200:
        return 1.13
    if surface_terrain <= 1400:
        return 1.14
    if surface_terrain <= 1600:
        return 1.16
    if surface_terrain <= 1800:
        return 1.17
    if surface_terrain <= 2000:
        return 1.18
    if surface_terrain <= 2500:
        return 1.20
    return 1.25


# ---------------------------------------------------------------------------
# Chargement des ventes DVF individuelles (data/{annee}/59.csv)
# ---------------------------------------------------------------------------

def _transactions_cache_valid() -> bool:
    """True if the pickle cache exists and is newer than every source CSV."""
    if not os.path.exists(_TRANSACTIONS_CACHE):
        return False
    cache_mtime = os.path.getmtime(_TRANSACTIONS_CACHE)
    for annee in _ANNEES_DVF:
        path = os.path.join(_ROOT, "data", str(annee), "59.csv")
        if os.path.exists(path) and os.path.getmtime(path) > cache_mtime:
            return False
    return True


def _load_transactions() -> list[dict[str, Any]]:
    """
    Charge toutes les ventes résidentielles DVF des années disponibles.
    Agrège par id_mutation (multi-lots) : surface = somme, type = lot principal.
    Conserve latitude/longitude de la parcelle si présentes dans le CSV.
    Résultat mis en cache dans data/cache/transactions_59.pkl.
    """
    os.makedirs(_CACHE_DIR, exist_ok=True)

    if _transactions_cache_valid():
        logger.info("analysis — cache transactions valide, chargement depuis %s", _TRANSACTIONS_CACHE)
        with open(_TRANSACTIONS_CACHE, "rb") as f:
            rows = pickle.load(f)
        logger.info("analysis — %d ventes chargées depuis le cache", len(rows))
        return rows

    all_rows: list[dict[str, Any]] = []

    for annee in _ANNEES_DVF:
        path = os.path.join(_ROOT, "data", str(annee), "59.csv")
        if not os.path.exists(path):
            logger.warning("DVF introuvable : %s", path)
            continue

        by_mutation: dict[str, dict[str, Any]] = {}

        with open(path, encoding="utf-8", newline="") as f:
            for row in csv.DictReader(f):
                if row.get("nature_mutation", "").strip() != "Vente":
                    continue
                type_local = row.get("type_local", "").strip()
                if type_local not in {"Maison", "Appartement"}:
                    continue

                try:
                    surface = float(row["surface_reelle_bati"] or 0)
                    valeur  = float(row["valeur_fonciere"].replace(",", ".") or 0)
                except (ValueError, KeyError):
                    continue

                if surface <= 0 or valeur <= 0:
                    continue

                mid = row["id_mutation"]
                if mid not in by_mutation:
                    try:
                        lat = float(row.get("latitude") or "")
                        lon = float(row.get("longitude") or "")
                    except (ValueError, TypeError):
                        lat, lon = None, None

                    by_mutation[mid] = {
                        "annee":       annee,
                        "date":        row.get("date_mutation", "").strip(),
                        "commune":     row.get("nom_commune", "").strip(),
                        "code_postal": row.get("code_postal", "").strip(),
                        "voie":        row.get("adresse_nom_voie", "").strip(),
                        "no_voie":     row.get("adresse_numero", "").strip(),
                        "valeur":      valeur,
                        "surface":     0.0,
                        "type_local":  type_local,
                        "max_surface": 0.0,
                        "lat":         lat,
                        "lon":         lon,
                    }

                m = by_mutation[mid]
                m["surface"] += surface
                if surface > m["max_surface"]:
                    m["max_surface"] = surface
                    m["type_local"]  = type_local

        count = 0
        for m in by_mutation.values():
            if m["surface"] > 0:
                m["prix_m2"] = m["valeur"] / m["surface"]
                if not (PRIX_M2_MIN <= m["prix_m2"] <= PRIX_M2_MAX):
                    continue
                all_rows.append(m)
                count += 1

        logger.info("analysis — DVF %d : %d ventes chargées", annee, count)

    logger.info("analysis — total DVF : %d ventes (2015-2025)", len(all_rows))
    logger.info("analysis — sauvegarde du cache transactions → %s", _TRANSACTIONS_CACHE)
    with open(_TRANSACTIONS_CACHE, "wb") as f:
        pickle.dump(all_rows, f)
    return all_rows


# ---------------------------------------------------------------------------
# Chargement de prix_evolution depuis SQLite
# ---------------------------------------------------------------------------

def _charger_prix_evolution() -> list[dict[str, Any]]:
    """Charge la table prix_evolution depuis MySQL."""
    try:
        with get_db() as conn:
            rows = conn.execute(
                "SELECT annee, code_postal, type_local, evolution_m2_pct "
                "FROM prix_evolution ORDER BY code_postal, type_local, annee"
            ).fetchall()
        logger.info("analysis — %d lignes prix_evolution chargées", len(rows))
        return rows
    except Exception as exc:
        logger.error("Chargement prix_evolution échoué : %s", exc)
        return []


# ---------------------------------------------------------------------------
# Pré-chargement des centroïdes de communes via l'API Geo
# ---------------------------------------------------------------------------

async def _charger_communes_geo(dept: str = "59") -> dict[str, tuple[float, float]]:
    os.makedirs(_CACHE_DIR, exist_ok=True)
    cache_path = os.path.join(_CACHE_DIR, f"communes_{dept}.json")

    if os.path.exists(cache_path):
        age_s = time.time() - os.path.getmtime(cache_path)
        if age_s < _COMMUNES_TTL_S:
            logger.info(
                "analysis — cache centroïdes valide (%.0f j restants), chargement depuis %s",
                (_COMMUNES_TTL_S - age_s) / 86_400, cache_path,
            )
            with open(cache_path, encoding="utf-8") as f:
                raw = json.load(f)
            return {k: tuple(v) for k, v in raw.items()}

    async with httpx.AsyncClient(timeout=TIMEOUT_GEO_S) as client:
        try:
            resp = await client.get(
                f"https://geo.api.gouv.fr/departements/{dept}/communes",
                params={"fields": "nom,code,centre", "format": "json"},
            )
            communes = resp.json()
        except httpx.HTTPError as exc:
            logger.error("Chargement communes Geo API échoué : %s", exc)
            if os.path.exists(cache_path):
                logger.warning("Utilisation du cache centroïdes expiré comme fallback")
                with open(cache_path, encoding="utf-8") as f:
                    raw = json.load(f)
                return {k: tuple(v) for k, v in raw.items()}
            return {}

    coords: dict[str, tuple[float, float]] = {}
    for c in communes:
        centre = c.get("centre", {})
        if centre.get("type") == "Point":
            lon, lat = centre["coordinates"]
            coords[_normaliser_commune(c["nom"])] = (float(lat), float(lon))

    with open(cache_path, "w", encoding="utf-8") as f:
        json.dump(coords, f)
    logger.info("analysis — %d centroïdes chargés et mis en cache (dept %s)", len(coords), dept)
    return coords


# ---------------------------------------------------------------------------
# Initialisation au démarrage
# ---------------------------------------------------------------------------

async def init_analysis() -> None:
    global _transactions, _commune_coords, _prix_evolution

    _commune_coords.update(await _charger_communes_geo("59"))
    _prix_evolution.extend(_charger_prix_evolution())
    _transactions.extend(_load_transactions())


# ---------------------------------------------------------------------------
# Distance Haversine
# ---------------------------------------------------------------------------

def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
    R = RAYON_TERRE_KM
    dlat = math.radians(lat2 - lat1)
    dlon = math.radians(lon2 - lon1)
    a = (
        math.sin(dlat / 2) ** 2
        + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2))
        * math.sin(dlon / 2) ** 2
    )
    return R * 2 * math.asin(math.sqrt(a))


# ---------------------------------------------------------------------------
# Géocodage de l'adresse d'entrée via l'API BAN
# ---------------------------------------------------------------------------

async def _geocode_adresse(adresse: str) -> tuple[float, float]:
    async with httpx.AsyncClient(timeout=TIMEOUT_BAN_S) as client:
        try:
            resp = await client.get(
                "https://api-adresse.data.gouv.fr/search/",
                params={"q": adresse, "limit": GEOCODE_LIMITE},
            )
            features = resp.json().get("features", [])
            if features:
                lon, lat = features[0]["geometry"]["coordinates"]
                return float(lat), float(lon)
        except httpx.HTTPError as exc:
            logger.warning("Géocodage BAN échoué pour '%s': %s", adresse, exc)
    raise ValueError(f"Adresse introuvable : {adresse!r}")


def get_surface_coefficient(surface_m2: float) -> float:
    """
    Logarithmic coefficient calculation:
    - High impact for small surfaces (max 1.6)
    - Stable 'plateau' between 60-120m2 (approx 1.1 to 0.9)
    - Minimum floor of 0.7
    """
    # We use log10 to create the curve. 
    # The '90' is our anchor, and '45' controls the steepness/stretch of the curve.
    # A negative sign ensures the coefficient drops as surface increases.
    
    # Formula logic: 1.0 + scale * log10(anchor / surface)
    # We've tuned the scale to 0.75 to hit your 60-120 target.
    if surface_m2 <= 15:
        return 1.6
    raw_coeff = 1.0 + 0.75 * math.log10(90 / surface_m2)
    return round(max(0.7, min(1.6, raw_coeff)), 3)

# ---------------------------------------------------------------------------
# Estimation principale
# ---------------------------------------------------------------------------

async def analyser_bien(
    adresse: str,
    code_postal: int,
    ville: str,
    surface_m2: float,
    statut: str,
    type_bien: str,
    orientation: str,
    taille_terrain: float = 0.0,
) -> dict[str, Any]:
    """
    Estime le prix d'un bien immobilier dans le Nord (59).

    Paramètres
    ----------
    adresse        : numéro et nom de la voie (ex: "7 avenue nelson mandela")
    code_postal    : code postal (ex: 59290)
    ville          : nom de la commune (ex: "wasquehal")
    surface_m2     : surface habitable en m²
    statut         : état du bien, parmi STATUTS_VALIDES
    type_bien      : "maison" ou "appartement"
    orientation    : orientation principale (ex: "sud", "nord-est"…)
    taille_terrain : surface du terrain en m² (0 pour un appartement sans terrain)
    """
    if not _transactions:
        raise RuntimeError("Données DVF indisponibles (data/{annee}/59.csv introuvables).")

    coef_renovation  = COEFS_RENOVATION[statut]
    coef_orientation = COEFS_ORIENTATION[orientation]
    coef_terrain_val = _coef_terrain(taille_terrain)
    coef_total       = round(coef_renovation * coef_orientation * coef_terrain_val, 4)

    # 1. Filtre par type de bien
    type_local_cible = _TYPE_LOCAL[type_bien]
    pool = [t for t in _transactions if t["type_local"] == type_local_cible]
    if len(pool) < MIN_VENTES:
        raise ValueError(f"Données insuffisantes pour '{type_bien}' : {len(pool)} ventes.")

    # 2. Géocode l'adresse d'entrée
    adresse_complete = f"{adresse}, {code_postal:05d} {ville}"
    lat_in, lon_in = await _geocode_adresse(adresse_complete)

    # 3. Distance vers chaque vente
    #    Priorité : lat/lon de la parcelle DVF ; fallback : centroïde de commune
    avec_dist: list[tuple[float, dict[str, Any]]] = []
    for t in pool:
        if t["lat"] is not None and t["lon"] is not None:
            dist = _haversine(lat_in, lon_in, t["lat"], t["lon"])
        else:
            coords = _commune_coords.get(_normaliser_commune(t["commune"]))
            if coords is None:
                continue
            dist = _haversine(lat_in, lon_in, coords[0], coords[1])
        avec_dist.append((dist, t))

    if len(avec_dist) < MIN_VENTES:
        raise ValueError(
            f"Données géolocalisées insuffisantes pour '{type_bien}' : {len(avec_dist)} ventes."
        )

    # 4. Top N ventes les plus proches
    avec_dist.sort(key=lambda x: x[0])
    top_ventes = avec_dist[:TOP_N_VENTES]

    # 5. Index (code_postal, annee) → evolution_m2_pct pour le compoundage
    evol_index: dict[tuple[str, int], float | None] = {
        (r["code_postal"], r["annee"]): r["evolution_m2_pct"]
        for r in _prix_evolution
        if r["type_local"] == type_local_cible
    }

    # 6. Pour chaque vente de l'année Y :
    #    prix_ajusté = prix_m2_vente × ∏(1 + evol_y / 100)  pour y = Y+1 … 2025
    #    poids = (1 / distance) × poids_récence[Y]
    total_w     = 0.0
    prix_m2_num = 0.0
    transactions_ref: list[dict[str, Any]] = []

    for dist, t in top_ventes:
        cp    = t["code_postal"]
        annee = t["annee"]

        compound = 1.0
        for y in range(annee + 1, _ANNEE_CIBLE + 1):
            evol = evol_index.get((cp, y))
            if evol is not None:
                compound *= 1.0 + evol / CENT

        prix_m2_ajuste = t["prix_m2"] * compound
        w = (1.0 / (dist + DIST_EPSILON_KM)) * _RECENCY_WEIGHTS.get(annee, 1)

        prix_m2_num += w * prix_m2_ajuste
        total_w     += w

        voie_parts = [p for p in [t["no_voie"], t["voie"]] if p]
        adresse_tx = f"{' '.join(voie_parts)}, {cp} {t['commune']}".strip(", ")

        transactions_ref.append({
            "commune":          t["commune"],
            "code_postal":      cp,
            "type_local":       t["type_local"],
            "annee":            annee,
            "date":             t["date"],
            "surface_m2":       t["surface"],
            "prix_total":       t["valeur"],
            "prix_m2_vente":    round(t["prix_m2"], 2),
            "evol_marche_pct":  round((compound - 1.0) * CENT, 2),
            "prix_m2_ajuste":   round(prix_m2_ajuste, 2),
            "distance_km":      round(dist, 2),
            "google_maps_url":  _google_maps_url(adresse_tx),
        })

    if total_w == 0.0:
        raise ValueError("Aucune donnée pondérée disponible pour l'estimation.")

    prix_m2_marche    = prix_m2_num / total_w
    prix_m2_estime    = round(prix_m2_marche * coef_total, 2)
    prix_total_estime = round(prix_m2_estime * surface_m2)

    return {
        "adresse":         adresse,
        "code_postal":     code_postal,
        "ville":           ville,
        "surface_m2":      surface_m2,
        "type_bien":       type_bien,
        "statut":          statut,
        "orientation":     orientation,
        "taille_terrain":  taille_terrain,
        "google_maps_url": _google_maps_url(adresse_complete),
        "estimation": {
            "prix_m2_marche":    round(prix_m2_marche, 2),
            "coef_renovation":   coef_renovation,
            "coef_orientation":  coef_orientation,
            "coef_terrain":      coef_terrain_val,
            "coef_total":        coef_total,
            "prix_m2_estime":    prix_m2_estime,
            "prix_total_estime": int(prix_total_estime),
        },
        "transactions_reference": transactions_ref,
    }
