"""
tests/test_tools.py
===================
Tests unitaires pour les 7 outils de l'agent immobilier.

Outils testés :
  - analyze_property       (tools/analyze_property.py)
  - calculate_profitability (tools/calculate_profitability.py)
  - compare_with_market    (tools/compare_with_market.py)
  - investment_score       (tools/investment_score.py)
  - generate_report        (tools/generate_report.py)
  - query_db               (tools/query_db.py)
  - get_loyer_data         (tools/get_loyer_data.py)

Couverture par outil :
  - Cas nominaux   : fonctionnement attendu sur des données valides
  - Cas limites    : valeurs aux bornes (seuils, zéros, très grandes valeurs)
  - Cas d'erreur   : champs manquants, types invalides, JSON malformé, SQL dangereux

Exécution :
  pytest tests/test_tools.py -v
"""

import json
import sqlite3
import sys
import os
from unittest.mock import patch

import pytest

# Ajoute la racine du projet au sys.path pour permettre les imports
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))

from domain.tools.analyze_property import analyze_property
from domain.tools.calculate_profitability import calculate_profitability
from domain.tools.compare_with_market import compare_with_market
from domain.tools.investment_score import investment_score
from domain.tools.generate_report import generate_report
from domain.tools.query_db import query_db
from domain.tools.get_loyer_data import get_loyer_data


# ══════════════════════════════════════════════════════════════════════════════
# FIXTURES
# ══════════════════════════════════════════════════════════════════════════════

@pytest.fixture
def db_temp(tmp_path):
    """
    Crée une base SQLite temporaire avec des données de test contrôlées.

    Tables créées :
      - mutations (3 transactions DVF fictives)
      - loyers    (5 segments de loyers observés fictifs)

    Utilisée par les tests de query_db et get_loyer_data via
    unittest.mock.patch sur le DB_PATH local de chaque module.
    """
    db_path = str(tmp_path / "test_immobilier.db")
    conn = sqlite3.connect(db_path)
    cur = conn.cursor()

    # ── Table mutations ───────────────────────────────────────────────────────
    cur.execute("""
        CREATE TABLE mutations (
            id                  INTEGER PRIMARY KEY AUTOINCREMENT,
            date_mutation       TEXT,
            valeur_fonciere     REAL,
            no_voie             TEXT,
            btq                 TEXT,
            type_voie           TEXT,
            voie                TEXT,
            code_postal         TEXT,
            commune             TEXT,
            code_departement    TEXT,
            type_local          TEXT,
            surface_reelle_bati INTEGER
        )
    """)
    cur.executemany(
        """INSERT INTO mutations
           (date_mutation, valeur_fonciere, no_voie, btq, type_voie, voie,
            code_postal, commune, code_departement, type_local, surface_reelle_bati)
           VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
        [
            # 2 Appartements à Toulouse
            ("01/01/2025", 200_000, "1", None, "RUE", "DE LA PAIX",
             "31000", "TOULOUSE", "31", "Appartement", 50),
            ("02/01/2025", 320_000, "2", None, "AV", "DU MIDI",
             "31000", "TOULOUSE", "31", "Maison", 80),
            # 1 Appartement à Tournefeuille
            ("03/01/2025", 100_000, "3", None, "RUE", "DES FLEURS",
             "31170", "TOURNEFEUILLE", "31", "Appartement", 40),
        ],
    )

    # ── Table loyers ──────────────────────────────────────────────────────────
    cur.execute("""
        CREATE TABLE loyers (
            id                   INTEGER PRIMARY KEY AUTOINCREMENT,
            annee                INTEGER,
            agglomeration        TEXT,
            zone                 TEXT,
            type_habitat         TEXT,
            epoque_construction  TEXT,
            anciennete_locataire TEXT,
            nombre_pieces        TEXT,
            loyer_m2_moyen       REAL,
            loyer_m2_median      REAL,
            loyer_m2_q1          REAL,
            loyer_m2_q3          REAL,
            loyer_mensuel_moyen  REAL,
            loyer_mensuel_median REAL,
            loyer_mensuel_q1     REAL,
            loyer_mensuel_q3     REAL,
            surface_moyenne      REAL,
            nombre_observations  INTEGER,
            nombre_logements     INTEGER
        )
    """)
    cur.executemany(
        """INSERT INTO loyers
           (annee, agglomeration, zone, type_habitat, epoque_construction,
            anciennete_locataire, nombre_pieces,
            loyer_m2_moyen, loyer_m2_median, loyer_m2_q1, loyer_m2_q3,
            loyer_mensuel_moyen, loyer_mensuel_median, loyer_mensuel_q1, loyer_mensuel_q3,
            surface_moyenne, nombre_observations, nombre_logements)
           VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
        [
            # Agrégat global (tous filtres NULL)
            (2025, "Agglomération de Toulouse",
             None, None, None, None, None,
             11.7, 11.9, 10.5, 14.0, 684.0, 625.0, 520.0, 776.0, 59.0, 39064, 172235),
            # Ville centre uniquement
            (2025, "Agglomération de Toulouse",
             "Ville centre", None, None, None, None,
             12.5, 12.3, 11.0, 14.0, 700.0, 680.0, 560.0, 800.0, 56.0, 20000, 90000),
            # Périphérie uniquement
            (2025, "Agglomération de Toulouse",
             "Périphérie", None, None, None, None,
             10.5, 10.2, 9.0, 12.0, 620.0, 600.0, 500.0, 720.0, 60.0, 19064, 82235),
            # Appart 2P global (zone NULL)
            (2025, "Agglomération de Toulouse",
             None, None, None, None, "Appart 2P",
             12.9, 12.8, 11.7, 14.2, 557.0, 545.0, 502.0, 597.0, 43.0, 16004, 57362),
            # Ville centre + Appart 2P
            (2025, "Agglomération de Toulouse",
             "Ville centre", None, None, None, "Appart 2P",
             13.3, 13.2, 11.9, 14.8, 569.0, 556.0, 510.0, 610.0, 43.0, 9886, 39133),
        ],
    )

    conn.commit()
    conn.close()
    return db_path


