"""
Normalisation des données brutes ClickUp en objets Python propres.
"""

from __future__ import annotations  # PEP 585 (`list[Task]`) sans Python 3.9 : l'hébergement est en 3.7


from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional


# ------------------------------------------------------------------
# Constantes : types d'items (custom_item_id)
# ------------------------------------------------------------------

TYPE_TACHE    = 0
TYPE_JALON    = 1
TYPE_CHANTIER = 1003
TYPE_REUNION  = 1004

TYPE_LABELS = {
    TYPE_TACHE:    "Tâche",
    TYPE_JALON:    "Jalon",
    TYPE_CHANTIER: "Chantier",
    TYPE_REUNION:  "Réunion",
}

# Statuts considérés comme "terminés"
STATUTS_TERMINES = {"complete", "completed", "terminé", "terminée", "done", "closed"}
STATUTS_A_RISQUE = {"à risque", "a risque", "at risk", "bloqué", "bloquée", "blocked"}
STATUTS_EN_ATTENTE = {"en attente", "waiting", "on hold"}
STATUTS_ANNULES = {"cancelled", "canceled", "annulé", "annulée", "annule"}


# ------------------------------------------------------------------
# Dataclass principale
# ------------------------------------------------------------------

@dataclass
class Task:
    # Identifiants
    id: str
    name: str
    item_type: int          # 0=Tâche, 1=Jalon, 1003=Chantier
    item_type_label: str

    # Dates (None si non renseigné)
    start_date: Optional[datetime]
    due_date: Optional[datetime]
    date_done: Optional[datetime]

    # Statut
    status: str
    is_done: bool
    is_at_risk: bool
    is_waiting: bool

    # Priorité (1=urgent, 2=high, 3=normal, 4=low, None=aucune)
    priority: Optional[int]
    priority_label: str

    # Contenu
    description: Optional[str]

    # Champs personnalisés
    visible_copil: bool
    visible_rapport: bool
    a_arbitrer: bool
    note_contexte: Optional[str]
    description_gantt: Optional[str]

    # Contexte hiérarchique
    folder_name: Optional[str]
    list_name: str
    list_id: str
    space_name: Optional[str]

    # Lien ClickUp
    url: str

    # Position dans ClickUp (pour tri)
    parent_id: Optional[str] = None
    orderindex: float = 0.0

    # Avancement (0-100)
    avancement: Optional[int] = None
    is_cancelled: bool = False

    # Couleur du chantier, résolue par Joseph (hexadécimal sans `#`).
    # `None` — le cas de toute source ClickUp — laisse `DOMAIN_PALETTE` décider.
    color: Optional[str] = None
    color_light: Optional[str] = None

    # Catégorie de statut brute côté Joseph (ex. "to_validate") — absente de
    # toute source ClickUp, contrairement à `status` (le libellé) et aux
    # booléens `is_*` ci-dessus, qui restent la façon historique de tester un
    # état. Un filtre qui n'a pas son booléen dédié (ex. "à valider") lit
    # celle-ci directement plutôt que d'en ajouter un de plus par statut.
    status_category: Optional[str] = None

    # Champs propres à la source Joseph, absents de toute source ClickUp — d'où
    # leurs défauts. Lus par les tableaux de bord (`generators/dashboards.py`).
    code: Optional[str] = None
    # [{"name": ..., "role": "responsible" | "consulted" | "informed"}]
    assignees: list = field(default_factory=list)
    # Jalon majeur (JOS-192) : ceux que la frise du Focus projet affiche.
    is_major: bool = False
    # Effort propre à l'élément, en heures — pas l'agrégat de sa descendance.
    effort_hours: Optional[float] = None


# ------------------------------------------------------------------
# Dataclass pour les groupes de réunions récurrentes
# ------------------------------------------------------------------

@dataclass
class ReunionGroup:
    """Représente une série de réunions récurrentes à afficher sur une seule ligne."""
    id: str                    # ID de la tâche parente
    name: str
    occurrences: list          # Liste de datetime (due_date de chaque occurrence)
    folder_name: Optional[str]
    list_name: str
    list_id: str
    parent_id: Optional[str] = None
    orderindex: float = 0.0


