"""Parser Faisabilité → contrat Publiciste (`projets_master.json`).

Livrable Publiciste · Sprint 2 · Semaine 2 (AGENT.md §Livrable Sprint).
Lit une faisabilité canonique `data_room/PXX/` (template v1.0) et produit le dict
`projet` conforme à `projets_master.schema.json`, consommé ensuite par le
générateur HTML.

Règles ANTI-INVENTION (CLAUDE.md #6) appliquées ici — le parser est volontairement
défensif : il n'invente JAMAIS un chiffre absent, et il **rétrograde** un projet
en « en_developpement » plutôt que de publier une donnée douteuse.

  1. Le statut publié dérive de `_META/version.json.statut_faisabilite` :
        complete        → disponible
        en_developpement→ en_developpement
        incomplete      → en_developpement
  2. Un projet n'est « disponible » QUE si version.json le dit complete ET que
     chaque typologie a un prix USD numérique (sinon rétrogradation défensive).
  3. Une cellule vide / « non défini » / placeholder `{{...}}` → None (jamais 0,
     jamais une valeur inventée).

Sources lues (ordre du template canonique v1.0) :
  _META/version.json                         → statut, score, traçabilité
  00_brief/brief.md                          → localisation (§1.1)
  20_architecture/architecture.md            → typologies + prix (§3.2, bloc anti-gap)
  30_paysage_experience/paysage_experience.md→ services inclus (§3.3)
  40_llm_outputs/commercial.md               → positionnement FR (1er paragraphe non-titre)
  60_photos_site/                            → rendus (liés au projet réel)
"""

from __future__ import annotations

import json
import os
import re
from datetime import datetime, timezone
from typing import Any, Optional

# Marqueurs signalant une donnée ABSENTE (jamais inventer — contrainte #6).
_ABSENT = {
    "", "—", "-", "–", "n/d", "nd", "na", "n.a.", "s/o", "so",
    "non défini", "non defini", "non renseigné", "non renseigne",
    "à définir", "a definir", "tbd", "todo", "…", "...",
}


# --------------------------------------------------------------------------- #
# Helpers de bas niveau : nettoyage de cellules Markdown → valeurs typées.
# --------------------------------------------------------------------------- #
def _clean(cell: str) -> str:
    """Retire gras/italique/backticks et espaces d'une cellule Markdown."""
    return cell.replace("**", "").replace("`", "").replace("*", "").strip()


def _is_absent(text: str) -> bool:
    t = _clean(text).lower()
    if not t:
        return True
    if "{{" in t and "}}" in t:  # placeholder de template non rempli
        return True
    return t in _ABSENT


def parse_number(text: str) -> Optional[float]:
    """« USD 250,000 » / « 1 250,50 » / « 3.5 » → float ; absent → None.

    Détecte le séparateur décimal (dernier « . » ou « , » suivi de 1-2 chiffres)
    et traite les autres séparateurs comme des milliers. Ignore tout habillage
    (devises, unités m², %, texte). Retourne None dès qu'aucun chiffre exploitable.
    """
    if _is_absent(text):
        return None
    raw = _clean(text)
    # Isole le premier bloc numérique (chiffres, points, virgules, espaces).
    m = re.search(r"[0-9][0-9\s., ]*", raw)
    if not m:
        return None
    token = m.group(0).strip().replace(" ", " ")
    token = token.rstrip(" .,")

    # Décide du séparateur décimal : le dernier '.' ou ',' suivi de 1-2 chiffres
    # de fin de chaîne est décimal ; le reste = séparateurs de milliers.
    dec = re.search(r"[.,](\d{1,2})$", token)
    if dec:
        int_part = token[: dec.start()]
        int_part = re.sub(r"[^\d]", "", int_part)
        value_str = f"{int_part}.{dec.group(1)}"
    else:
        value_str = re.sub(r"[^\d]", "", token)
    if not value_str or value_str == ".":
        return None
    try:
        return float(value_str)
    except ValueError:
        return None


def parse_int(text: str) -> Optional[int]:
    n = parse_number(text)
    if n is None:
        return None
    return int(round(n))


def parse_price(text: str) -> Optional[float]:
    """Prix « à partir de ». Alias sémantique de parse_number (garde None si absent)."""
    return parse_number(text)