# Données d'analyse complètes réutilisées dans les tests generate_report
DONNEES_RAPPORT_COMPLET = {
    "bien": {
        "ville": "Toulouse",
        "type_bien": "Appartement",
        "surface_m2": 50,
        "prix_total": 200_000,
        "prix_m2": 4_000,
    },
    "analyse_marche": {
        "prix_m2_moyen": 4_200,
        "position_marche": "sous-évalué",
        "loyer_m2_moyen": 13.0,
        "demande_locative": "forte",
        "tendance_marche": "hausse modérée",
    },
    "indicateurs_financiers": {
        "loyer_mensuel_estime": 650,
        "loyer_annuel": 7_800,
        "charges_annuelles": 1_500,
        "rentabilite_brute": 3.9,
        "rentabilite_nette": 3.15,
    },
    "score_investissement": {
        "score": 6.5,
        "niveau": "bon",
        "details": {
            "rentabilite": 4.5,
            "prix_marche": 9,
            "demande_locative": 8,
            "risque": 6,
        },
    },
    "conclusion": "Bien positionné sous le marché.",
    "recommandation": "Investissement recommandé.",
}


# ══════════════════════════════════════════════════════════════════════════════
# 1. ANALYZE_PROPERTY
# ══════════════════════════════════════════════════════════════════════════════

class TestAnalyzeProperty:
    """Tests de tools/analyze_property.py — calcul du prix au m²."""

    # ── Cas nominaux ──────────────────────────────────────────────────────────

    def test_calcul_prix_m2_basique(self):
        """Vérifie le calcul fondamental : prix / surface = prix_m2."""
        result = analyze_property('{"prix": 200000, "surface": 50}')
        assert result["prix_m2"] == 4_000.0

    def test_resultat_contient_tous_les_champs(self):
        """Le retour doit inclure les 5 champs définis dans la docstring."""
        result = analyze_property(
            '{"prix": 200000, "surface": 50, "ville": "Toulouse", "type_bien": "Appartement"}'
        )
        assert result["ville"]      == "Toulouse"
        assert result["type_bien"]  == "Appartement"
        assert result["surface_m2"] == 50
        assert result["prix_total"] == 200_000
        assert result["prix_m2"]    == 4_000.0

    def test_arrondi_deux_decimales(self):
        """Le prix/m² doit être arrondi à 2 décimales (ex: 150000/7 = 21428.57)."""
        result = analyze_property('{"prix": 150000, "surface": 7}')
        assert result["prix_m2"] == round(150_000 / 7, 2)

    def test_valeurs_par_defaut_ville_type(self):
        """Sans ville ni type_bien, les valeurs par défaut sont 'inconnue'/'inconnu'."""
        result = analyze_property('{"prix": 100000, "surface": 30}')
        assert result["ville"]     == "inconnue"
        assert result["type_bien"] == "inconnu"

    # ── Cas limites ───────────────────────────────────────────────────────────

    def test_surface_un_m2(self):
        """Surface minimale valide : 1 m²."""
        result = analyze_property('{"prix": 50000, "surface": 1}')
        assert result["prix_m2"] == 50_000.0

    def test_prix_tres_eleve(self):
        """Prix très élevé (>1M€) : doit fonctionner sans erreur."""
        result = analyze_property('{"prix": 5000000, "surface": 200}')
        assert result["prix_m2"] == 25_000.0
        assert "erreur" not in result

    # ── Cas d'erreur ─────────────────────────────────────────────────────────

    def test_erreur_prix_manquant(self):
        """Sans prix, le tool doit retourner une erreur."""
        result = analyze_property('{"surface": 50}')
        assert "erreur" in result

    def test_erreur_surface_manquante(self):
        """Sans surface, le tool doit retourner une erreur."""
        result = analyze_property('{"prix": 200000}')
        assert "erreur" in result

    def test_erreur_surface_zero(self):
        """Une surface de 0 est invalide (division par zéro)."""
        result = analyze_property('{"prix": 200000, "surface": 0}')
        assert "erreur" in result

    def test_erreur_surface_negative(self):
        """Une surface négative est invalide."""
        result = analyze_property('{"prix": 200000, "surface": -10}')
        assert "erreur" in result

    def test_erreur_parametre_non_json(self):
        """Un paramètre non parseable en JSON doit retourner une erreur."""
        result = analyze_property("je veux analyser un appartement")
        assert "erreur" in result

    def test_erreur_parametre_vide(self):
        """Un paramètre vide doit retourner une erreur."""
        result = analyze_property("")
        assert "erreur" in result


