"""
Relaxed Caveman (RC) benchmark generator.

Faithful Python reimplementation of the original Perl scripts used in:

  - Aldecoa & Marín 2011 (PLoS ONE 6, e24195)
  - Aldecoa & Marín 2012 (Phys Rev E 85, 026109)
  - Aldecoa & Marín 2013 (Sci Rep 3, 2216)

Original Perl scripts archived in ``reference/perl_scripts/``.

Quick start
-----------
::

    from pysurprise.benchmarks.rc_benchmark import rc_benchmark, describe

    # Random broken-stick sizes, 30% open degradation
    edges, gt = rc_benchmark(512, 16, degradation=0.3)

    # Fixed Pielou index, closed degradation
    edges, gt = rc_benchmark(512, 16, degradation=0.5, pielou=0.75, mode="closed")

    # Equal-sized communities, pure edge removal
    edges, gt = rc_benchmark(512, 16, degradation=0.3, sizes=[32]*16, mode="removal")

    # Paper preset (see PAPER_PRESETS dict)
    edges, gt = rc_benchmark(**PAPER_PRESETS["2013_scirep"], degradation=0.3)

Pipeline
--------
  1. ``rc_generate()``        — build isolated cliques  (≡ RC_generator.pl)
  2. ``rc_degrade_open()``    — remove + shuffle edges  (≡ RC_shuffler.pl)
     ``rc_degrade_closed()``  — swap to permuted target (≡ closed_benchmark.pl)
     ``rc_degrade_removal()`` — pure edge removal       (≡ 16_2_512.pl)

Combined:
  ``rc_benchmark()``  — generate + degrade in one call

Degradation modes
-----------------
- ``"open"``:    Remove D% of edges, then shuffle D% of remaining with
  random edges.  Edge count decreases.  (Default.)
- ``"closed"``:  Swap edges toward a permuted-community target.  Edge
  count stays exactly constant.
- ``"removal"``: Pure random edge deletion, no replacement.
"""

from __future__ import annotations

import math
import random
from typing import Dict, List, Optional, Sequence, Tuple

Edge      = Tuple[str, str]
Partition = Dict[str, int]


# ═══════════════════════════════════════════════════════════════════════════
# Helpers
# ═══════════════════════════════════════════════════════════════════════════

def pielou_index(sizes: Sequence[int]) -> float:
    """Pielou's evenness index  J = H' / ln(K).

    Matches ``calcPI`` in the original ``RC_generator.pl``.
    The Perl version rounds to 2 decimal places; this function returns
    the full-precision value.  Use ``round(pielou_index(sizes), 2)``
    to match the Perl rounding.
    """
    n = sum(sizes)
    k = len(sizes)
    if k <= 1 or n == 0:
        return 1.0
    h = -sum((s / n) * math.log(s / n) for s in sizes if s > 0)
    return h / math.log(k)


def broken_stick_sizes(
    n: int,
    k: int,
    rng: random.Random,
    *,
    min_size: int = 2,
    max_attempts: int = 1_000_000,
) -> List[int]:
    """Generate *k* positive integer sizes summing to *n* (broken-stick model).

    Mirrors the original ``RC_generator.pl`` breakpoint generation:
    - K−1 breakpoints drawn from ``int(rand(N))`` (range [0, N−1]).
    - ``checkPoints()`` rejects configurations with any breakpoint < 2
      or > N−2, or any two breakpoints closer than 2 apart.
    - This enforces a **minimum community size of 2** (no singletons).

    Parameters
    ----------
    n          : total number of nodes.
    k          : number of communities.
    rng        : random.Random instance.
    min_size   : minimum community size (default 2, matching Perl).
                 Set to 1 for unrestricted broken-stick.
    max_attempts : maximum rejection-sampling attempts.

    Returns
    -------
    List of k sizes summing to n.
    """
    if k == 1:
        return [n]
    if min_size < 1:
        min_size = 1

    for _ in range(max_attempts):
        # Perl: int(rand($nNodes)) → [0, N-1], then adds 0 and N
        breaks = [rng.randrange(n) for _ in range(k - 1)]

        # Perl checkPoints: reject if any break < 2 or > N-2,
        # or any two breaks are closer than 2
        if min_size >= 2:
            ok = True
            for i in range(k - 1):
                if breaks[i] < min_size or breaks[i] > n - min_size:
                    ok = False
                    break
                for j in range(i + 1, k - 1):
                    if abs(breaks[i] - breaks[j]) < min_size:
                        ok = False
                        break
                if not ok:
                    break
            if not ok:
                continue

        breaks.sort()
        points = [0] + breaks + [n]
        sizes = [points[i + 1] - points[i] for i in range(k)]

        # Final validation
        if all(s >= min_size for s in sizes):
            return sizes

    # Fallback: simple sample-based approach
    breaks = sorted(rng.sample(range(1, n), k - 1))
    points = [0] + breaks + [n]
    return [points[i + 1] - points[i] for i in range(k)]