# --------------------------------------------------------------------------- #
# Tables Markdown.
# --------------------------------------------------------------------------- #
def _split_row(line: str) -> list[str]:
    line = line.strip()
    if line.startswith("|"):
        line = line[1:]
    if line.endswith("|"):
        line = line[:-1]
    return [c.strip() for c in line.split("|")]


def _is_separator_row(cells: list[str]) -> bool:
    return all(re.fullmatch(r":?-{2,}:?", c.strip()) for c in cells if c.strip()) and any(cells)


def find_table_with_header(md: str, header_keywords: list[str]) -> tuple[list[str], list[list[str]]]:
    """(header, rows) de la première table Markdown dont l'en-tête contient TOUS
    les mots-clés (insensible casse). ([], []) si aucune table correspondante.
    """
    lines = md.splitlines()
    keys = [k.lower() for k in header_keywords]
    i = 0
    while i < len(lines):
        line = lines[i]
        if line.count("|") >= 2:
            header = _split_row(line)
            header_txt = " ".join(header).lower()
            nxt = lines[i + 1] if i + 1 < len(lines) else ""
            if all(k in header_txt for k in keys) and _is_separator_row(_split_row(nxt)):
                rows: list[list[str]] = []
                j = i + 2
                while j < len(lines) and lines[j].count("|") >= 2:
                    cells = _split_row(lines[j])
                    if not _is_separator_row(cells):
                        rows.append(cells)
                    j += 1
                return header, rows
        i += 1
    return [], []


def find_table(md: str, header_keywords: list[str]) -> list[list[str]]:
    """Lignes de données seules (compat) — voir find_table_with_header."""
    return find_table_with_header(md, header_keywords)[1]


def _col(header: list[str], *keywords: str) -> Optional[int]:
    """Index de la 1re colonne dont l'en-tête contient TOUS les mots-clés (casse ignorée)."""
    keys = [k.lower() for k in keywords]
    for idx, cell in enumerate(header):
        low = _clean(cell).lower()
        if all(k in low for k in keys):
            return idx
    return None


# --------------------------------------------------------------------------- #
# Extractions par volet.
# --------------------------------------------------------------------------- #
def parse_typologies(architecture_md: str) -> list[dict[str, Any]]:
    """Tableau §3.2 → liste de typologies. Ignore les lignes-placeholder.

    Colonnes attendues (template v1.0) :
      Typologie | Nb unités | Surface intérieure | Surface terrasse |
      Surface totale | Prix « à partir de » (USD) | Prix (DOP)
    """
    header, rows = find_table_with_header(architecture_md, ["typologie", "prix"])
    if not header:
        return []

    # Mapping des colonnes PAR EN-TÊTE (résilient au ré-ordonnancement / ajout de
    # colonnes). Une colonne absente de l'en-tête → valeur None (jamais deviner une
    # position et risquer d'assigner la mauvaise donnée — contrainte #6).
    i_nom = _col(header, "typologie")
    i_qte = _col(header, "unit")  # « Nb unités »
    i_int = _col(header, "intérieure")
    if i_int is None:
        i_int = _col(header, "interieure")
    i_terr = _col(header, "terrasse")
    i_tot = _col(header, "totale")
    i_usd = _col(header, "usd")
    i_dop = _col(header, "dop")
    if i_nom is None:  # en-tête inexploitable → on ne parse pas de prix au hasard
        return []

    def cell(cells: list[str], idx: Optional[int]) -> str:
        if idx is None or not (0 <= idx < len(cells)):
            return ""
        return cells[idx]

    typologies: list[dict[str, Any]] = []
    for cells in rows:
        if _is_absent(cell(cells, i_nom)):  # ligne entièrement placeholder → on saute
            continue
        typologies.append(
            {
                "nom": _clean(cell(cells, i_nom)),
                "quantite": parse_int(cell(cells, i_qte)),
                "surface_interieure_m2": parse_number(cell(cells, i_int)),
                "surface_terrasse_m2": parse_number(cell(cells, i_terr)),
                "surface_totale_m2": parse_number(cell(cells, i_tot)),
                "prix_depuis_usd": parse_price(cell(cells, i_usd)),
                "prix_depuis_dop": parse_price(cell(cells, i_dop)),
            }
        )
    return typologies