# ══════════════════════════════════════════════════════════════════════════════
# 2. CALCULATE_PROFITABILITY
# ══════════════════════════════════════════════════════════════════════════════

class TestCalculateProfitability:
    """Tests de tools/calculate_profitability.py — rentabilité brute et nette."""

    # ── Cas nominaux ──────────────────────────────────────────────────────────

    def test_rentabilite_brute_sans_charges(self):
        """Sans charges, brute et nette sont identiques.
        Formule : (loyer_mensuel * 12 / prix) * 100
        Ex : (800*12 / 200000) * 100 = 4.8 %
        """
        result = calculate_profitability('{"prix": 200000, "loyer_mensuel": 800}')
        assert result["rentabilite_brute_pct"] == 4.8
        assert result["rentabilite_nette_pct"] == 4.8

    def test_loyer_annuel_est_mensuel_fois_12(self):
        """Le loyer annuel doit toujours être loyer_mensuel × 12."""
        result = calculate_profitability('{"prix": 200000, "loyer_mensuel": 650}')
        assert result["loyer_annuel"] == 650 * 12

    def test_rentabilite_nette_avec_toutes_charges(self):
        """Avec charges, taxe et frais, la nette < brute.
        Prix=200000, loyer=800 → annuel=9600
        Charges=1200, taxe=800, frais=600 → revenu_net=7000
        Nette = (7000/200000)*100 = 3.5 %
        """
        result = calculate_profitability(json.dumps({
            "prix": 200_000,
            "loyer_mensuel": 800,
            "charges_annuelles": 1_200,
            "taxe_fonciere": 800,
            "frais_gestion": 600,
        }))
        assert result["rentabilite_brute_pct"] == 4.8
        assert result["rentabilite_nette_pct"] == 3.5
        assert result["revenu_net_annuel"] == 7_000.0

    def test_rentabilite_elevee(self):
        """Loyer élevé par rapport au prix → rentabilité brute > 10 %."""
        result = calculate_profitability('{"prix": 100000, "loyer_mensuel": 2000}')
        assert result["rentabilite_brute_pct"] == 24.0

    # ── Cas limites ───────────────────────────────────────────────────────────

    def test_charges_superieures_au_loyer_nette_negative(self):
        """Si les charges dépassent les loyers, la rentabilité nette est négative."""
        result = calculate_profitability(json.dumps({
            "prix": 100_000,
            "loyer_mensuel": 300,
            "charges_annuelles": 5_000,
        }))
        # loyer_annuel=3600, revenu_net=3600-5000=-1400, nette=(-1400/100000)*100=-1.4
        assert result["rentabilite_nette_pct"] < 0

    def test_charges_optionnelles_par_defaut_zero(self):
        """Les charges, taxe et frais sont optionnels et valent 0 par défaut."""
        result = calculate_profitability('{"prix": 100000, "loyer_mensuel": 500}')
        assert result["charges_annuelles"]  == 0
        assert result["taxe_fonciere"]      == 0
        assert result["frais_gestion"]      == 0

    # ── Cas d'erreur ─────────────────────────────────────────────────────────

    def test_erreur_prix_manquant(self):
        """Sans prix, retour d'une erreur."""
        result = calculate_profitability('{"loyer_mensuel": 800}')
        assert "erreur" in result

    def test_erreur_loyer_manquant(self):
        """Sans loyer_mensuel, retour d'une erreur."""
        result = calculate_profitability('{"prix": 200000}')
        assert "erreur" in result

    def test_erreur_prix_zero(self):
        """Un prix de 0 est invalide (division par zéro)."""
        result = calculate_profitability('{"prix": 0, "loyer_mensuel": 800}')
        assert "erreur" in result

    def test_erreur_parametre_non_json(self):
        """Un paramètre non parseable doit retourner une erreur."""
        result = calculate_profitability("pas du json")
        assert "erreur" in result