def _find_sizes_for_pi(
    n: int,
    k: int,
    target_pi: float,
    rng: random.Random,
    *,
    min_size: int = 2,
    pi_decimals: int = 2,
    max_attempts: int = 100_000,
) -> List[int]:
    """Find broken-stick sizes whose Pielou's index matches *target_pi*.

    Mirrors the ``-p`` flag of the original ``RC_generator.pl``:
    generates broken-stick realisations until one matches the target PI
    **rounded to pi_decimals decimal places** (default 2, matching Perl's
    ``sprintf("%.2f", $se)``).

    If no exact match is found, returns the closest realisation
    (matching Perl's fallback: return max or min PI found).

    Parameters
    ----------
    n            : total number of nodes.
    k            : number of communities.
    target_pi    : target Pielou's index (0, 1].
    rng          : random.Random instance.
    min_size     : minimum community size (default 2).
    pi_decimals  : decimal places for PI comparison (default 2).
    max_attempts : maximum search attempts (default 100000).

    Returns
    -------
    List of k sizes summing to n with PI ≈ target_pi.
    """
    search_rng = random.Random(rng.randint(0, 2**31 - 1))

    # Special case: PI = 1 → equal-sized communities
    if target_pi == 1.0:
        if n % k != 0:
            raise ValueError(
                f"PI=1.0 requires N ({n}) divisible by K ({k})."
            )
        return [n // k] * k

    target_rounded = round(target_pi, pi_decimals)
    best: Optional[List[int]] = None
    best_diff = float('inf')
    max_pi: float = 0.0
    min_pi: float = 1.0
    max_sizes: Optional[List[int]] = None
    min_sizes: Optional[List[int]] = None

    for _ in range(max_attempts):
        sizes = broken_stick_sizes(n, k, search_rng, min_size=min_size)
        pi = round(pielou_index(sizes), pi_decimals)

        if pi > max_pi:
            max_pi = pi
            max_sizes = sizes[:]
        if pi < min_pi:
            min_pi = pi
            min_sizes = sizes[:]

        # Exact match (rounded)
        if pi == target_rounded:
            return sorted(sizes)

        diff = abs(pi - target_rounded)
        if diff < best_diff:
            best_diff = diff
            best = sizes[:]

    # Perl fallback: return closest (max or min)
    if target_rounded > max_pi and max_sizes is not None:
        return sorted(max_sizes)
    if target_rounded < min_pi and min_sizes is not None:
        return sorted(min_sizes)
    assert best is not None
    return sorted(best)


def _fisher_yates_shuffle(lst: list, rng: random.Random) -> None:
    """In-place Fisher-Yates shuffle matching the Perl implementation.

    Perl: ``for ($i = @$array; --$i; ) { $j = int rand($i+1); ... }``
    """
    for i in range(len(lst) - 1, 0, -1):
        j = rng.randrange(i + 1)
        if i != j:
            lst[i], lst[j] = lst[j], lst[i]


# ═══════════════════════════════════════════════════════════════════════════
# Step 1 — Generate isolated cliques  (≡ RC_generator.pl)
# ═══════════════════════════════════════════════════════════════════════════

def rc_generate(
    n_nodes:       int = 512,
    n_communities: int = 16,
    *,
    pielou:   Optional[float]     = None,
    sizes:    Optional[List[int]] = None,
    min_size: int = 2,
    seed:     Optional[int]       = None,
) -> Tuple[List[Edge], Partition, List[int]]:
    """Build a network of isolated cliques.

    Community sizes are determined in order of priority:

    1. **sizes** — explicit list of community sizes (must sum to n_nodes).
    2. **pielou** — target Pielou's index; searches broken-stick until match.
    3. **neither** — single random broken-stick draw.

    Parameters
    ----------
    n_nodes       : number of nodes (default 512).
    n_communities : number of cliques (default 16).
    pielou        : target Pielou's index.  ``None`` → see priorities above.
    sizes         : explicit community sizes. Overrides pielou and n_communities.
    min_size      : minimum community size for broken-stick (default 2,
                    matching Perl's ``checkPoints``). Set to 1 for unrestricted.
    seed          : RNG seed for reproducibility.

    Returns
    -------
    edges        : clique edge list ``[(str, str), …]``
    ground_truth : ``{node_label: community_id}``
    sizes        : community sizes sorted ascending (matching Perl output order)
    """
    rng = random.Random(seed)

    # ── community sizes ──────────────────────────────────────────────────
    if sizes is not None:
        comm_sizes = list(sizes)
        if sum(comm_sizes) != n_nodes:
            raise ValueError(
                f"sizes sum to {sum(comm_sizes)}, expected {n_nodes}."
            )
        n_communities = len(comm_sizes)
    elif pielou is not None:
        comm_sizes = _find_sizes_for_pi(
            n_nodes, n_communities, pielou, rng, min_size=min_size)
    else:
        comm_sizes = broken_stick_sizes(
            n_nodes, n_communities, rng, min_size=min_size)

    # Sort ascending (matching Perl: @clusters = sort {$a <=> $b} (@clusters))
    comm_sizes = sorted(comm_sizes)

    # ── node → community assignment ──────────────────────────────────────
    community_of: Dict[int, int] = {}
    communities:  Dict[int, List[int]] = {}
    idx = 0
    for c, sz in enumerate(comm_sizes):
        members = list(range(idx, idx + sz))
        communities[c] = members
        for v in members:
            community_of[v] = c
        idx += sz

    # ── perfect cliques ──────────────────────────────────────────────────
    edges: List[Tuple[int, int]] = []
    for members in communities.values():
        for i in range(len(members)):
            for j in range(i + 1, len(members)):
                u, v = members[i], members[j]
                edges.append((min(u, v), max(u, v)))

    str_edges: List[Edge]   = [(str(u), str(v)) for u, v in edges]
    ground_truth: Partition  = {str(v): community_of[v]
                                for v in range(n_nodes)}
    return str_edges, ground_truth, comm_sizes


# ═══════════════════════════════════════════════════════════════════════════
# Step 2a — Open degradation: remove + shuffle  (≡ RC_shuffler.pl)
# ═══════════════════════════════════════════════════════════════════════════

def rc_degrade_open(
    edges:       List[Edge],
    n_nodes:     int,
    degradation: float,
    *,
    seed: Optional[int] = None,
) -> List[Edge]:
    """Open benchmark degradation matching ``RC_shuffler.pl``.

    Two-step process (verified against actual RC75 data files):

      1. **Remove** ``floor(n_edges × D)`` edges at random.
      2. **Shuffle** ``floor(remaining × D)`` of the remaining edges:
         for each shuffle iteration, remove the first edge from a
         randomly-permuted list and add a new edge between two uniformly
         random distinct nodes (if not already present in hash).

    Faithfully reproduces the Perl behaviour including:
    - Hash only tracks one direction of deleted edges (half-deletion bug).
    - New random edges not added to hash (allows rare duplicates).
    - Net effect: edge count = ``n_edges × (1 − D)`` with small duplicate rate.

    **Verified**: edge counts and intra/inter community fractions match
    RC75 data within expected random variation.

    Parameters
    ----------
    edges       : original clique edges (not modified).
    n_nodes     : total number of nodes in the graph.
    degradation : D ∈ [0, 1].
    seed        : RNG seed.

    Returns
    -------
    degraded edge list ``[(str, str), …]``
    """
    if degradation <= 0.0:
        return list(edges)

    rng = random.Random(seed)

    # work with integer tuples — build edge list (preserves order for shuffle)
    edge_list: List[Tuple[int, int]] = []
    for u_s, v_s in edges:
        u, v = int(u_s), int(v_s)
        edge_list.append((min(u, v), max(u, v)))

    n_orig = len(edge_list)

    # Perl: my @edges = shuffle(@origEdges);
    _fisher_yates_shuffle(edge_list, rng)

    # ── step 1: remove D % of edges (shift from front) ──────────────────
    n_remove = math.floor(n_orig * degradation)
    edge_list = edge_list[n_remove:]  # Perl: shift @edges × n_remove

    # ── build hash from remaining edges ──────────────────────────────────
    # Perl: $e{$a}{$b} = $e{$b}{$a} = 1;  (both directions)
    hash_ab: set[Tuple[int, int]] = set()      # (a, b) direction
    hash_ba: set[Tuple[int, int]] = set()      # (b, a) direction
    for a, b in edge_list:
        hash_ab.add((a, b))
        hash_ba.add((b, a))

    # ── step 2: shuffle D % of remaining ─────────────────────────────────
    n_shuffle = math.floor(len(edge_list) * degradation)
    for _ in range(n_shuffle):
        # Perl: $a = floor(rand($nNodes)); $b = floor(rand($nNodes));
        # do { ... } while(defined($e{$a}{$b}) || defined($e{$b}{$a}));
        while True:
            a = rng.randrange(n_nodes)
            b = rng.randrange(n_nodes)
            if a == b:
                continue
            # Perl checks: defined($e{$a}{$b}) || defined($e{$b}{$a})
            if (a, b) not in hash_ab and (a, b) not in hash_ba and \
               (b, a) not in hash_ab and (b, a) not in hash_ba:
                break

        # Perl: my $deleted = shift @edges;
        deleted = edge_list.pop(0)

        # Perl: undef($e{$x}{$y}); — only ONE direction!
        dx, dy = deleted
        hash_ab.discard((dx, dy))
        # NOTE: hash_ba still has (dy, dx) → "phantom" blocking

        # Perl: push @edges, "$a\t$b\n";
        edge_list.append((min(a, b), max(a, b)))
        # NOTE: new edge NOT added to hash (Perl bug → allows duplicates)

    return [(str(u), str(v)) for u, v in edge_list]


# ═══════════════════════════════════════════════════════════════════════════
# Step 2b — Closed degradation: swap to permuted target  (≡ closed_benchmark.pl)
# ═══════════════════════════════════════════════════════════════════════════

def rc_degrade_closed(
    edges:       List[Edge],
    ground_truth: Partition,
    n_nodes:     int,
    degradation: float,
    *,
    seed: Optional[int] = None,
) -> Tuple[List[Edge], Partition]:
    """Closed benchmark degradation matching ``closed_benchmark.pl``.

    Creates a target graph by permuting node labels (Fisher-Yates shuffle)
    while preserving community structure. Then progressively swaps edges
    from the initial graph to the target graph.

    **Edge count stays exactly constant.**

    This is the degradation used in Aldecoa & Marín 2012 (Phys Rev E).

    Parameters
    ----------
    edges        : original clique edges.
    ground_truth : ``{node_label: community_id}`` of original graph.
    n_nodes      : total number of nodes.
    degradation  : fraction of swappable edges to convert ∈ [0, 1].
    seed         : RNG seed.

    Returns
    -------
    (degraded_edges, final_ground_truth)
        The final ground truth is the permuted-target partition.
    """
    if degradation <= 0.0:
        return list(edges), dict(ground_truth)

    rng = random.Random(seed)

    # ── build initial graph as adjacency ──────────────────────────────────
    labels = sorted(ground_truth.keys(), key=int)
    n2c = {k: v for k, v in ground_truth.items()}

    # Fisher-Yates shuffle of labels → target mapping
    flabels = list(labels)
    _fisher_yates_shuffle(flabels, rng)

    # initial→final and final→initial mappings
    i2f = {labels[i]: flabels[i] for i in range(n_nodes)}
    f2i = {flabels[i]: labels[i] for i in range(n_nodes)}

    # Final ground truth: final node inherits community of its mapped initial node
    final_gt: Partition = {}
    for n_label in labels:
        fn = i2f[n_label]
        final_gt[fn] = n2c[n_label]

    # Build adjacency sets
    g: set[Tuple[str, str]] = set()
    for u, v in edges:
        g.add((min(u, v), max(u, v)))

    # Compute @noGraph: edges in final graph but not in initial
    # Final graph has same edges as initial but with remapped nodes
    no_graph: List[Tuple[str, str]] = []
    for u_init, v_init in edges:
        u_final = i2f[u_init]
        v_final = i2f[v_init]
        edge_final = (min(u_final, v_final), max(u_final, v_final))
        if edge_final not in g:
            no_graph.append(edge_final)

    n_links = len(no_graph)
    if n_links == 0:
        return list(edges), final_gt

    # ── perform swaps ─────────────────────────────────────────────────────
    n_swap = int(round(n_links * degradation))
    n_swap = min(n_swap, n_links)

    current_edges = set(g)

    # Perl: pick random edge from @graph, check it's in the final-mapped
    # pool (i.e., its inverse-mapped version is NOT in initial→final edges),
    # splice it out, splice a random @noGraph edge in.
    graph_list_mut = list(current_edges)
    rng.shuffle(graph_list_mut)
    no_graph_mut = list(no_graph)
    rng.shuffle(no_graph_mut)

    swapped = 0
    attempts = 0
    max_att = n_swap * 100

    while swapped < n_swap and attempts < max_att and no_graph_mut:
        attempts += 1
        idx = rng.randrange(len(graph_list_mut))
        edge = graph_list_mut[idx]

        # Check: is this edge NOT in the final graph?
        # Perl: if($comm{$graph[$r]->[0]} ne $comm{$graph[$r]->[1]})
        # Equivalent: the mapped-back nodes must not share community in final
        u_back = f2i.get(edge[0], edge[0])
        v_back = f2i.get(edge[1], edge[1])
        e_back = (min(u_back, v_back), max(u_back, v_back))
        if e_back not in g:
            # This edge can be swapped
            graph_list_mut.pop(idx)
            current_edges.discard(edge)

            new_edge = no_graph_mut.pop()
            graph_list_mut.append(new_edge)
            current_edges.add(new_edge)

            swapped += 1

    return [(u, v) for u, v in current_edges], final_gt


# ═══════════════════════════════════════════════════════════════════════════
# Step 2c — Pure edge removal  (≡ 16_2_512.pl)
# ═══════════════════════════════════════════════════════════════════════════

def rc_degrade_removal(
    edges:       List[Edge],
    degradation: float,
    *,
    seed: Optional[int] = None,
) -> List[Edge]:
    """Pure edge removal degradation matching ``16_2_512.pl``.

    Simply removes D% of edges at random, with no replacement.
    This is the simplest degradation used in the 2011 paper for the
    "16 communities to 512 singletons" open benchmarks.

    **Edge count = original × (1 − D).**

    Parameters
    ----------
    edges       : original clique edges (not modified).
    degradation : D ∈ [0, 1].
    seed        : RNG seed.

    Returns
    -------
    degraded edge list ``[(str, str), …]``
    """
    if degradation <= 0.0:
        return list(edges)

    rng = random.Random(seed)

    edge_list = list(edges)
    n_orig = len(edge_list)

    # Perl: progressively splice random edges out
    n_remove = math.floor(n_orig * degradation)
    # Use Fisher-Yates partial shuffle to select n_remove random indices
    indices = list(range(n_orig))
    for i in range(n_remove):
        j = rng.randrange(i, n_orig)
        indices[i], indices[j] = indices[j], indices[i]

    remove_set = set(indices[:n_remove])
    return [e for i, e in enumerate(edge_list) if i not in remove_set]


# Backward-compatible alias
rc_degrade = rc_degrade_open


# ═══════════════════════════════════════════════════════════════════════════
# Combined entry point
# ═══════════════════════════════════════════════════════════════════════════

def rc_benchmark(
    n_nodes:       int   = 512,
    n_communities: int   = 16,
    degradation:   float = 0.0,
    *,
    pielou:   Optional[float]     = None,
    sizes:    Optional[List[int]] = None,
    mode:     str = "open",
    min_size: int = 2,
    seed:     Optional[int]       = 3481,
) -> Tuple[List[Edge], Partition]:
    """Generate a Relaxed Caveman benchmark graph (cliques + degradation).

    This is the main entry point.  It generates isolated cliques and then
    degrades them according to *mode*.  Uses one master seed to derive
    **independent** seeds for generation and degradation, so the same
    clique structure receives reproducible but independent degradation at
    every D value.

    Parameters
    ----------
    n_nodes       : total nodes (default 512).
    n_communities : number of communities (default 16).
    degradation   : D ∈ [0, 1] (default 0).
    pielou        : target Pielou's index.
                    ``None`` → random broken-stick sizes.
    sizes         : explicit community sizes (overrides pielou and n_communities).
    mode          : degradation type:

                    - ``"open"``    — remove D% + shuffle D% of remaining
                    - ``"closed"``  — swap toward permuted target (constant edges)
                    - ``"removal"`` — pure edge deletion
    min_size      : minimum community size for broken-stick (default 2).
    seed          : master RNG seed (default 3481).

    Returns
    -------
    edges : list of (str, str)
    ground_truth : dict {node_label: community_id}

    Examples
    --------
    ::

        # Random broken-stick sizes, 30% open degradation
        edges, gt = rc_benchmark(512, 16, degradation=0.3)

        # Fixed Pielou index, closed degradation
        edges, gt = rc_benchmark(512, 16, 0.5, pielou=0.75, mode="closed")

        # Equal-sized communities, pure removal
        edges, gt = rc_benchmark(512, 16, 0.3, sizes=[32]*16, mode="removal")

        # Paper preset
        edges, gt = rc_benchmark(**PAPER_PRESETS["2013_scirep"], degradation=0.3)
    """
    master = random.Random(seed)
    gen_seed = master.randint(0, 2**31 - 1)
    deg_seed = master.randint(0, 2**31 - 1)

    clique_edges, gt, _sizes = rc_generate(
        n_nodes, n_communities,
        pielou=pielou, sizes=sizes, min_size=min_size, seed=gen_seed,
    )

    if degradation <= 0.0:
        return clique_edges, gt

    if mode == "open":
        deg_edges = rc_degrade_open(
            clique_edges, n_nodes, degradation, seed=deg_seed)
        return deg_edges, gt
    elif mode == "closed":
        deg_edges, final_gt = rc_degrade_closed(
            clique_edges, gt, n_nodes, degradation, seed=deg_seed)
        return deg_edges, final_gt
    elif mode == "removal":
        deg_edges = rc_degrade_removal(
            clique_edges, degradation, seed=deg_seed)
        return deg_edges, gt
    else:
        raise ValueError(
            f"Unknown mode '{mode}'. Use 'open', 'closed', or 'removal'."
        )


# Backward-compatible alias
aldecoa_rc_graph = rc_benchmark


# ═══════════════════════════════════════════════════════════════════════════
# Paper presets & constants
# ═══════════════════════════════════════════════════════════════════════════

RC_ASYMMETRIC_SIZES = [2, 6, 6, 10, 10, 10, 11, 15, 15, 30, 37, 52, 55, 63, 92, 98]
"""Fixed asymmetric community sizes used in the 2011 PLoS ONE paper."""

# Backward-compatible alias
ALDECOA2011_ASYM_SIZES = RC_ASYMMETRIC_SIZES

PAPER_PRESETS: Dict[str, dict] = {
    # ── Aldecoa & Marín 2011 (PLoS ONE) ──────────────────────────────────
    "2011_open_rc": dict(
        n_nodes=512, n_communities=16, mode="open", min_size=2,
    ),
    "2011_closed_symmetric": dict(
        n_nodes=512, n_communities=16, sizes=[32] * 16, mode="closed",
    ),
    "2011_closed_asymmetric": dict(
        n_nodes=512, n_communities=16, sizes=RC_ASYMMETRIC_SIZES, mode="closed",
    ),
    "2011_open_symmetric": dict(
        n_nodes=512, n_communities=16, sizes=[32] * 16, mode="removal",
    ),
    "2011_open_asymmetric": dict(
        n_nodes=512, n_communities=16, sizes=RC_ASYMMETRIC_SIZES, mode="removal",
    ),
    # ── Aldecoa & Marín 2012 (Phys Rev E) ────────────────────────────────
    "2012_closed_rc": dict(
        n_nodes=512, n_communities=16, pielou=0.75, mode="closed", min_size=2,
    ),
    # ── Aldecoa & Marín 2013 (Sci Rep) ───────────────────────────────────
    "2013_scirep": dict(
        n_nodes=512, n_communities=16, pielou=0.75, mode="open", min_size=2,
    ),
}
"""Pre-configured parameter dicts for reproducing specific papers.

Usage::

    edges, gt = rc_benchmark(**PAPER_PRESETS["2013_scirep"], degradation=0.5, seed=42)
"""


# ═══════════════════════════════════════════════════════════════════════════
# Diagnostics
# ═══════════════════════════════════════════════════════════════════════════

def describe(
    n_nodes:       int = 512,
    n_communities: int = 16,
    seed:          Optional[int] = 2013,
    pielou:        Optional[float] = None,
    sizes:         Optional[List[int]] = None,
    min_size:      int = 2,
) -> None:
    """Print community size distribution, Pielou's index, and edge count."""
    if sizes is not None:
        comm_sizes = sorted(sizes)
    else:
        rng = random.Random(seed)
        if pielou is not None:
            comm_sizes = _find_sizes_for_pi(
                n_nodes, n_communities, pielou, rng, min_size=min_size)
        else:
            comm_sizes = broken_stick_sizes(
                n_nodes, n_communities, rng, min_size=min_size)
    comm_sizes_desc = sorted(comm_sizes, reverse=True)
    pi = pielou_index(comm_sizes)
    n_edges = sum(s * (s - 1) // 2 for s in comm_sizes)

    print(f"N={n_nodes}, K={len(comm_sizes)}, seed={seed}"
          f"{f', target PI={pielou}' if pielou else ''}")
    print(f"  Pielou's index J = {pi:.4f} (rounded: {round(pi, 2):.2f})")
    print(f"  sizes (desc): {comm_sizes_desc}")
    print(f"  min={min(comm_sizes)}, max={max(comm_sizes)}, "
          f"ratio={max(comm_sizes)/max(min(comm_sizes),1):.1f}x")
    print(f"  edges (cliques): {n_edges}")
    print(f"  density: {2*n_edges/(n_nodes*(n_nodes-1)):.4f}")
