"""PageRank service utilities for knowledge-graph visualization sizing."""

import logging
import time
import uuid
from collections.abc import Sequence
from typing import Any

from neo4j import Driver

logger = logging.getLogger(__name__)

VISUAL_SIZE_MIN = 10.0
VISUAL_SIZE_MAX = 50.0
NODE_LABEL = "Literature"
WEIGHT_PROPERTY = "score"
FINAL_PROPERTY = "visual_pagerank_size"


def _project_graph(session: Any, graph_name: str, project_id: str) -> tuple[int, int]:
    """Create an ephemeral GDS graph projection for one project.

    Args:
        session: Active Neo4j session.
        graph_name: Unique GDS graph name for this execution.
        project_id: Project boundary used to scope nodes and edges.

    Returns:
        tuple[int, int]: Projected node count and relationship count.
    """

    projection_query = """
    MATCH (source:Literature)-[r]->(target:Literature)
    WHERE source.project_id = $project_id
      AND target.project_id = $project_id
      AND type(r) STARTS WITH 'SIMILAR_'
      AND r.score IS NOT NULL
    WITH gds.graph.project(
        $graph_name,
        source,
        target,
        { relationshipProperties: r { .score } },
        { undirectedRelationshipTypes: ['*'] }
    ) AS g
    RETURN g.nodeCount AS node_count, g.relationshipCount AS relationship_count
    """

    record = session.run(
        projection_query,
        graph_name=graph_name,
        project_id=project_id,
    ).single()
    if record is None:
        logger.warning(
            "Skipping visual PageRank because projection returned no record",
            extra={
                "project_id": project_id,
                "graph_name": graph_name,
            },
        )
        return 0, 0

    node_count = record["node_count"]
    relationship_count = record["relationship_count"]
    if node_count is None or relationship_count is None:
        logger.warning(
            "Skipping visual PageRank because projection counts are missing",
            extra={
                "project_id": project_id,
                "graph_name": graph_name,
                "node_count": node_count,
                "relationship_count": relationship_count,
            },
        )
        return 0, 0

    try:
        return int(node_count), int(relationship_count)
    except (TypeError, ValueError):
        logger.warning(
            "Skipping visual PageRank because projection counts are invalid",
            extra={
                "project_id": project_id,
                "graph_name": graph_name,
                "node_count": node_count,
                "relationship_count": relationship_count,
            },
        )
        return 0, 0


def _stream_scores(session: Any, graph_name: str) -> list[dict[str, float | str]]:
    """Stream raw PageRank scores from GDS.

    Args:
        session: Active Neo4j session.
        graph_name: Projected GDS graph name.

    Returns:
        list[dict[str, float | str]]: Element id and raw score pairs.
    """

    stream_query = """
    CALL gds.pageRank.stream(
        $graph_name,
        {
            relationshipWeightProperty: $weight_property,
            maxIterations: 20,
            dampingFactor: 0.85
        }
    )
    YIELD nodeId, score
    RETURN elementId(gds.util.asNode(nodeId)) AS element_id, score
    """

    result = session.run(
        stream_query,
        graph_name=graph_name,
        weight_property=WEIGHT_PROPERTY,
    )
    return [
        {"element_id": str(record["element_id"]), "score": float(record["score"])}
        for record in result
    ]


def _scale_scores_to_visual_size(
    scores: Sequence[dict[str, float | str]],
) -> tuple[list[dict[str, float | str]], float, float]:
    """Scale streamed PageRank scores into frontend node sizes.

    Args:
        scores: Streamed element id and raw score pairs.

    Returns:
        tuple[list[dict[str, float | str]], float, float]: Batch payload for
        Neo4j, minimum raw score, and maximum raw score.
    """

    raw_scores = [float(item["score"]) for item in scores]
    min_score = min(raw_scores)
    max_score = max(raw_scores)

    if min_score == max_score:
        midpoint = (VISUAL_SIZE_MIN + VISUAL_SIZE_MAX) / 2.0
        return (
            [
                {"element_id": str(item["element_id"]), "visual_size": midpoint}
                for item in scores
            ],
            min_score,
            max_score,
        )

    scale = VISUAL_SIZE_MAX - VISUAL_SIZE_MIN
    denominator = max_score - min_score
    batch = []
    for item in scores:
        raw_score = float(item["score"])
        visual_size = VISUAL_SIZE_MIN + ((raw_score - min_score) / denominator) * scale
        batch.append(
            {
                "element_id": str(item["element_id"]),
                "visual_size": visual_size,
            }
        )
    return batch, min_score, max_score


