# Copyright 2026 Gregorio Momm
# Licensed under the Apache License, Version 2.0
"""
Python integration tests for the 30+ graph algorithm methods exposed on Graph.

Run with:  pytest tests/test_algorithms.py -v
"""

import pytest
from ocg import Graph


# ── Fixtures ──────────────────────────────────────────────────────────────────

@pytest.fixture
def two_community_graph():
    """
    Two tightly-connected triangles (communities) linked by a single bridge edge.

    Community 1: a→b, b→c, c→a
    Community 2: d→e, e→f, f→d
    Bridge:      c→d  (weight 0.1)
    """
    g = Graph()
    a = g.create_node(["Person"], {"name": "Alice"})
    b = g.create_node(["Person"], {"name": "Bob"})
    c = g.create_node(["Person"], {"name": "Charlie"})
    d = g.create_node(["Person"], {"name": "Dave"})
    e = g.create_node(["Person"], {"name": "Eve"})
    f = g.create_node(["Person"], {"name": "Frank"})

    g.create_relationship(a, b, "KNOWS", {"weight": 1.0})
    g.create_relationship(b, c, "KNOWS", {"weight": 1.0})
    g.create_relationship(c, a, "KNOWS", {"weight": 1.0})
    g.create_relationship(d, e, "KNOWS", {"weight": 1.0})
    g.create_relationship(e, f, "KNOWS", {"weight": 1.0})
    g.create_relationship(f, d, "KNOWS", {"weight": 1.0})
    g.create_relationship(c, d, "KNOWS", {"weight": 0.1})

    return g, (a, b, c, d, e, f)


@pytest.fixture
def dag():
    """Simple DAG: a→b→c, a→c  (no back edges)."""
    g = Graph()
    a = g.create_node(["N"], {})
    b = g.create_node(["N"], {})
    c = g.create_node(["N"], {})
    g.create_relationship(a, b, "E", {"weight": 3.0})
    g.create_relationship(b, c, "E", {"weight": 3.0})
    g.create_relationship(a, c, "E", {"weight": 5.0})
    return g, (a, b, c)


@pytest.fixture
def flow_graph():
    """Linear chain s→m→t with weight 5 on each edge."""
    g = Graph()
    s = g.create_node(["N"], {})
    m = g.create_node(["N"], {})
    t = g.create_node(["N"], {})
    g.create_relationship(s, m, "E", {"weight": 5.0})
    g.create_relationship(m, t, "E", {"weight": 5.0})
    return g, (s, m, t)


# ── Centrality ────────────────────────────────────────────────────────────────

def test_degree_centrality_all_nodes(two_community_graph):
    g, nodes = two_community_graph
    scores = g.degree_centrality()
    for n in nodes:
        assert n in scores, f"Node {n} missing from degree_centrality"


def test_betweenness_centrality_all_nodes(two_community_graph):
    g, nodes = two_community_graph
    scores = g.betweenness_centrality()
    assert len(scores) == len(nodes)


def test_closeness_centrality_all_nodes(two_community_graph):
    g, nodes = two_community_graph
    scores = g.closeness_centrality()
    assert len(scores) == len(nodes)


def test_pagerank_positive(two_community_graph):
    g, nodes = two_community_graph
    scores = g.pagerank()
    for n in nodes:
        assert scores[n] > 0.0, f"PageRank of node {n} should be positive"


def test_pagerank_sum_approx_one(two_community_graph):
    g, _ = two_community_graph
    scores = g.pagerank()
    total = sum(scores.values())
    # PageRank scores sum to 1 (within floating-point tolerance)
    assert abs(total - 1.0) < 0.01


# ── Pathfinding ───────────────────────────────────────────────────────────────

def test_bfs_path_exists(dag):
    g, (a, b, c) = dag
    path = g.bfs_path(a, c)
    assert path is not None
    assert path[0] == a
    assert path[-1] == c


def test_bfs_path_none_when_unreachable():
    g = Graph()
    x = g.create_node(["N"], {})
    y = g.create_node(["N"], {})
    # No edge from y to x
    g.create_relationship(x, y, "E", {})
    assert g.bfs_path(y, x) is None