# ══════════════════════════════════════════════════════════════════════════════
# 3. COMPARE_WITH_MARKET
# ══════════════════════════════════════════════════════════════════════════════

class TestCompareWithMarket:
    """Tests de tools/compare_with_market.py — positionnement par rapport au marché.

    Règles de statut :
      écart_pct < -5 %  → "sous-évalué"
      écart_pct > +5 %  → "surévalué"
      sinon             → "prix marché"
    """

    # ── Cas nominaux ──────────────────────────────────────────────────────────

    def test_statut_sous_evalue(self):
        """Un bien 25 % moins cher que le marché est sous-évalué.
        prix_bien=3000, prix_marché=4000 → écart=-25.0 %
        """
        result = compare_with_market('{"prix_m2_bien": 3000, "prix_m2_marche": 4000}')
        assert result["statut"]    == "sous-évalué"
        assert result["ecart_pct"] == -25.0

    def test_statut_sureevalue(self):
        """Un bien 25 % plus cher que le marché est surévalué.
        prix_bien=5000, prix_marché=4000 → écart=+25.0 %
        """
        result = compare_with_market('{"prix_m2_bien": 5000, "prix_m2_marche": 4000}')
        assert result["statut"]    == "surévalué"
        assert result["ecart_pct"] == 25.0

    def test_statut_prix_marche(self):
        """Un bien à 2 % du marché est au prix du marché."""
        result = compare_with_market('{"prix_m2_bien": 4080, "prix_m2_marche": 4000}')
        assert result["statut"] == "prix marché"

    def test_calcul_ecart_euros(self):
        """L'écart en euros est bien prix_bien - prix_marché."""
        result = compare_with_market('{"prix_m2_bien": 3800, "prix_m2_marche": 4000}')
        assert result["ecart_euros"] == -200.0

    # ── Cas limites (seuils à ±5 %) ──────────────────────────────────────────

    def test_seuil_exactement_moins_5pct_est_prix_marche(self):
        """À exactement -5 %, le statut est 'prix marché' (seuil strict < -5)."""
        result = compare_with_market('{"prix_m2_bien": 3800, "prix_m2_marche": 4000}')
        # (3800-4000)/4000 * 100 = -5.0
        assert result["ecart_pct"] == -5.0
        assert result["statut"]    == "prix marché"

    def test_seuil_exactement_plus_5pct_est_prix_marche(self):
        """À exactement +5 %, le statut est 'prix marché' (seuil strict > +5)."""
        result = compare_with_market('{"prix_m2_bien": 4200, "prix_m2_marche": 4000}')
        # (4200-4000)/4000 * 100 = +5.0
        assert result["ecart_pct"] == 5.0
        assert result["statut"]    == "prix marché"

    def test_juste_sous_moins_5pct_est_sous_evalue(self):
        """Juste en dessous de -5 % → sous-évalué."""
        result = compare_with_market('{"prix_m2_bien": 2000, "prix_m2_marche": 4000}')
        assert result["statut"] == "sous-évalué"

    def test_juste_au_dessus_plus_5pct_est_sureevalue(self):
        """Juste au dessus de +5 % → surévalué."""
        result = compare_with_market('{"prix_m2_bien": 4600, "prix_m2_marche": 4000}')
        assert result["statut"] == "surévalué"

    # ── Cas d'erreur ─────────────────────────────────────────────────────────

    def test_erreur_prix_bien_manquant(self):
        result = compare_with_market('{"prix_m2_marche": 4000}')
        assert "erreur" in result

    def test_erreur_prix_marche_manquant(self):
        result = compare_with_market('{"prix_m2_bien": 4000}')
        assert "erreur" in result

    def test_erreur_prix_marche_zero(self):
        """Un prix marché de 0 est invalide (division par zéro)."""
        result = compare_with_market('{"prix_m2_bien": 4000, "prix_m2_marche": 0}')
        assert "erreur" in result

    def test_erreur_parametre_non_json(self):
        result = compare_with_market("texte invalide")
        assert "erreur" in result


# ══════════════════════════════════════════════════════════════════════════════
# 4. INVESTMENT_SCORE
# ══════════════════════════════════════════════════════════════════════════════