# ------------------------------------------------------------------
# Parsers
# ------------------------------------------------------------------

def _parse_ts(ts_ms) -> Optional[datetime]:
    """Convertit un timestamp ClickUp (millisecondes en string) en datetime."""
    if not ts_ms:
        return None
    try:
        return datetime.fromtimestamp(int(ts_ms) / 1000)
    except (ValueError, TypeError):
        return None


def _parse_custom_fields(custom_fields: list) -> dict:
    """Transforme la liste de custom fields en dict {nom_normalisé: valeur}."""
    result = {}
    for cf in custom_fields:
        name = cf.get("name", "").strip()
        value = cf.get("value")
        type_ = cf.get("type", "")

        # Cases à cocher : value est "true"/"false" ou 1/0 ou None
        if type_ == "checkbox":
            result[name] = value in (True, 1, "true", "1")
        else:
            result[name] = value if value not in (None, "", []) else None

    return result


def _parse_priority(priority_data) -> tuple[Optional[int], str]:
    if not priority_data:
        return None, ""
    id_ = priority_data.get("id")
    label = priority_data.get("priority", "")
    try:
        return int(id_), label
    except (ValueError, TypeError):
        return None, label


def from_raw(raw: dict) -> Task:
    """Construit un objet Task depuis un dict brut retourné par l'API ClickUp."""

    custom = _parse_custom_fields(raw.get("custom_fields", []))
    status_str = raw.get("status", {}).get("status", "").lower().strip()
    priority_id, priority_label = _parse_priority(raw.get("priority"))
    item_type = raw.get("custom_item_id", 0) or 0

    return Task(
        id=raw["id"],
        name=raw.get("name", ""),
        item_type=item_type,
        item_type_label=TYPE_LABELS.get(item_type, f"Type {item_type}"),

        start_date=_parse_ts(raw.get("start_date")),
        due_date=_parse_ts(raw.get("due_date")),
        date_done=_parse_ts(raw.get("date_done")),

        status=status_str,
        is_done=status_str in STATUTS_TERMINES,
        is_at_risk=status_str in STATUTS_A_RISQUE,
        is_waiting=status_str in STATUTS_EN_ATTENTE,
        is_cancelled=status_str in STATUTS_ANNULES,

        priority=priority_id,
        priority_label=priority_label,

        description=raw.get("text_content") or raw.get("description") or None,

        visible_copil=custom.get("Visible COPIL", False) or False,
        visible_rapport=custom.get("Visible rapport", False) or False,
        a_arbitrer=custom.get("A arbitrer", False) or False,
        note_contexte=custom.get("Note de contexte"),
        description_gantt=custom.get("Description Gantt"),

        folder_name=raw.get("_folder_name"),
        list_name=raw.get("_list_name", ""),
        list_id=raw.get("_list_id", ""),
        space_name=raw.get("_space_name"),

        url=raw.get("url", ""),
        parent_id=raw.get("parent") or None,
        orderindex=float(raw.get("orderindex") or 0),
    )


def parse_tasks(raw_tasks: list) -> list[Task]:
    """Convertit une liste de dicts bruts en liste de Task. Exclut les tâches annulées."""
    tasks = []
    for raw in raw_tasks:
        try:
            task = from_raw(raw)
            if not task.is_cancelled:
                tasks.append(task)
        except Exception as e:
            print(f"  [warn] Impossible de parser la tâche '{raw.get('name', '?')}' : {e}")
    return tasks


def filter_for_planning(tasks: list[Task]) -> dict:
    """
    Répartit les tasks en catégories utiles pour le planning.
    Retourne un dict avec les clés : chantiers, jalons, taches, reunions.
    """
    return {
        "chantiers": [t for t in tasks if t.item_type == TYPE_CHANTIER],
        "jalons":    [t for t in tasks if t.item_type == TYPE_JALON],
        "taches":    [t for t in tasks if t.item_type == TYPE_TACHE],
        "reunions":  [t for t in tasks if t.item_type == TYPE_REUNION],
    }