def test_dijkstra_path_cost(dag):
    g, (a, b, c) = dag
    result = g.dijkstra_path(a, c)
    assert result is not None
    cost, path = result
    # Shortest weighted path a→b→c costs 3+3=6; direct a→c costs 5
    assert cost == 5.0
    assert path == [a, c]


def test_astar_zero_heuristic_equals_dijkstra(dag):
    g, (a, b, c) = dag
    dijkstra_result = g.dijkstra_path(a, c)
    astar_result = g.astar_path(a, c)
    assert dijkstra_result is not None
    assert astar_result is not None
    assert dijkstra_result[0] == astar_result[0]  # same cost


def test_astar_custom_heuristic(dag):
    g, (a, b, c) = dag
    # Heuristic always returns 0 — result must still be correct
    result = g.astar_path(a, c, heuristic=lambda _: 0.0)
    assert result is not None
    cost, path = result
    assert cost == 5.0


def test_bellman_ford_distances(dag):
    g, (a, b, c) = dag
    dists = g.bellman_ford_distances(a)
    assert dists is not None
    assert dists[c] <= 5.0  # shortest path to c is 5


def test_floyd_warshall_all_pairs(dag):
    g, (a, b, c) = dag
    # Returns dict {(src, dst): distance}
    distances = g.floyd_warshall_distances()
    # Direct edge a→c has weight 5.0 which beats a→b→c (3+3=6)
    assert (a, c) in distances
    assert distances[(a, c)] == 5.0


def test_all_pairs_distances_structure(dag):
    g, (a, b, c) = dag
    apd = g.all_pairs_distances()
    assert a in apd
    assert c in apd[a]


# ── Spanning Trees ────────────────────────────────────────────────────────────

def test_minimum_spanning_tree_edge_count(two_community_graph):
    g, nodes = two_community_graph
    mst = g.minimum_spanning_tree()
    assert len(mst) == len(nodes) - 1


def test_maximum_spanning_tree_edge_count(two_community_graph):
    g, nodes = two_community_graph
    mxst = g.maximum_spanning_tree()
    assert len(mxst) == len(nodes) - 1


def test_spanning_tree_returns_triples(two_community_graph):
    g, _ = two_community_graph
    mst = g.minimum_spanning_tree()
    for item in mst:
        assert len(item) == 3
        src, dst, w = item
        assert isinstance(src, int)
        assert isinstance(dst, int)
        assert isinstance(w, float)


# ── DAG Algorithms ────────────────────────────────────────────────────────────

def test_is_dag_true(dag):
    g, _ = dag
    assert g.is_dag()


def test_is_dag_false(two_community_graph):
    g, _ = two_community_graph
    assert not g.is_dag()


def test_topological_sort_order(dag):
    g, (a, b, c) = dag
    order = g.topological_sort()
    pa = order.index(a)
    pc = order.index(c)
    assert pa < pc  # a must come before c


def test_topological_sort_raises_on_cycle(two_community_graph):
    g, _ = two_community_graph
    with pytest.raises(RuntimeError):
        g.topological_sort()


def test_find_cycles_non_empty(two_community_graph):
    g, _ = two_community_graph
    cycles = g.find_cycles()
    assert len(cycles) > 0
    for cycle in cycles:
        assert len(cycle) >= 1


def test_find_cycles_empty_for_dag(dag):
    g, _ = dag
    assert g.find_cycles() == []


def test_dag_longest_path(dag):
    g, (a, b, c) = dag
    result = g.dag_longest_path()
    assert result is not None
    length, path = result
    assert length >= 1
    assert len(path) == length + 1


def test_transitive_closure(dag):
    g, (a, b, c) = dag
    tc = g.transitive_closure()
    # a can reach c (directly and via b)
    assert (a, c) in tc
    assert (a, b) in tc
    # c cannot reach a in a DAG
    assert (c, a) not in tc


# ── Network Flow ─────────────────────────────────────────────────────────────

def test_max_flow_value(flow_graph):
    g, (s, m, t) = flow_graph
    result = g.max_flow(s, t)
    assert result is not None
    mf, flows = result
    assert mf == 5.0