class TestInvestmentScore:
    """Tests de tools/investment_score.py — score pondéré 0–10.

    Pondération :
      rentabilité  40 %  (score_renta)
      prix marché  30 %  (score_marche)
      demande      20 %  (score_demande)
      risque       10 %  (score_risque)

    Seuils de rentabilité → score_renta :
      >= 7 % → 10.0
      >= 5 % → 6 + (r - 5) * 2
      >= 3 % → 3 + (r - 3) * 1.5
      < 3 %  → max(0, r)

    Niveaux :
      >= 8 → excellent | >= 6 → bon | >= 4 → correct | >= 2 → faible | < 2 → mauvais
    """

    # ── Score par niveau ──────────────────────────────────────────────────────

    def test_niveau_excellent(self):
        """Toutes valeurs optimales → score >= 8, niveau 'excellent'.
        r=8→score_renta=10, sous-évalué→9, très forte→10, faible→9
        score = 10*0.4 + 9*0.3 + 10*0.2 + 9*0.1 = 9.6
        """
        result = investment_score(json.dumps({
            "rentabilite_brute": 8,
            "statut_marche": "sous-évalué",
            "demande_locative": "très forte",
            "risque": "faible",
        }))
        assert result["score"] >= 8
        assert result["niveau"] == "excellent"

    def test_niveau_bon(self):
        """Valeurs moyennes-hautes → niveau 'bon' (6 <= score < 8).
        r=5→score_renta=6, prix marché→6, forte→8, moyen→6
        score = 6*0.4 + 6*0.3 + 8*0.2 + 6*0.1 = 6.4
        """
        result = investment_score(json.dumps({
            "rentabilite_brute": 5,
            "statut_marche": "prix marché",
            "demande_locative": "forte",
            "risque": "moyen",
        }))
        assert 6 <= result["score"] < 8
        assert result["niveau"] == "bon"

    def test_niveau_correct(self):
        """Valeurs moyennes → niveau 'correct' (4 <= score < 6).
        r=3→score_renta=3, prix marché→6, moyenne→5, moyen→6
        score = 3*0.4 + 6*0.3 + 5*0.2 + 6*0.1 = 4.6
        """
        result = investment_score(json.dumps({
            "rentabilite_brute": 3,
            "statut_marche": "prix marché",
            "demande_locative": "moyenne",
            "risque": "moyen",
        }))
        assert 4 <= result["score"] < 6
        assert result["niveau"] == "correct"

    def test_niveau_faible(self):
        """Valeurs basses → niveau 'faible' (2 <= score < 4).
        r=0→score_renta=0, prix marché→6, faible→2, moyen→6
        score = 0*0.4 + 6*0.3 + 2*0.2 + 6*0.1 = 2.8
        """
        result = investment_score(json.dumps({
            "rentabilite_brute": 0,
            "statut_marche": "prix marché",
            "demande_locative": "faible",
            "risque": "moyen",
        }))
        assert 2 <= result["score"] < 4
        assert result["niveau"] == "faible"

    def test_niveau_mauvais(self):
        """Toutes valeurs minimales → score < 2, niveau 'mauvais'.
        r=0→0, surévalué→2, faible→2, élevé→2
        score = 0*0.4 + 2*0.3 + 2*0.2 + 2*0.1 = 1.2
        """
        result = investment_score(json.dumps({
            "rentabilite_brute": 0,
            "statut_marche": "surévalué",
            "demande_locative": "faible",
            "risque": "élevé",
        }))
        assert result["score"] < 2
        assert result["niveau"] == "mauvais"

    # ── Seuils de calcul score_renta ─────────────────────────────────────────

    def test_seuil_renta_7_et_plus_donne_10(self):
        """Rentabilité >= 7 % → score_renta = 10.0."""
        result = investment_score(json.dumps({
            "rentabilite_brute": 7,
            "statut_marche": "prix marché",
            "demande_locative": "moyenne",
            "risque": "moyen",
        }))
        assert result["details"]["rentabilite"] == 10.0

    def test_seuil_renta_entre_5_et_7(self):
        """Rentabilité = 6 → score_renta = 6 + (6-5)*2 = 8.0."""
        result = investment_score(json.dumps({
            "rentabilite_brute": 6,
            "statut_marche": "prix marché",
            "demande_locative": "moyenne",
            "risque": "moyen",
        }))
        assert result["details"]["rentabilite"] == 8.0

    def test_seuil_renta_entre_3_et_5(self):
        """Rentabilité = 4 → score_renta = 3 + (4-3)*1.5 = 4.5."""
        result = investment_score(json.dumps({
            "rentabilite_brute": 4,
            "statut_marche": "prix marché",
            "demande_locative": "moyenne",
            "risque": "moyen",
        }))
        assert result["details"]["rentabilite"] == 4.5

    def test_seuil_renta_inferieure_3(self):
        """Rentabilité = 2 → score_renta = max(0, 2) = 2.0."""
        result = investment_score(json.dumps({
            "rentabilite_brute": 2,
            "statut_marche": "prix marché",
            "demande_locative": "moyenne",
            "risque": "moyen",
        }))
        assert result["details"]["rentabilite"] == 2.0

    # ── Scores unitaires par critère ─────────────────────────────────────────

    def test_score_marche_sous_evalue(self):
        """Statut 'sous-évalué' → score_marche = 9."""
        result = investment_score(
            '{"rentabilite_brute": 5, "statut_marche": "sous-évalué", '
            '"demande_locative": "moyenne", "risque": "moyen"}'
        )
        assert result["details"]["prix_marche"] == 9

    def test_score_marche_prix_marche(self):
        """Statut 'prix marché' → score_marche = 6."""
        result = investment_score(
            '{"rentabilite_brute": 5, "statut_marche": "prix marché", '
            '"demande_locative": "moyenne", "risque": "moyen"}'
        )
        assert result["details"]["prix_marche"] == 6

    def test_score_marche_sureevalue(self):
        """Statut 'surévalué' → score_marche = 2."""
        result = investment_score(
            '{"rentabilite_brute": 5, "statut_marche": "surévalué", '
            '"demande_locative": "moyenne", "risque": "moyen"}'
        )
        assert result["details"]["prix_marche"] == 2

    # ── Valeurs par défaut ────────────────────────────────────────────────────

    def test_valeurs_par_defaut_parametres_manquants(self):
        """Sans paramètres, l'outil utilise des valeurs par défaut sans lever d'exception.
        Défauts : rentabilite=0, prix marché, moyenne, moyen.
        """
        result = investment_score("{}")
        assert "score"  in result
        assert "niveau" in result
        assert "erreur" not in result

    def test_ponderation_score_final(self):
        """Vérifie le calcul pondéré exact du score final.
        rentabilite=5→score_renta=6, sous-évalué→9, forte→8, faible→9
        score = 6*0.40 + 9*0.30 + 8*0.20 + 9*0.10 = 2.4+2.7+1.6+0.9 = 7.6
        """
        result = investment_score(json.dumps({
            "rentabilite_brute": 5,
            "statut_marche": "sous-évalué",
            "demande_locative": "forte",
            "risque": "faible",
        }))
        assert result["score"] == round(6 * 0.40 + 9 * 0.30 + 8 * 0.20 + 9 * 0.10, 1)