def _parse_bullets_after(md: str, heading_keywords: list[str]) -> list[str]:
    """Puces (- / * / •) sous le premier titre/ligne contenant tous les mots-clés."""
    lines = md.splitlines()
    keys = [k.lower() for k in heading_keywords]
    items: list[str] = []
    capturing = False
    for line in lines:
        low = line.lower()
        if not capturing:
            if line.lstrip().startswith("#") and all(k in low for k in keys):
                capturing = True
            continue
        if line.lstrip().startswith("#"):  # section suivante → stop
            break
        m = re.match(r"\s*[-*•]\s+(.*)", line)
        if m:
            val = _clean(m.group(1))
            if val and not _is_absent(val):
                items.append(val)
    return items


def parse_services_inclus(paysage_md: str) -> list[str]:
    """§3.3 « Services inclus » (+ §3.2 amenities en complément)."""
    services = _parse_bullets_after(paysage_md, ["services", "inclus"])
    amenities = _parse_bullets_after(paysage_md, ["amenities"])
    # Dédoublonnage en conservant l'ordre.
    seen: set[str] = set()
    out: list[str] = []
    for item in services + amenities:
        key = item.lower()
        if key not in seen:
            seen.add(key)
            out.append(item)
    return out


def parse_localisation(brief_md: str) -> Optional[str]:
    """Ligne « Localisation : … » du brief (§1.1)."""
    for line in brief_md.splitlines():
        m = re.match(r"\s*[-*]?\s*\**localisation\**\s*[:：]\s*(.+)", line, re.IGNORECASE)
        if m:
            val = _clean(m.group(1))
            if val and not _is_absent(val):
                return val
    return None


def parse_nom(brief_md: str, code: str) -> str:
    """Nom commercial : 1er titre H1 du brief, sinon le code projet."""
    for line in brief_md.splitlines():
        m = re.match(r"\s*#\s+(.+)", line)
        if m:
            return _clean(m.group(1))
    return code


def parse_positionnement_fr(commercial_md: str) -> Optional[str]:
    """Positionnement FR = 1er paragraphe non-titre de commercial.md.

    NOTE : la génération EN/ES multilingue est le livrable Semaine 6 ; ici on ne
    remplit que le FR à partir de la source documentée (jamais inventé — #6).
    """
    for block in re.split(r"\n\s*\n", commercial_md):
        block = block.strip()
        if not block or block.startswith("#") or block.startswith("|"):
            continue
        text = _clean(block.replace("\n", " "))
        if len(text) >= 20:
            return text
    return None


def parse_rendus(photos_dir: str) -> list[dict[str, Any]]:
    """Rendus liés au projet réel (jamais IA générique — contrainte CLAUDE.md).

    Deux sources possibles :
      1. Un manifeste `60_photos_site/README.md` (table : Fichier | Vue | Hero).
      2. À défaut, la liste des fichiers image du dossier.
    """
    rendus: list[dict[str, Any]] = []
    if not os.path.isdir(photos_dir):
        return rendus

    manifest = os.path.join(photos_dir, "README.md")
    if os.path.isfile(manifest):
        with open(manifest, encoding="utf-8") as fh:
            rows = find_table(fh.read(), ["fichier"])
        for cells in rows:
            cells = cells + [""] * (3 - len(cells))
            fichier = _clean(cells[0])
            if _is_absent(cells[0]):
                continue
            entry: dict[str, Any] = {"fichier": fichier}
            vue = _clean(cells[1])
            if vue and not _is_absent(cells[1]):
                entry["vue"] = vue
            entry["hero"] = "hero" in _clean(cells[2]).lower() or "oui" in _clean(cells[2]).lower()
            rendus.append(entry)
        if rendus:
            return rendus

    # Fallback : fichiers image présents.
    exts = (".jpg", ".jpeg", ".png", ".webp", ".avif")
    for fn in sorted(os.listdir(photos_dir)):
        if fn.lower().endswith(exts):
            rendus.append({"fichier": fn, "hero": False})
    if rendus:
        rendus[0]["hero"] = True  # 1er rendu = hero par défaut
    return rendus