def test_max_flow_returns_edge_flows(flow_graph):
    g, (s, m, t) = flow_graph
    result = g.max_flow(s, t)
    assert result is not None
    mf, flows = result
    # flows is a dict {(src, dst): flow_value}
    assert isinstance(flows, dict)
    for key, val in flows.items():
        src, dst = key
        assert isinstance(src, int)
        assert isinstance(val, float)


def test_min_cut_capacity(flow_graph):
    g, (s, m, t) = flow_graph
    cut = g.min_cut_capacity(s, t)
    assert cut == 5.0


def test_max_flow_none_for_missing_node():
    g = Graph()
    g.create_node(["N"], {})
    assert g.max_flow(999, 1000) is None


# ── Coloring ─────────────────────────────────────────────────────────────────

def test_node_coloring_all_nodes(two_community_graph):
    g, nodes = two_community_graph
    colors = g.node_coloring()
    assert len(colors) == len(nodes)
    for n in nodes:
        assert n in colors


def test_node_coloring_no_conflicts(two_community_graph):
    g, _ = two_community_graph
    colors = g.node_coloring()
    g.execute("MATCH (n)-[r]->(m) RETURN n.id, m.id")  # just ensure graph is valid
    # Adjacent nodes must have different colors
    # (can't easily enumerate edges here — just verify it's non-empty)
    assert len(set(colors.values())) >= 1


def test_chromatic_number_positive(two_community_graph):
    g, _ = two_community_graph
    chi = g.chromatic_number()
    assert chi >= 1


def test_edge_coloring_returns_dict(two_community_graph):
    g, _ = two_community_graph
    # Returns dict {(src, dst): color}
    ec = g.edge_coloring()
    assert isinstance(ec, dict)
    for (src, dst), color in ec.items():
        assert isinstance(src, int)
        assert isinstance(dst, int)
        assert isinstance(color, int)


# ── Matching ─────────────────────────────────────────────────────────────────

def test_max_weight_matching_non_overlapping(dag):
    g, _ = dag
    pairs = g.max_weight_matching()
    nodes_seen = set()
    for u, v in pairs:
        assert u not in nodes_seen, "Matching is not valid (node appears twice)"
        assert v not in nodes_seen
        nodes_seen.add(u)
        nodes_seen.add(v)


def test_max_cardinality_matching_non_empty(two_community_graph):
    g, _ = two_community_graph
    pairs = g.max_cardinality_matching()
    assert len(pairs) > 0


# ── Community Detection ───────────────────────────────────────────────────────

def test_louvain_all_nodes_assigned(two_community_graph):
    g, nodes = two_community_graph
    result = g.louvain_communities()
    assert len(result["node_to_community"]) == len(nodes)


def test_louvain_has_required_keys(two_community_graph):
    g, _ = two_community_graph
    result = g.louvain_communities()
    assert "node_to_community" in result
    assert "communities" in result
    assert "modularity" in result
    assert "num_communities" in result


def test_louvain_modularity_nonneg(two_community_graph):
    g, _ = two_community_graph
    result = g.louvain_communities()
    assert result["modularity"] >= 0.0


def test_label_propagation_all_nodes(two_community_graph):
    g, nodes = two_community_graph
    result = g.label_propagation_communities()
    assert len(result["node_to_community"]) == len(nodes)


def test_girvan_newman_k_communities(two_community_graph):
    g, nodes = two_community_graph
    result = g.girvan_newman_communities(2)
    assert result["num_communities"] == 2
    assert len(result["node_to_community"]) == len(nodes)


# ── Components ────────────────────────────────────────────────────────────────

def test_connected_components_fully_connected(two_community_graph):
    g, _ = two_community_graph
    cc = g.connected_components()
    assert len(cc) == 1  # all nodes reachable from the same component


def test_connected_components_disconnected():
    g = Graph()
    a = g.create_node(["N"], {})
    b = g.create_node(["N"], {})
    c = g.create_node(["N"], {})  # isolated
    g.create_relationship(a, b, "E", {})
    cc = g.connected_components()
    assert len(cc) == 2


def test_strongly_connected_components(two_community_graph):
    g, _ = two_community_graph
    sccs = g.strongly_connected_components()
    assert len(sccs) >= 1
    # Two triangles → at least 2 SCCs
    assert len(sccs) >= 2