# ══════════════════════════════════════════════════════════════════════════════
# 5. GENERATE_REPORT
# ══════════════════════════════════════════════════════════════════════════════

class TestGenerateReport:
    """Tests de tools/generate_report.py — génération du rapport Markdown."""

    # ── Cas nominaux ──────────────────────────────────────────────────────────

    def test_format_retour(self):
        """Le retour doit être un dict avec les clés 'format' et 'rapport'."""
        result = generate_report(json.dumps(DONNEES_RAPPORT_COMPLET))
        assert result.get("format")  == "markdown"
        assert isinstance(result.get("rapport"), str)

    def test_sections_markdown_presentes(self):
        """Les 5 sections du rapport doivent être présentes dans le Markdown."""
        result = generate_report(json.dumps(DONNEES_RAPPORT_COMPLET))
        rapport = result["rapport"]
        assert "## Property Summary"       in rapport
        assert "## Market Analysis"        in rapport
        assert "## Financial Indicators"   in rapport
        assert "## Investment Score"       in rapport
        assert "## Conclusion"             in rapport
        assert "## Recommandation"         in rapport

    def test_valeurs_bien_dans_rapport(self):
        """Les valeurs du bien doivent apparaître dans le rapport."""
        result = generate_report(json.dumps(DONNEES_RAPPORT_COMPLET))
        rapport = result["rapport"]
        assert "Toulouse"    in rapport
        assert "Appartement" in rapport
        assert "200000"      in rapport

    def test_score_dans_rapport(self):
        """Le score d'investissement doit apparaître dans le header de la section."""
        result = generate_report(json.dumps(DONNEES_RAPPORT_COMPLET))
        assert "6.5" in result["rapport"]

    # ── Cas limites ───────────────────────────────────────────────────────────

    def test_valeurs_none_affichees_na(self):
        """Les champs absents (None) doivent s'afficher 'N/A' et non provoquer d'erreur."""
        donnees_incompletes = {
            "bien": {"ville": "Toulouse", "type_bien": None, "surface_m2": None,
                     "prix_total": None, "prix_m2": None},
            "analyse_marche": {},
            "indicateurs_financiers": {},
            "score_investissement": {"score": None, "niveau": None, "details": {}},
            "conclusion": "N/A",
            "recommandation": "N/A",
        }
        result = generate_report(json.dumps(donnees_incompletes))
        assert result.get("format") == "markdown"
        assert "N/A" in result["rapport"]

    # ── Cas d'erreur ─────────────────────────────────────────────────────────

    def test_erreur_parametre_vide(self):
        """Un paramètre vide doit retourner une erreur."""
        result = generate_report("")
        assert "erreur" in result

    def test_erreur_parametre_non_json(self):
        """Un paramètre non parseable en JSON doit retourner une erreur."""
        result = generate_report("texte invalide")
        assert "erreur" in result


