"""Endpoints do grafo de relações societárias.

Substituem o card "Relações com outras empresas", que era derivado do
`process_parties` — partes de processos judiciais, das quais 578 420 de 588 966
são *credores*. Duas empresas ficavam "relacionadas" porque a mesma pessoa
reclamou créditos nas insolvências de ambas.

A diferença de fundo: aqui as arestas vêm do registo comercial, são **dirigidas**
e cada uma traz o acto que a sustenta, com data. Uma afirmação sem fonte não
aparece.
"""
from decimal import Decimal
from typing import Annotated, Any

from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession

from app.auth.deps import CurrentUser, current_user
from app.db import get_session
from app.services.registry_query import (
    EDGE_TYPES,
    MAX_DEPTH,
    coverage,
    entity_detail,
    entry_points,
    neighbourhood,
    search_entities,
)

router = APIRouter(prefix="/graph", tags=["graph"])

MONITORING_TYPES = ("internal", "competitor", "client", "analysis", "related")


@router.get("/coverage")
async def get_coverage(
    session: Annotated[AsyncSession, Depends(get_session)],
    _user: Annotated[CurrentUser, Depends(current_user)],
) -> dict[str, Any]:
    return await coverage(session)


@router.get("/search")
async def get_search(
    session: Annotated[AsyncSession, Depends(get_session)],
    _user: Annotated[CurrentUser, Depends(current_user)],
    q: str = Query(min_length=3),
    limit: int = Query(30, le=100),
) -> list[dict[str, Any]]:
    return await search_entities(session, q, limit=limit)


@router.get("/entry-points")
async def get_entry_points(
    session: Annotated[AsyncSession, Depends(get_session)],
    _user: Annotated[CurrentUser, Depends(current_user)],
    limit: int = Query(24, le=60),
) -> list[dict[str, Any]]:
    """Por onde começar, para a página da rede não abrir vazia."""
    return await entry_points(session, limit=limit)


@router.get("/entities/{nif}")
async def get_entity(
    nif: str,
    session: Annotated[AsyncSession, Depends(get_session)],
    _user: Annotated[CurrentUser, Depends(current_user)],
) -> dict[str, Any]:
    detail = await entity_detail(session, nif)
    if detail is None:
        raise HTTPException(
            status_code=404,
            detail="entidade não encontrada no índice do registo comercial",
        )
    return detail


@router.get("/{nif}")
async def get_neighbourhood(
    nif: str,
    session: Annotated[AsyncSession, Depends(get_session)],
    _user: Annotated[CurrentUser, Depends(current_user)],
    depth: int = Query(2, ge=0, le=MAX_DEPTH),
    types: str | None = Query(None, description="tipos de aresta, separados por vírgula"),
    include_hubs: bool = Query(False, description="atravessar nós de grau alto"),
) -> dict[str, Any]:
    """Vizinhança de um NIF.

    `include_hubs` existe para o caso raro em que se quer mesmo seguir um nó de
    grau muito alto (um banco como sócio, um gerente nomeado em centenas de
    empresas). Por defeito não se atravessam: ligam tudo a tudo e o grafo deixa
    de significar nada. Aparecem sempre listados em `meta.hubs_skipped`, para a
    omissão ser visível.
    """
    wanted = None
    if types:
        wanted = [t.strip() for t in types.split(",") if t.strip() in EDGE_TYPES]
        if not wanted:
            raise HTTPException(
                status_code=400,
                detail=f"tipos válidos: {', '.join(EDGE_TYPES)}",
            )
    return await neighbourhood(
        session, nif, depth=depth, edge_types=wanted, include_hubs=include_hubs
    )


