"""Construye el grafo agregado de un vault de Obsidian: nodos + aristas.

Funcion impura (lee disco) del grupo de capacidad ``obsidian``. Es la pieza
que cierra la frontera "el grupo obsidian no indexa el grafo agregado":
recorre todas las notas del vault, las convierte en nodos tipados y resuelve
los wikilinks ``[[...]]`` del cuerpo a aristas entre nodos existentes.

Registry-first: compone funciones puras/impuras ya existentes del grupo
``obsidian`` (``list_obsidian_notes``, ``read_obsidian_note``,
``slugify_obsidian_name``) en vez de reimplementar el parseo del vault.
"""

import os
import re

from obsidian.list_obsidian_notes import list_obsidian_notes
from obsidian.read_obsidian_note import read_obsidian_note
from obsidian.slugify_obsidian_name import slugify_obsidian_name

# Carpetas de primer nivel del vault -> tipo de nodo por defecto. El tipo del
# frontmatter (campo ``tipo``) siempre tiene prioridad sobre la carpeta.
_FOLDER_TIPO = {
    "personas": "persona",
    "organizaciones": "organizacion",
    "lugares": "lugar",
    "dominios": "dominio",
    "casos": "caso",
}

# Encabezados de seccion (## ...) -> kind de las aristas que aparecen debajo.
# Se normaliza el texto del encabezado a un slug para comparar de forma estable
# (acentos, mayusculas y plurales/sinonimos cercanos colapsan al mismo bucket).
_SECTION_KIND = {
    "relaciones": "relacion",
    "relacionado": "relacion",
    "relacion": "relacion",
    "lugares": "lugar",
    "lugar": "lugar",
    "documentos": "documento",
    "documento": "documento",
}

# Captura un wikilink [[...]] o embed ![[...]]; el grupo es el interior.
_WIKILINK_RE = re.compile(r"!?\[\[([^\[\]]+?)\]\]")

# Captura un encabezado Markdown ATX (## Titulo). Grupo 1 = texto del titulo.
_HEADING_RE = re.compile(r"^#{1,6}\s+(.*?)\s*#*\s*$")


def _tipo_for_note(path: str, vault_dir: str, frontmatter: dict) -> str:
    """Determina el tipo de un nodo: frontmatter['tipo'] o carpeta de 1er nivel.

    El campo ``tipo`` del frontmatter manda. Si falta, se usa la primera
    carpeta del path relativo al vault mapeada por ``_FOLDER_TIPO``. Si no
    encaja en ninguna, el tipo es la propia carpeta de primer nivel (o
    ``"nota"`` para notas en la raiz del vault).
    """
    tipo = frontmatter.get("tipo")
    if isinstance(tipo, str) and tipo.strip():
        return tipo.strip()

    rel = os.path.relpath(path, vault_dir)
    parts = rel.split(os.sep)
    if len(parts) >= 2:
        top = parts[0]
        return _FOLDER_TIPO.get(top, top)
    return "nota"


def _wikilink_target_slug(raw_target: str) -> str:
    """Reduce el destino de un wikilink a un slug estable para indexar.

    El destino que entrega ``extract_obsidian_wikilinks`` ya viene sin alias
    (``|...``) ni ancla (``#...``). Aqui se toma el ultimo segmento del path
    (Obsidian resuelve ``[[carpeta/nota]]`` por la nota, no por la carpeta) y
    se slugifica con ``slugify_obsidian_name`` para que ``[[Maria del Mar]]``
    y ``[[maria-del-mar]]`` apunten al mismo nodo.
    """
    # Ultimo segmento del path (soporta tanto '/' como '\\' por robustez).
    last = re.split(r"[\\/]", raw_target)[-1]
    return slugify_obsidian_name(last)


def _iter_body_links_with_kind(body: str):
    """Itera (slug_destino, kind) por cada wikilink del cuerpo, con su seccion.

    Recorre el body linea a linea llevando la cuenta de la ultima seccion
    ``## ...`` vista para asignar el ``kind`` de cada wikilink segun
    ``_SECTION_KIND`` (relaciones/lugares/documentos). Fuera de esas secciones
    el kind por defecto es ``"wikilink"``. No deduplica: cada aparicion produce
    un par (la deduplicacion se hace al construir las aristas).
    """
    current_kind = "wikilink"
    for line in body.splitlines():
        heading = _HEADING_RE.match(line)
        if heading:
            heading_slug = slugify_obsidian_name(heading.group(1))
            current_kind = _SECTION_KIND.get(heading_slug, "wikilink")
            continue
        for match in _WIKILINK_RE.finditer(line):
            inner = match.group(1)
            target = inner.split("|", 1)[0].split("#", 1)[0].strip()
            if not target:
                continue
            slug = _wikilink_target_slug(target)
            if not slug:
                continue
            yield slug, current_kind