def group_reunions(tasks: list[Task]) -> list:
    """
    Regroupe les tâches de type Réunion (1004) par (parent_id, name).
    Chaque groupe devient un ReunionGroup avec une liste d'occurrences (datetime).
    Les tâches réunion sans occurrences (tâche parente isolée) sont gardées telles quelles.
    Retourne une liste mixte : Task (non-réunion) + ReunionGroup.
    """
    # Sépare réunions et autres
    reunions = [t for t in tasks if t.item_type == TYPE_REUNION]
    autres   = [t for t in tasks if t.item_type != TYPE_REUNION]

    # Les parents « fantômes » à absorber : **uniquement ceux qui sont
    # eux-mêmes des réunions**.
    #
    # ClickUp représentait une réunion récurrente par une tâche-réunion parente
    # portant des occurrences en sous-tâches ; absorber ce parent est donc juste.
    # Dans Joseph, une réunion est un enfant ordinaire d'un **chantier** — et la
    # règle d'origine faisait alors disparaître le chantier entier du planning
    # dès qu'il contenait une réunion. Constaté sur un export réel.
    reunion_ids = {t.id for t in reunions}
    parent_ids = set(
        t.parent_id for t in reunions
        if t.parent_id and t.parent_id in reunion_ids
    )

    # Groupe par clé canonique (id_racine, name)
    # Passe 1 : réunions enfants (parent_id défini) → créent/alimentent le groupe
    groupes: dict = {}
    racines_ids = {t.id for t in reunions if not t.parent_id}

    for t in reunions:
        if t.parent_id:
            key = (t.parent_id, t.name)
            if key not in groupes:
                parent = next((p for p in tasks if isinstance(p, Task) and p.id == t.parent_id), None)
                absorbe = parent is not None and parent.id in parent_ids

                # Quand le parent est absorbé (série ClickUp), le groupe prend sa
                # place — son identité, sa position, son propre parent. Sinon —
                # une réunion sous un chantier Joseph, le cas normal — il reste
                # **un enfant du parent**, qui garde sa ligne : le groupe a donc
                # besoin de SA PROPRE identité, distincte de celle du chantier.
                #
                # Bug corrigé le 22/09/2026 (trouvé en préparant une maquette,
                # reproduit indépendamment d'elle) : ce cas non-absorbé posait
                # `id=t.parent_id`, donc le même id que le chantier parent.
                # `build_ordered_rows()` marque déjà ce chantier comme "visité"
                # avant de parcourir ses enfants (`append_descendants()`) — le
                # groupe de réunions, avec le même id, était donc silencieusement
                # ignoré comme "déjà vu" et disparaissait du planning, à chaque
                # fois qu'une réunion récurrente était l'enfant direct d'un
                # chantier (le cas décrit ci-dessus comme normal dans Joseph).
                groupes[key] = ReunionGroup(
                    id=t.parent_id if absorbe else "reunion:{}:{}".format(t.parent_id, t.name),
                    name=t.name,
                    occurrences=[],
                    folder_name=t.folder_name,
                    list_name=t.list_name,
                    list_id=t.list_id,
                    parent_id=(parent.parent_id if parent else None) if absorbe else t.parent_id,
                    orderindex=(parent.orderindex if parent else t.orderindex) if absorbe else t.orderindex,
                )
            if t.due_date:
                groupes[key].occurrences.append(t.due_date)

    # Passe 2 : réunions racines (sans parent_id)
    # Si elles ont déjà un groupe (leurs enfants l'ont créé), on ne fait rien
    # Sinon on crée le groupe avec leur propre due_date
    for t in reunions:
        if not t.parent_id:
            key = (t.id, t.name)
            if key not in groupes:
                groupes[key] = ReunionGroup(
                    id=t.id,
                    name=t.name,
                    occurrences=[t.due_date] if t.due_date else [],
                    folder_name=t.folder_name,
                    list_name=t.list_name,
                    list_id=t.list_id,
                    parent_id=None,
                    orderindex=t.orderindex,
                )

    # Trie les occurrences chronologiquement dans chaque groupe
    for g in groupes.values():
        g.occurrences.sort()

    # Retire les tâches parentes "fantômes" des autres (elles sont absorbées par le groupe)
    autres = [t for t in autres if t.id not in parent_ids]

    return autres + list(groupes.values())