@router.post("/entities/{nif}/promote", status_code=status.HTTP_201_CREATED)
async def promote_entity(
    nif: str,
    session: Annotated[AsyncSession, Depends(get_session)],
    _user: Annotated[CurrentUser, Depends(current_user)],
    monitoring_type: str = Query("analysis"),
) -> dict[str, Any]:
    """Passa um nó do grafo a empresa monitorizada.

    É o caminho natural a partir do grafo: descobre-se uma entidade a dois hops,
    quer-se passar a acompanhá-la a sério — processos judiciais, contratos,
    alvará — e isso precisa de uma linha em `companies`.

    É a **única** porta por onde este subsistema escreve em `companies`, e é
    explícita e por acção de uma pessoa. O resto do grafo nunca lá toca: encher a
    tabela do universo vigiado com entidades nacionais estragaria todos os ecrãs
    e todos os jobs que iteram sobre ela.
    """
    if monitoring_type not in MONITORING_TYPES:
        raise HTTPException(
            status_code=400, detail=f"tipo inválido; use um de {MONITORING_TYPES}"
        )
    entity = (
        await session.execute(
            text(
                "SELECT nif, name, natureza_juridica, sede, distrito, concelho,"
                " freguesia, codigo_postal, localidade"
                " FROM registry_entities WHERE nif = :nif"
            ),
            {"nif": "".join(c for c in nif if c.isdigit())},
        )
    ).mappings().first()
    if not entity:
        raise HTTPException(status_code=404, detail="entidade não está no índice")

    row = (
        await session.execute(
            text(
                """
                INSERT INTO companies (
                    nif, legal_name, monitoring_type, monitored, active, status,
                    natureza_juridica, address, distrito, concelho, freguesia,
                    codigo_postal, localidade
                ) VALUES (
                    :nif, :name, :mtype, TRUE, TRUE, 'active',
                    :natureza, :sede, :distrito, :concelho, :freguesia, :cp, :localidade
                )
                ON CONFLICT (nif) DO UPDATE SET
                    monitoring_type = EXCLUDED.monitoring_type,
                    monitored = TRUE,
                    updated_at = now()
                RETURNING id::text, monitoring_type, (xmax = 0) AS criada
                """
            ),
            {
                "nif": entity["nif"], "name": entity["name"], "mtype": monitoring_type,
                "natureza": entity["natureza_juridica"], "sede": entity["sede"],
                "distrito": entity["distrito"], "concelho": entity["concelho"],
                "freguesia": entity["freguesia"], "cp": entity["codigo_postal"],
                "localidade": entity["localidade"],
            },
        )
    ).mappings().first()

    # Entra também na fila do histórico por NIPC, para os scrapers completos
    # apanharem a entidade na próxima corrida em vez de esperar por um ciclo.
    await session.execute(
        text(
            """
            INSERT INTO registry_entity_fetch (nif, depth, reason)
            VALUES (:nif, 0, 'promovida')
            ON CONFLICT (nif) DO UPDATE SET depth = 0, status = 'pending', attempts = 0
            """
        ),
        {"nif": entity["nif"]},
    )
    await session.commit()
    return {
        "company_id": row["id"], "nif": entity["nif"], "name": entity["name"],
        "monitoring_type": row["monitoring_type"], "criada": bool(row["criada"]),
    }


@router.get("/identity-links/stats")
async def identity_link_stats(
    session: Annotated[AsyncSession, Depends(get_session)],
    _user: Annotated[CurrentUser, Depends(current_user)],
) -> list[dict[str, Any]]:
    """Quantos candidatos por evidência, e quantos já decididos."""
    rows = (
        await session.execute(
            text(
                """
                SELECT evidence, score,
                       count(*) FILTER (
                         WHERE confirmed_at IS NULL AND rejected_at IS NULL) AS por_decidir,
                       count(*) FILTER (WHERE confirmed_at IS NOT NULL) AS confirmados,
                       count(*) FILTER (WHERE rejected_at IS NOT NULL) AS rejeitados
                  FROM registry_identity_links
                 GROUP BY evidence, score
                 ORDER BY score DESC
                """
            )
        )
    ).mappings().all()
    return [dict(r) for r in rows]


@router.post("/identity-links/confirm-batch")
async def confirm_identity_links(
    ids: list[str],
    session: Annotated[AsyncSession, Depends(get_session)],
    user: Annotated[CurrentUser, Depends(current_user)],
    confirm: bool = Query(True),
) -> dict[str, Any]:
    """Decide vários candidatos de uma vez.

    Existe porque a fila tem 4.602 candidatos de `nome+rua+cp` e uma fila desse
    tamanho não se revê linha a linha — ficaria por rever, que é o estado em que
    esta tabela esteve desde a Fase 8. Os ids vêm do que o ecrã mostrou, e não de
    um filtro no servidor: quem decide vê exactamente o que está a decidir.
    """
    if not ids or len(ids) > 1000:
        raise HTTPException(status_code=400, detail="entre 1 e 1000 ligações")
    result = await session.execute(
        text(
            """
            UPDATE registry_identity_links SET
                confirmed_at = CASE WHEN :confirm THEN now() ELSE NULL END,
                confirmed_by = CASE WHEN :confirm THEN CAST(:uid AS UUID) ELSE NULL END,
                rejected_at = CASE WHEN :confirm THEN NULL ELSE now() END,
                rejected_by = CASE WHEN :confirm THEN NULL ELSE CAST(:uid AS UUID) END
             WHERE id = ANY(CAST(:ids AS UUID[]))
               AND confirmed_at IS NULL AND rejected_at IS NULL
            """
        ),
        {"confirm": confirm, "uid": str(user.id), "ids": ids},
    )
    from app.services.registry_graph import apply_identity_merges

    fusoes = await apply_identity_merges(session) if confirm else {}
    await session.commit()
    return {"decididas": result.rowcount or 0, "confirmadas": confirm, "fusoes": fusoes}