# --------------------------------------------------------------------------- #
# Mapping de statut + assemblage projet.
# --------------------------------------------------------------------------- #
_STATUT_MAP = {
    "complete": "disponible",
    "en_developpement": "en_developpement",
    "incomplete": "en_developpement",
}


def map_statut(version_statut: str) -> str:
    return _STATUT_MAP.get(version_statut, "en_developpement")


def _read(path: str) -> str:
    if os.path.isfile(path):
        with open(path, encoding="utf-8") as fh:
            return fh.read()
    return ""


def parse_projet(projet_dir: str) -> dict[str, Any]:
    """Lit un dossier `data_room/PXX/` → dict `projet` (contrat schéma).

    Lève FileNotFoundError si `_META/version.json` absent (donnée de vérité
    obligatoire : sans elle on ne connaît ni le statut ni le score, on refuse
    d'inventer).
    """
    meta_path = os.path.join(projet_dir, "_META", "version.json")
    if not os.path.isfile(meta_path):
        raise FileNotFoundError(f"version.json manquant : {meta_path}")
    with open(meta_path, encoding="utf-8") as fh:
        version = json.load(fh)

    code = version["projet"]
    brief_md = _read(os.path.join(projet_dir, "00_brief", "brief.md"))
    architecture_md = _read(os.path.join(projet_dir, "20_architecture", "architecture.md"))
    paysage_md = _read(os.path.join(projet_dir, "30_paysage_experience", "paysage_experience.md"))
    commercial_md = _read(os.path.join(projet_dir, "40_llm_outputs", "commercial.md"))

    typologies = parse_typologies(architecture_md)
    statut = map_statut(version.get("statut_faisabilite", "incomplete"))

    # Rétrogradation défensive (contrainte #6) : « disponible » exige des prix USD
    # numériques sur toutes les typologies, sinon on ne publie pas de prix douteux.
    if statut == "disponible":
        prix_ok = bool(typologies) and all(
            isinstance(t["prix_depuis_usd"], (int, float)) for t in typologies
        )
        if not prix_ok:
            statut = "en_developpement"

    projet: dict[str, Any] = {
        "code": code,
        "nom": parse_nom(brief_md, code),
        "statut": statut,
        "localisation": parse_localisation(brief_md) or "",
        # Les typologies (avec prix null tolérés) restent exposées même en
        # « en_developpement » : le générateur choisit d'afficher ou non la grille.
        "typologies": typologies,
        "inclus": parse_services_inclus(paysage_md),
        "rendus": parse_rendus(os.path.join(projet_dir, "60_photos_site")),
        "source": {
            "template_version": version.get("template_version", ""),
            "score_4big": version.get("score_4big", 0),
            "fichiers": _sources_presentes(projet_dir),
        },
    }
    positionnement_fr = parse_positionnement_fr(commercial_md)
    if positionnement_fr:
        projet["positionnement"] = {"fr": positionnement_fr}
    return projet


def _sources_presentes(projet_dir: str) -> list[str]:
    """Chemins relatifs des sources effectivement lues (traçabilité #6)."""
    candidates = [
        os.path.join("_META", "version.json"),
        os.path.join("00_brief", "brief.md"),
        os.path.join("20_architecture", "architecture.md"),
        os.path.join("30_paysage_experience", "paysage_experience.md"),
        os.path.join("40_llm_outputs", "commercial.md"),
        os.path.join("60_photos_site", "README.md"),
    ]
    return [c for c in candidates if os.path.isfile(os.path.join(projet_dir, c))]


def build_master(
    data_room_dir: str,
    generated_at: Optional[str] = None,
    template_version: str = "1.0.0",
) -> dict[str, Any]:
    """Parcourt `data_room/` → objet `projets_master.json` complet et trié par code.

    `generated_at` : horodatage ISO-8601 UTC ; par défaut « maintenant ».
    Injectable pour des sorties déterministes (tests).
    """
    if generated_at is None:
        generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")

    projets: list[dict[str, Any]] = []
    for name in sorted(os.listdir(data_room_dir)):
        pdir = os.path.join(data_room_dir, name)
        if re.fullmatch(r"P0[1-9]", name) and os.path.isfile(
            os.path.join(pdir, "_META", "version.json")
        ):
            projets.append(parse_projet(pdir))

    return {
        "generated_at": generated_at,
        "template_version": template_version,
        "projets": projets,
    }