# ══════════════════════════════════════════════════════════════════════════════
# 6. QUERY_DB
# ══════════════════════════════════════════════════════════════════════════════

class TestQueryDb:
    """Tests de tools/query_db.py — requêtes SQL sur la base DVF.

    Utilise la fixture db_temp (base SQLite temporaire patchée via DB_PATH).
    """

    # ── Cas nominaux ──────────────────────────────────────────────────────────

    def test_select_count_retourne_toutes_lignes(self, db_temp):
        """Un SELECT COUNT(*) doit retourner le nombre de mutations insérées."""
        with patch("domain.tools.query_db.DB_PATH", db_temp):
            result = query_db("SELECT COUNT(*) AS n FROM mutations")
        assert result[0]["n"] == 3

    def test_select_filtre_commune_toulouse(self, db_temp):
        """Filtrer sur commune='TOULOUSE' doit retourner 2 lignes (fixture)."""
        with patch("domain.tools.query_db.DB_PATH", db_temp):
            result = query_db("SELECT * FROM mutations WHERE commune = 'TOULOUSE'")
        assert len(result) == 2

    def test_select_retourne_liste_de_dicts(self, db_temp):
        """Le résultat doit être une liste de dictionnaires avec noms de colonnes."""
        with patch("domain.tools.query_db.DB_PATH", db_temp):
            result = query_db("SELECT commune, valeur_fonciere FROM mutations")
        assert isinstance(result, list)
        assert "commune"         in result[0]
        assert "valeur_fonciere" in result[0]

    def test_select_vide_retourne_liste_vide(self, db_temp):
        """Un SELECT qui ne matche rien doit retourner une liste vide."""
        with patch("domain.tools.query_db.DB_PATH", db_temp):
            result = query_db("SELECT * FROM mutations WHERE commune = 'INEXISTANT'")
        assert result == []

    def test_avg_prix_m2(self, db_temp):
        """Calcul du prix moyen au m² sur Toulouse.
        Fixtures : 200000/50=4000, 320000/80=4000 → AVG=4000.0
        """
        with patch("domain.tools.query_db.DB_PATH", db_temp):
            result = query_db(
                "SELECT ROUND(AVG(valeur_fonciere / surface_reelle_bati), 2) AS prix_m2_moyen "
                "FROM mutations WHERE commune = 'TOULOUSE' AND surface_reelle_bati > 0"
            )
        assert result[0]["prix_m2_moyen"] == 4_000.0

    # ── Blocage des requêtes d'écriture ──────────────────────────────────────

    def test_delete_bloque(self, db_temp):
        """Une requête DELETE doit être bloquée et retourner une erreur."""
        with patch("domain.tools.query_db.DB_PATH", db_temp):
            result = query_db("DELETE FROM mutations")
        assert "erreur" in result

    def test_insert_bloque(self, db_temp):
        """Une requête INSERT doit être bloquée."""
        with patch("domain.tools.query_db.DB_PATH", db_temp):
            result = query_db("INSERT INTO mutations (commune) VALUES ('TEST')")
        assert "erreur" in result

    def test_drop_bloque(self, db_temp):
        """Une requête DROP TABLE doit être bloquée."""
        with patch("domain.tools.query_db.DB_PATH", db_temp):
            result = query_db("DROP TABLE mutations")
        assert "erreur" in result

    # ── Injection SQL ─────────────────────────────────────────────────────────

    def test_injection_union_select_bloque(self, db_temp):
        """Une requête UNION SELECT (injection SQL) doit être bloquée par security.py."""
        with patch("domain.tools.query_db.DB_PATH", db_temp):
            result = query_db(
                "SELECT commune FROM mutations UNION SELECT api_key FROM config"
            )
        assert "erreur" in result

    def test_injection_stacked_query_bloque(self, db_temp):
        """Un stacked query (';') doit être bloqué."""
        with patch("domain.tools.query_db.DB_PATH", db_temp):
            result = query_db("SELECT 1; DROP TABLE mutations")
        assert "erreur" in result

    # ── Cas d'erreur ─────────────────────────────────────────────────────────

    def test_sql_invalide_retourne_erreur(self, db_temp):
        """Une syntaxe SQL invalide doit retourner un dict avec 'erreur'."""
        with patch("domain.tools.query_db.DB_PATH", db_temp):
            result = query_db("SELEC * FORM mutations")
        assert "erreur" in result