@router.get("/identity-links/pending")
async def pending_identity_links(
    session: Annotated[AsyncSession, Depends(get_session)],
    _user: Annotated[CurrentUser, Depends(current_user)],
    limit: int = Query(50, le=200),
    min_score: float = Query(0.6, ge=0.0, le=1.0),
) -> list[dict[str, Any]]:
    """Candidatos a ligar um nó sem NIF (tipicamente um cônjuge) a um com NIF.

    Ver `registry_graph.suggest_identity_links`: nome igual mais rua e código
    postal iguais é uma boa sugestão, não um facto — podem ter mudado de casa. A
    travessia do grafo só atravessa o que for confirmado aqui.
    """
    rows = (
        await session.execute(
            text(
                """
                SELECT l.id::text, l.evidence, l.score,
                       u.name AS nome_sem_nif, u.entity_key,
                       c.nif AS candidato_nif, c.name AS candidato_nome,
                       c.concelho AS candidato_concelho,
                       (SELECT count(*) FROM registry_edges r
                         WHERE r.holder_id = c.id AND r.is_current) AS candidato_ligacoes,
                       -- O que está em jogo: as ligações que o nó sem NIF tem e
                       -- que, sem a fusão, não levam a lado nenhum.
                       (SELECT count(*) FROM registry_edges r
                         WHERE r.holder_id = u.id AND r.is_current) AS ligacoes_em_jogo,
                       (SELECT string_agg(DISTINCT s.name, ' · ')
                          FROM registry_edges r
                          JOIN registry_entities s ON s.id = r.subject_id
                         WHERE r.holder_id = u.id AND r.is_current) AS onde_aparece,
                       a.act_date, a.act_type
                  FROM registry_identity_links l
                  JOIN registry_entities u ON u.id = l.unknown_id
                  JOIN registry_entities c ON c.id = l.candidate_id
                  LEFT JOIN registry_acts a ON a.id = l.source_act_id
                 WHERE l.confirmed_at IS NULL AND l.rejected_at IS NULL
                   AND l.score >= :mins
                 ORDER BY l.score DESC, c.name
                 LIMIT :lim
                """
            ),
            # `Decimal` e não `float`. O `score` é NUMERIC(3,2) e o 0.9 em
            # binário é 0.90000000000000002, portanto `0.90 >= 0.9` dá **falso**:
            # o filtro "só as fortes" devolvia zero linhas com 4.602 na tabela.
            # Com 0.6 passava, porque em binário fica logo abaixo — o género de
            # defeito que só aparece num dos valores. Um CAST no SQL não chega:
            # o valor já vem impreciso de Python.
            {"lim": limit, "mins": Decimal(str(min_score))},
        )
    ).mappings().all()
    return [dict(r) for r in rows]


@router.post("/identity-links/{link_id}")
async def decide_identity_link(
    link_id: str,
    session: Annotated[AsyncSession, Depends(get_session)],
    user: Annotated[CurrentUser, Depends(current_user)],
    confirm: bool = Query(...),
) -> dict[str, Any]:
    result = await session.execute(
        text(
            """
            UPDATE registry_identity_links SET
                confirmed_at = CASE WHEN :confirm THEN now() ELSE NULL END,
                confirmed_by = CASE WHEN :confirm THEN CAST(:uid AS UUID) ELSE NULL END,
                rejected_at = CASE WHEN :confirm THEN NULL ELSE now() END,
                rejected_by = CASE WHEN :confirm THEN NULL ELSE CAST(:uid AS UUID) END
             WHERE id = CAST(:lid AS UUID)
            """
        ),
        {"confirm": confirm, "uid": str(user.id), "lid": link_id},
    )
    if not result.rowcount:
        raise HTTPException(status_code=404, detail="ligação não encontrada")
    # Confirmar tem de ter efeito. A tabela existia desde a Fase 8 com 531
    # candidatos e zero decisões — e mesmo que alguém decidisse, nada usava a
    # decisão. É aqui que a fusão acontece.
    from app.services.registry_graph import apply_identity_merges

    fusoes = await apply_identity_merges(session) if confirm else {}
    await session.commit()
    return {"ok": True, "confirmada": confirm, "fusoes": fusoes}


@router.get("/health/parser")
async def get_parser_health(
    session: Annotated[AsyncSession, Depends(get_session)],
    _user: Annotated[CurrentUser, Depends(current_user)],
) -> dict[str, Any]:
    """Sinais de extracção morta.

    Existe porque já aconteceu: uma regex que exigia `NIF/NIPC:` colado ao fim de
    linha devolveu zero pessoas durante meses e nada acusou. Um número de
    constituições sem titulares acima de 20 % não é a realidade da fonte.
    """
    from app.services.registry_graph import parser_health, wipeout_candidates

    return {
        "parser": await parser_health(session),
        "captables_parciais": await wipeout_candidates(session, limit=20),
    }