def _write_visual_sizes(session: Any, batch: Sequence[dict[str, float | str]]) -> int:
    """Persist scaled PageRank values to Neo4j.

    Args:
        session: Active Neo4j session.
        batch: Element id to visual size payload.

    Returns:
        int: Number of updated nodes.
    """

    write_query = """
    UNWIND $batch AS data
    MATCH (n:Literature)
    WHERE elementId(n) = data.element_id
    SET n.visual_pagerank_size = data.visual_size
    RETURN count(n) AS updated_count
    """

    record = session.run(write_query, batch=list(batch)).single()
    if record is None:
        return 0
    return int(record["updated_count"])


def _drop_graph(session: Any, graph_name: str) -> None:
    """Drop an ephemeral GDS projection.

    Args:
        session: Active Neo4j session.
        graph_name: Projected GDS graph name.
    """

    session.run("CALL gds.graph.drop($graph_name, false)", graph_name=graph_name)


def calculate_visual_pagerank(
    driver: Driver,
    project_id: str,
    user_id: str | None = None,
) -> dict[str, int | float]:
    """Calculate and store visual PageRank values for one project.

    Args:
        driver: Active Neo4j driver provided by the backend runtime.
        project_id: Project boundary used to isolate graph analytics.
        user_id: Optional user id for log context.

    Returns:
        dict[str, int | float]: Summary statistics for the execution.

    Raises:
        ValueError: If project_id is empty after normalization.
        Exception: Propagates Neo4j or GDS execution errors.
    """

    normalized_project_id = (project_id or "").strip()
    if not normalized_project_id:
        raise ValueError("project_id is required for PageRank execution")

    graph_name = f"pagerank_{uuid.uuid4().hex}"
    started_at = time.perf_counter()

    logger.info(
        "Starting visual PageRank calculation",
        extra={
            "project_id": normalized_project_id,
            "user_id": user_id,
            "graph_name": graph_name,
        },
    )

    with driver.session() as session:
        try:
            projected_nodes, projected_relationships = _project_graph(
                session,
                graph_name=graph_name,
                project_id=normalized_project_id,
            )
            if projected_nodes == 0 or projected_relationships == 0:
                elapsed_ms = (time.perf_counter() - started_at) * 1000
                logger.info(
                    "Skipping visual PageRank because projection is empty",
                    extra={
                        "project_id": normalized_project_id,
                        "user_id": user_id,
                        "graph_name": graph_name,
                    },
                )
                return {
                    "nodes_processed": 0,
                    "time_ms": elapsed_ms,
                    "min_raw_score": 0.0,
                    "max_raw_score": 0.0,
                }

            streamed_scores = _stream_scores(session, graph_name=graph_name)
            if not streamed_scores:
                elapsed_ms = (time.perf_counter() - started_at) * 1000
                logger.info(
                    "Skipping visual PageRank because no scores were streamed",
                    extra={
                        "project_id": normalized_project_id,
                        "user_id": user_id,
                        "graph_name": graph_name,
                    },
                )
                return {
                    "nodes_processed": 0,
                    "time_ms": elapsed_ms,
                    "min_raw_score": 0.0,
                    "max_raw_score": 0.0,
                }

            batch, min_score, max_score = _scale_scores_to_visual_size(streamed_scores)
            updated_count = _write_visual_sizes(session, batch=batch)
            elapsed_ms = (time.perf_counter() - started_at) * 1000

            logger.info(
                "Completed visual PageRank calculation",
                extra={
                    "project_id": normalized_project_id,
                    "user_id": user_id,
                    "graph_name": graph_name,
                    "projected_nodes": projected_nodes,
                    "projected_relationships": projected_relationships,
                    "streamed_nodes": len(streamed_scores),
                    "updated_nodes": updated_count,
                    "min_raw_score": min_score,
                    "max_raw_score": max_score,
                    "time_ms": elapsed_ms,
                },
            )
            return {
                "nodes_processed": updated_count,
                "time_ms": elapsed_ms,
                "min_raw_score": min_score,
                "max_raw_score": max_score,
            }
        finally:
            try:
                _drop_graph(session, graph_name=graph_name)
                logger.info(
                    "Dropped visual PageRank projection",
                    extra={
                        "project_id": normalized_project_id,
                        "user_id": user_id,
                        "graph_name": graph_name,
                    },
                )
            except Exception:
                logger.warning(
                    "Failed to drop visual PageRank projection",
                    extra={
                        "project_id": normalized_project_id,
                        "user_id": user_id,
                        "graph_name": graph_name,
                    },
                    exc_info=True,
                )