# ══════════════════════════════════════════════════════════════════════════════
# 7. GET_LOYER_DATA
# ══════════════════════════════════════════════════════════════════════════════

class TestGetLoyerData:
    """Tests de tools/get_loyer_data.py — données de loyers observés.

    Utilise la fixture db_temp patchée via tools.get_loyer_data.DB_PATH.
    """

    # ── Cas nominaux ──────────────────────────────────────────────────────────

    def test_sans_filtre_retourne_agregat_global(self, db_temp):
        """Sans paramètre, retourne la ligne agrégée globale (tous NULL).
        Fixture : loyer_m2_moyen = 11.7
        """
        with patch("domain.tools.get_loyer_data.DB_PATH", db_temp):
            result = get_loyer_data("{}")
        assert result.get("loyer_m2_moyen") == 11.7

    def test_filtre_zone_ville_centre(self, db_temp):
        """Filtrer sur 'Ville centre' retourne les données centre.
        Fixture : loyer_m2_moyen = 12.5
        """
        with patch("domain.tools.get_loyer_data.DB_PATH", db_temp):
            result = get_loyer_data('{"zone": "Ville centre"}')
        assert result.get("loyer_m2_moyen") == 12.5

    def test_filtre_zone_peripherie(self, db_temp):
        """Filtrer sur 'Périphérie' retourne les données périphérie.
        Fixture : loyer_m2_moyen = 10.5
        """
        with patch("domain.tools.get_loyer_data.DB_PATH", db_temp):
            result = get_loyer_data('{"zone": "Périphérie"}')
        assert result.get("loyer_m2_moyen") == 10.5

    def test_filtre_nombre_pieces(self, db_temp):
        """Filtrer sur nombre_pieces='Appart 2P' retourne les données 2P.
        Fixture : loyer_m2_moyen = 12.9
        """
        with patch("domain.tools.get_loyer_data.DB_PATH", db_temp):
            result = get_loyer_data('{"nombre_pieces": "Appart 2P"}')
        assert result.get("loyer_m2_moyen") == 12.9

    def test_filtre_combine_zone_et_pieces(self, db_temp):
        """Combiner zone + nombre_pieces → ligne spécifique.
        Fixture : Ville centre + Appart 2P → loyer_m2_moyen = 13.3
        """
        with patch("domain.tools.get_loyer_data.DB_PATH", db_temp):
            result = get_loyer_data('{"zone": "Ville centre", "nombre_pieces": "Appart 2P"}')
        assert result.get("loyer_m2_moyen") == 13.3

    def test_source_presente_dans_retour(self, db_temp):
        """La clé 'source' doit toujours être présente dans le retour."""
        with patch("domain.tools.get_loyer_data.DB_PATH", db_temp):
            result = get_loyer_data("{}")
        assert "source" in result
        assert "Observatoire" in result["source"]

    # ── Cas limites ───────────────────────────────────────────────────────────

    def test_parametre_vide_chaine(self, db_temp):
        """Un paramètre chaîne vide est équivalent à {} → agrégat global."""
        with patch("domain.tools.get_loyer_data.DB_PATH", db_temp):
            result = get_loyer_data("")
        assert result.get("loyer_m2_moyen") == 11.7

    def test_parametre_non_json_fallback_global(self, db_temp):
        """Un paramètre non-JSON doit déclencher le fallback (params={} → global)."""
        with patch("domain.tools.get_loyer_data.DB_PATH", db_temp):
            result = get_loyer_data("texte invalide")
        # params={} → tous NULL → agrégat global
        assert result.get("loyer_m2_moyen") == 11.7

    def test_zone_inexistante_retourne_liste_vide(self, db_temp):
        """Une zone qui n'existe pas en base déclenche le fallback loose,
        qui retourne aussi une liste vide → {"source": ..., "resultats": []}.
        Le tool ne lève pas d'erreur mais signale l'absence via une liste vide.
        """
        with patch("domain.tools.get_loyer_data.DB_PATH", db_temp):
            result = get_loyer_data('{"zone": "Zone Inconnue"}')
        # Pas d'erreur levée, mais "resultats" présent et vide
        assert "erreur"    not in result
        assert "resultats" in result
        assert result["resultats"] == []

    def test_retour_une_ligne_est_un_dict_plat(self, db_temp):
        """Quand une seule ligne correspond, le retour est un dict plat (pas de 'resultats')."""
        with patch("domain.tools.get_loyer_data.DB_PATH", db_temp):
            result = get_loyer_data('{"zone": "Ville centre"}')
        assert "resultats" not in result
        assert "loyer_m2_moyen" in result