def build_obsidian_graph(vault_dir: str, include_dangling: bool = True) -> dict:
    """Construye el grafo agregado (nodos + aristas) de un vault de Obsidian.

    Recorre cada nota ``.md`` del vault (excluyendo ``.obsidian/`` y
    ``.trash/`` via ``list_obsidian_notes``; ``attachments/`` no contiene
    ``.md`` y queda fuera de forma natural). Cada nota es un nodo cuyo ``id``
    es su slug (nombre de archivo sin ``.md``) y cuyo ``tipo`` sale del campo
    ``tipo`` del frontmatter o, en su defecto, de la carpeta de primer nivel.
    Cada wikilink ``[[...]]`` del cuerpo es una arista dirigida del nodo origen
    al nodo destino, resuelto por slug del ultimo segmento del destino.

    Args:
        vault_dir: Ruta (absoluta o relativa) a la raiz del vault de Obsidian.
        include_dangling: Si es ``True`` (por defecto), los wikilinks que no
            resuelven a ninguna nota del vault generan un nodo fantasma con
            ``dangling: true`` y su arista correspondiente. Si es ``False``,
            esos wikilinks rotos se descartan (ni nodo fantasma ni arista).

    Returns:
        dict con dos claves:
            - ``nodes``: lista de ``{"id": slug, "tipo": str, "label": str,
              "frontmatter": dict}``. Los nodos fantasma anaden
              ``"dangling": True`` y llevan ``frontmatter`` vacio.
            - ``edges``: lista de ``{"source": slug, "target": slug,
              "kind": str}`` deduplicada por (source, target, kind). El
              ``kind`` se deduce de la seccion (``relacion``/``lugar``/
              ``documento``) o es ``"wikilink"`` por defecto.

    Raises:
        FileNotFoundError: si ``vault_dir`` no existe.
        NotADirectoryError: si ``vault_dir`` no es un directorio.
    """
    note_paths = list_obsidian_notes(vault_dir)

    # Indice slug -> nodo real. Si dos notas colapsan al mismo slug (raro), la
    # primera en orden alfabetico gana (list_obsidian_notes devuelve ordenado).
    nodes_by_slug: dict[str, dict] = {}
    # Conserva el orden de descubrimiento de los nodos reales para la salida.
    real_order: list[str] = []

    for path in note_paths:
        slug = os.path.splitext(os.path.basename(path))[0]
        if not slug or slug in nodes_by_slug:
            continue
        try:
            note = read_obsidian_note(path)
        except OSError:
            # Una nota ilegible no debe tumbar el grafo entero: se omite.
            continue
        frontmatter = note.get("frontmatter", {}) or {}
        tipo = _tipo_for_note(path, vault_dir, frontmatter)
        nombre = frontmatter.get("nombre")
        label = str(nombre).strip() if isinstance(nombre, str) and nombre.strip() else slug
        nodes_by_slug[slug] = {
            "id": slug,
            "tipo": tipo,
            "label": label,
            "frontmatter": frontmatter,
            "_path": path,  # interno: para resolver enlaces del body
        }
        real_order.append(slug)

    edges: list[dict] = []
    seen_edges: set[tuple] = set()
    dangling_slugs: list[str] = []
    dangling_seen: set[str] = set()

    for slug in real_order:
        node = nodes_by_slug[slug]
        try:
            body = read_obsidian_note(node["_path"]).get("body", "") or ""
        except OSError:
            continue
        for target_slug, kind in _iter_body_links_with_kind(body):
            if target_slug == slug:
                # Auto-enlace: se ignora (no aporta al grafo).
                continue
            resolved = target_slug in nodes_by_slug
            if not resolved:
                if not include_dangling:
                    continue
                if target_slug not in dangling_seen:
                    dangling_seen.add(target_slug)
                    dangling_slugs.append(target_slug)
            edge_key = (slug, target_slug, kind)
            if edge_key in seen_edges:
                continue
            seen_edges.add(edge_key)
            edges.append({"source": slug, "target": target_slug, "kind": kind})

    # Serializa nodos reales (sin la clave interna _path) + nodos fantasma.
    nodes: list[dict] = []
    for slug in real_order:
        node = nodes_by_slug[slug]
        nodes.append(
            {
                "id": node["id"],
                "tipo": node["tipo"],
                "label": node["label"],
                "frontmatter": node["frontmatter"],
            }
        )
    if include_dangling:
        for slug in dangling_slugs:
            nodes.append(
                {
                    "id": slug,
                    "tipo": "desconocido",
                    "label": slug,
                    "frontmatter": {},
                    "dangling": True,
                }
            )

    return {"nodes": nodes, "edges": edges}


if __name__ == "__main__":
    import json
    import sys

    target_vault = sys.argv[1] if len(sys.argv) > 1 else "/home/enmanuel/Obsidian/osint"
    graph = build_obsidian_graph(target_vault)
    print(
        json.dumps(
            {
                "vault": target_vault,
                "nodes": len(graph["nodes"]),
                "edges": len(graph["edges"]),
                "dangling": sum(1 for n in graph["nodes"] if n.get("dangling")),
            },
            ensure_ascii=False,
            indent=2,
        )
    )
