"""Show detected clusters and directory mismatches.

Output formats: text (default), ``--json``. SARIF is deliberately NOT
emitted because clusters outputs are invocation-scoped cluster rankings
— not per-location violations. Multi-file expansion would distort SARIF
semantics. See action.yml _SUPPORTED_SARIF allowlist + W1175-RESEARCH
Bucket B propagation plan + W1148 audit memo.
"""

from __future__ import annotations

from collections import defaultdict

import click

from roam.capability import roam_capability
from roam.commands.resolve import empty_corpus_state, ensure_index
from roam.db.connection import open_db
from roam.db.queries import ALL_CLUSTERS
from roam.graph.builder import build_symbol_graph
from roam.graph.clusters import cluster_quality, compare_with_directories
from roam.output.formatter import format_table, json_envelope, strip_list_payloads, to_json
from roam.output.mermaid import (
    diagram as mdiagram,
)
from roam.output.mermaid import (
    edge as medge,
)
from roam.output.mermaid import (
    node as mnode,
)
from roam.output.mermaid import (
    subgraph as msubgraph,
)


def _compute_cohesion(conn):
    """Compute per-cluster intra/total edge counts and inter-cluster pairs."""
    cluster_rows = conn.execute("SELECT symbol_id, cluster_id FROM clusters").fetchall()
    sym_to_cluster = {r["symbol_id"]: r["cluster_id"] for r in cluster_rows}
    edges = conn.execute("SELECT source_id, target_id FROM edges").fetchall()

    intra: dict[int, int] = defaultdict(int)
    total: dict[int, int] = defaultdict(int)
    inter_pairs: dict[tuple, int] = defaultdict(int)
    for e in edges:
        c_src = sym_to_cluster.get(e["source_id"])
        c_tgt = sym_to_cluster.get(e["target_id"])
        if c_src is None or c_tgt is None:
            continue
        if c_src == c_tgt:
            intra[c_src] += 1
            total[c_src] += 1
        else:
            pair = (min(c_src, c_tgt), max(c_src, c_tgt))
            inter_pairs[pair] += 1
            total[c_src] += 1
            total[c_tgt] += 1

    # Plain dicts out: callers index these directly (e.g. ``total[cid]``) and
    # must keep KeyError-on-missing semantics.
    return edges, dict(intra), dict(total), dict(inter_pairs)


def _print_mega_detail(conn, visible, mega_ids, total_symbols, intra_count, total_count, median_cohesion, edges):
    """Print mega-cluster sub-directory breakdowns and coupling matrices."""
    click.echo("\n=== Mega-Cluster Detail ===")

    # Pre-fetch all symbols belonging to any visible mega-cluster in a
    # single batched query, then group by cluster_id in Python. Replaces
    # the per-cluster SELECT that was an N+1 in the loop body below.
    visible_mega_ids = [r["cluster_id"] for r in visible if r["cluster_id"] in mega_ids]
    syms_by_cluster: dict[int, list] = {cid: [] for cid in visible_mega_ids}
    if visible_mega_ids:
        from roam.db.connection import batched_in

        rows = batched_in(
            conn,
            "SELECT c.cluster_id, s.id, s.name, s.kind, f.path, "
            "COALESCE(gm.pagerank, 0) as pagerank "
            "FROM clusters c "
            "JOIN symbols s ON c.symbol_id = s.id "
            "JOIN files f ON s.file_id = f.id "
            "LEFT JOIN graph_metrics gm ON s.id = gm.symbol_id "
            "WHERE c.cluster_id IN ({ph})",
            visible_mega_ids,
        )
        for row in rows:
            syms_by_cluster.setdefault(row["cluster_id"], []).append(row)

    for r in visible:
        cid = r["cluster_id"]
        if cid not in mega_ids:
            continue
        pct = r["size"] * 100 / total_symbols if total_symbols else 0
        c_coh = intra_count.get(cid, 0) * 100 / total_count[cid] if total_count.get(cid) else 0
        coh_ctx = "above" if c_coh >= median_cohesion else "below"
        click.echo(
            f"\n  Cluster {cid}: {r['cluster_label']} "
            f"({r['size']} symbols, {pct:.0f}% of graph, "
            f"cohesion {c_coh:.0f}% — {coh_ctx} median {median_cohesion:.0f}%)"
        )

        c_syms = syms_by_cluster.get(cid, [])

        dir_syms: dict[str, list] = {}
        for s in c_syms:
            p = s["path"].replace("\\", "/")
            parts = p.split("/")
            if len(parts) >= 3:
                d = "/".join(parts[:2])
            elif len(parts) >= 2:
                d = parts[0]
            else:
                d = "."
            dir_syms.setdefault(d, []).append(s)

        sorted_dirs = sorted(dir_syms.items(), key=lambda x: -len(x[1]))

        group_labels = "ABCDEFGH"
        big_groups = []
        click.echo("    Sub-groups:")
        for idx, (d, syms) in enumerate(sorted_dirs[:8]):
            dpct = len(syms) * 100 / r["size"] if r["size"] else 0
            top3 = sorted(syms, key=lambda s: -s["pagerank"])[:3]
            names = ", ".join(f"{s['name']}" for s in top3)
            label = group_labels[idx] if idx < len(group_labels) else str(idx)
            click.echo(f"      {label}: {d + '/':<36s} {len(syms):>4d} ({dpct:>2.0f}%)  {names}")
            if dpct >= 10:
                big_groups.append((label, d, {s["id"] for s in syms}))
        if len(sorted_dirs) > 8:
            click.echo(f"      (+{len(sorted_dirs) - 8} more directories)")

        _print_coupling_matrix(big_groups, edges)


def _print_coupling_matrix(big_groups, edges):
    """Print cross-coupling matrix between big sub-groups of a mega-cluster."""
    if len(big_groups) < 2:
        return
    sym_to_grp = {}
    for lbl, d, sids in big_groups:
        for sid in sids:
            sym_to_grp[sid] = lbl

    pair_edges: dict[tuple, int] = defaultdict(int)
    grp_internal: dict[str, int] = defaultdict(int)
    for e in edges:
        g_src = sym_to_grp.get(e["source_id"])
        g_tgt = sym_to_grp.get(e["target_id"])
        if g_src and g_tgt:
            if g_src == g_tgt:
                grp_internal[g_src] += 1
            else:
                pair = (min(g_src, g_tgt), max(g_src, g_tgt))
                pair_edges[pair] += 1

    click.echo("    Coupling matrix:")
    total_internal = sum(grp_internal.values())
    total_cross = sum(pair_edges.values())
    total_grp = total_internal + total_cross

    for i, (lbl_a, d_a, _) in enumerate(big_groups):
        for lbl_b, d_b, _ in big_groups[i + 1 :]:
            pair = (min(lbl_a, lbl_b), max(lbl_a, lbl_b))
            cnt = pair_edges.get(pair, 0)
            cpct = cnt * 100 / total_grp if total_grp else 0
            d_a_short = d_a.rsplit("/", 1)[-1] if "/" in d_a else d_a
            d_b_short = d_b.rsplit("/", 1)[-1] if "/" in d_b else d_b
            if cnt > 0:
                click.echo(f"      {lbl_a}({d_a_short}) <-> {lbl_b}({d_b_short}): {cnt} edges ({cpct:.0f}%)")

    if total_grp > 0:
        overall_cross = total_cross * 100 / total_grp
        if overall_cross < 20:
            click.echo(
                f"    ** Consider splitting: {len(big_groups)} sub-groups, "
                f"only {overall_cross:.0f}% cross-group coupling — clear seams"
            )
        elif overall_cross < 40:
            click.echo(f"    Cross-group coupling: {overall_cross:.0f}% — moderate, some seams visible")


def _clusters_mermaid(conn, rows, min_size):
    """Generate a Mermaid left-right diagram for code clusters.

    Shows each visible cluster as a subgraph with its top members,
    and inter-cluster edges.  Returns the diagram as a string.
    """
    visible = [r for r in rows if r["size"] >= min_size]
    if not visible:
        return mdiagram("LR", ['    empty["No clusters detected"]'])

    elements: list[str] = []
    members_per_cluster = 5

    # Build subgraph for each cluster
    for r in visible[:15]:  # Cap at 15 clusters for readability
        cid = r["cluster_id"]
        label = r["cluster_label"] or f"Cluster {cid}"
        # Fetch top symbols by PageRank for this cluster
        top_syms = conn.execute(
            "SELECT s.name, s.kind, f.path, COALESCE(gm.pagerank, 0) as pr "
            "FROM clusters c "
            "JOIN symbols s ON c.symbol_id = s.id "
            "JOIN files f ON s.file_id = f.id "
            "LEFT JOIN graph_metrics gm ON s.id = gm.symbol_id "
            "WHERE c.cluster_id = ? "
            "ORDER BY pr DESC LIMIT ?",
            (cid, members_per_cluster),
        ).fetchall()
        node_lines = []
        for s in top_syms:
            node_id = f"c{cid}_{s['name']}"
            node_lines.append(mnode(node_id, s["name"]))
        elements.append(msubgraph(f"Cluster {cid}: {label}", node_lines))

    # Add inter-cluster edges
    _, _, _, inter_pairs = _compute_cohesion(conn)
    visible_ids = {r["cluster_id"] for r in visible[:15]}
    visible_pairs = {k: v for k, v in inter_pairs.items() if k[0] in visible_ids and k[1] in visible_ids}
    top_inter = sorted(visible_pairs.items(), key=lambda x: -x[1])[:10]
    for (ca, cb), cnt in top_inter:
        # Use cluster-level node IDs for inter-cluster edges
        # Pick the first symbol from each cluster as the edge anchor
        src_sym = conn.execute(
            "SELECT s.name FROM clusters c JOIN symbols s ON c.symbol_id = s.id "
            "LEFT JOIN graph_metrics gm ON s.id = gm.symbol_id "
            "WHERE c.cluster_id = ? ORDER BY COALESCE(gm.pagerank, 0) DESC LIMIT 1",
            (ca,),
        ).fetchone()
        tgt_sym = conn.execute(
            "SELECT s.name FROM clusters c JOIN symbols s ON c.symbol_id = s.id "
            "LEFT JOIN graph_metrics gm ON s.id = gm.symbol_id "
            "WHERE c.cluster_id = ? ORDER BY COALESCE(gm.pagerank, 0) DESC LIMIT 1",
            (cb,),
        ).fetchone()
        if src_sym and tgt_sym:
            elements.append(medge(f"c{ca}_{src_sym['name']}", f"c{cb}_{tgt_sym['name']}"))

    return mdiagram("LR", elements)


def _clusters_json(conn, rows, min_size, quality, mermaid=None, detail=True, token_budget=0):
    """Emit JSON output for clusters command."""
    visible = [r for r in rows if r["size"] >= min_size]
    mismatches = compare_with_directories(conn)
    visible_ids = {r["cluster_id"] for r in visible}

    _, intra, total, _ = _compute_cohesion(conn)

    extra = {}
    if mermaid is not None:
        extra["mermaid"] = mermaid

    # Build verdict + a machine-readable state (W805-BB Pattern-2). A bare
    # "no clusters detected" conflated three distinct zero-cluster cases; an
    # agent could not tell whether to re-index, lower --min-size, or accept
    # that there is no modular structure. Disclose which case fired:
    #   clusters_detected  — real decomposition surfaced (Newman Q > 0)
    #   trivial_clustering — clusters exist but modularity Q<=0 (no real structure)
    #   empty_corpus       — 0 symbols indexed (no graph to cluster)
    #   no_clusters        — symbols exist but Louvain found no structure
    #   below_min_size     — clusters exist but all smaller than --min-size
    n_visible = len(visible)
    largest = max(visible, key=lambda r: r["size"]) if visible else None
    mod_q = quality["modularity"]
    summary = {
        "clusters": n_visible,
        "mismatches": sum(1 for m in mismatches if m["cluster_id"] in visible_ids),
        "modularity_q": mod_q,
        "mean_conductance": quality["mean_conductance"],
    }
    if largest and mod_q > 0.0:
        summary["verdict"] = f"{n_visible} clusters, largest: {largest['cluster_label']}({largest['size']} syms)"
        summary["state"] = "clusters_detected"
    elif largest:
        # Visible clusters exist but Newman modularity Q<=0 means there is no real
        # community structure (Q>0.3 is meaningful; Newman 2004). Disclose so an
        # agent does not read "N clusters" as a genuine architectural
        # decomposition and proceed to refactor a phantom cluster. (Now that
        # modularity is computed against a repaired partition, Q<=0 is an honest
        # signal — a well-modularized repo reports Q~0.8, not 0.)
        summary["verdict"] = f"{n_visible} trivial cluster(s), modularity Q={mod_q} — no community structure detected"
        summary["state"] = "trivial_clustering"
        summary["partial_success"] = True
    else:
        _empty = empty_corpus_state(conn)
        if _empty is not None:
            # Keep "no clusters" in the verdict (sealed contract) while
            # disclosing the empty corpus via the canonical state + flag.
            summary["verdict"] = "no clusters — no symbols indexed in corpus (run `roam index --force`)"
            summary.update(_empty)  # state="empty_corpus", partial_success=True
        elif not rows:
            summary["verdict"] = "no clusters detected — no community structure found in the symbol graph"
            summary["state"] = "no_clusters"
            summary["partial_success"] = True
        else:
            summary["verdict"] = f"no clusters >= min-size {min_size} ({len(rows)} below threshold)"
            summary["state"] = "below_min_size"

    envelope = json_envelope(
        "clusters",
        summary=summary,
        budget=token_budget,
        clusters=[
            {
                "id": r["cluster_id"],
                "label": r["cluster_label"],
                "size": r["size"],
                "cohesion_pct": round(intra.get(r["cluster_id"], 0) * 100 / total[r["cluster_id"]])
                if total.get(r["cluster_id"])
                else 0,
                "conductance": quality["per_cluster"].get(r["cluster_id"], 0.0),
                "members": r["members"] or "",
            }
            for r in visible
        ],
        mismatches=[
            {
                "cluster_id": m["cluster_id"],
                "label": m["cluster_label"],
                "mismatch_count": m["mismatch_count"],
                "directories": m["directories"],
            }
            for m in mismatches
            if m["cluster_id"] in visible_ids
        ],
        **extra,
    )
    if not detail:
        envelope = strip_list_payloads(envelope)
    click.echo(to_json(envelope))


@roam_capability(
    name="clusters",
    category="architecture",
    summary="Show code clusters and directory mismatches",
    maturity="stable",
    mcp_expose=True,
    mcp_preset=("core", "architecture"),
    side_effect=False,
    task_required=False,
    destructive=False,
    stale_sensitive=True,
    ai_safe=True,
    requires_index=True,
)
@click.command("clusters")
@click.option("--min-size", default=3, show_default=True, help="Hide clusters smaller than this")
@click.option("--mermaid", "mermaid_mode", is_flag=True, help="Output Mermaid diagram")
@click.option(
    "--weak",
    "weak_mode",
    is_flag=True,
    default=False,
    help=(
        "Rank clusters by lowest intra-density (split candidates). "
        "Round 3 #21, B: surfaces clusters whose internal edges are weaker "
        "than their cross-cluster coupling — refactor candidates."
    ),
)
@click.option(
    "--strong",
    "strong_mode",
    is_flag=True,
    default=False,
    help=(
        "Rank clusters by highest intra-density (well-formed modules). "
        "Useful as a 'leave these alone' baseline when planning a refactor."
    ),
)
@click.pass_context
def clusters_cmd(ctx, min_size, mermaid_mode, weak_mode, strong_mode):
    """Show code clusters and directory mismatches."""
    json_mode = ctx.obj.get("json") if ctx.obj else False
    detail = ctx.obj.get("detail", False) if ctx.obj else False
    token_budget = ctx.obj.get("budget", 0) if ctx.obj else 0
    ensure_index()
    with open_db(readonly=True) as conn:
        rows = conn.execute(ALL_CLUSTERS).fetchall()

        G = build_symbol_graph(conn)
        cluster_map_rows = conn.execute("SELECT symbol_id, cluster_id FROM clusters").fetchall()
        cluster_map = {r["symbol_id"]: r["cluster_id"] for r in cluster_map_rows}
        quality = cluster_quality(G, cluster_map)

        if mermaid_mode:
            mermaid_text = _clusters_mermaid(conn, rows, min_size)
            if json_mode:
                _clusters_json(
                    conn,
                    rows,
                    min_size,
                    quality,
                    mermaid=mermaid_text,
                    detail=detail,
                    token_budget=token_budget,
                )
            else:
                click.echo(mermaid_text)
            return

        if json_mode:
            _clusters_json(conn, rows, min_size, quality, detail=detail, token_budget=token_budget)
            return

        visible_pre = [r for r in rows if r["size"] >= min_size]
        if visible_pre:
            largest_pre = max(visible_pre, key=lambda r: r["size"])
            verdict_text = (
                f"{len(visible_pre)} clusters, largest: {largest_pre['cluster_label']}({largest_pre['size']} syms)"
            )
        else:
            verdict_text = "no clusters detected"
        click.echo(f"VERDICT: {verdict_text}\n")

        click.echo("=== Clusters ===")
        if not rows:
            click.echo("  (no clusters detected)")
            _print_mismatches(conn, set())
            return

        visible = [r for r in rows if r["size"] >= min_size]
        hidden_count = len(rows) - len(visible)
        visible_ids = {r["cluster_id"] for r in visible}
        total_symbols = sum(r["size"] for r in rows)

        edges, intra_count, total_count, inter_pairs = _compute_cohesion(conn)

        # Compute global cohesion for both summary and detail
        total_intra = sum(intra_count.values())
        total_inter = sum(inter_pairs.values())
        total_all = total_intra + total_inter
        cohesion_pct = total_intra * 100 / total_all if total_all else 0
        q_label = "strong" if quality["modularity"] > 0.3 else "weak"

        if not detail:
            # Summary mode: cluster count + sizes + global metrics only
            click.echo(f"  {len(visible)} clusters (>= {min_size} symbols)")
            if hidden_count:
                click.echo(f"  ({hidden_count} smaller clusters hidden)")
            click.echo(f"  Cohesion: {cohesion_pct:.0f}%  Modularity Q: {quality['modularity']:.3f} ({q_label})")
            if visible:
                click.echo("  Top clusters by size:")
                for r in visible[:5]:
                    click.echo(f"    [{r['cluster_id']}] {r['cluster_label']}  size={r['size']}")
                if len(visible) > 5:
                    click.echo(f"    (+{len(visible) - 5} more — run `roam --detail clusters` for the full breakdown)")
            return

        # Round 3 #21, B — sort by cohesion when --weak / --strong
        # is requested. We need cohesion per cluster up front (rather
        # than only inside the table loop) so the ordering decision
        # has the data it needs.
        if weak_mode or strong_mode:
            scored: list[tuple[float, dict]] = []
            for r in visible:
                cid = r["cluster_id"]
                c_intra = intra_count.get(cid, 0)
                c_total = total_count.get(cid, 0)
                cohesion = c_intra * 100 / c_total if c_total else 0
                scored.append((cohesion, r))
            scored.sort(key=lambda x: x[0])
            ordered = [r for _, r in scored] if weak_mode else [r for _, r in reversed(scored)]
            visible = ordered[:10]
            label = "Weakest clusters (split candidates):" if weak_mode else "Strongest clusters (well-formed):"
            click.echo(f"\n=== {label} ===")

        # Main table
        mega_ids = set()
        cohesion_values = []
        if visible:
            table_rows = []
            for r in visible:
                cid = r["cluster_id"]
                members_str = r["members"] or ""
                pct = r["size"] * 100 / total_symbols if total_symbols else 0
                is_mega = r["size"] > 100 or pct > 40

                c_intra = intra_count.get(cid, 0)
                c_total = total_count.get(cid, 0)
                cohesion = c_intra * 100 / c_total if c_total else 0
                coh_str = f"{cohesion:.0f}%"
                cohesion_values.append(cohesion)

                if is_mega:
                    mega_ids.add(cid)
                    preview = f"MEGA ({pct:.0f}%) — see detail below"
                else:
                    preview = members_str[:80] + "..." if len(members_str) > 80 else members_str
                table_rows.append(
                    [
                        str(cid),
                        r["cluster_label"],
                        str(r["size"]),
                        coh_str,
                        preview,
                    ]
                )
            click.echo(
                format_table(
                    ["ID", "Label", "Size", "Cohsn", "Members"],
                    table_rows,
                    budget=30,
                )
            )
        else:
            click.echo("  (no clusters with size >= {})".format(min_size))

        if hidden_count:
            click.echo(f"  ({hidden_count} clusters with fewer than {min_size} members hidden)")

        median_cohesion = sorted(cohesion_values)[len(cohesion_values) // 2] if cohesion_values else 50

        if visible and mega_ids:
            _print_mega_detail(
                conn,
                visible,
                mega_ids,
                total_symbols,
                intra_count,
                total_count,
                median_cohesion,
                edges,
            )

        # Global cohesion summary
        click.echo(
            f"\n  Cluster cohesion: {cohesion_pct:.0f}% edges are intra-cluster ({total_intra} internal, {total_inter} cross-cluster)"
        )
        click.echo(
            f"  Modularity Q: {quality['modularity']:.3f} ({q_label}), mean conductance: {quality['mean_conductance']:.3f}"
        )

        # Top inter-cluster coupling pairs
        if inter_pairs:
            visible_pairs = {k: v for k, v in inter_pairs.items() if k[0] in visible_ids and k[1] in visible_ids}
            top_inter = sorted(visible_pairs.items(), key=lambda x: -x[1])[:10]
            if top_inter:
                cl_labels = {r["cluster_id"]: r["cluster_label"] for r in rows}
                click.echo("\n=== Inter-Cluster Coupling (top pairs) ===")
                ic_rows = []
                for (ca, cb), cnt in top_inter:
                    ic_rows.append(
                        [
                            cl_labels.get(ca, f"c{ca}"),
                            cl_labels.get(cb, f"c{cb}"),
                            str(cnt),
                        ]
                    )
                click.echo(format_table(["Cluster A", "Cluster B", "Edges"], ic_rows))

        _print_mismatches(conn, visible_ids)


def _print_mismatches(conn, visible_ids):
    """Print directory mismatches section."""
    click.echo("\n=== Directory Mismatches (hidden coupling) ===")
    mismatches = compare_with_directories(conn)
    if mismatches and visible_ids:
        mismatches = [m for m in mismatches if m["cluster_id"] in visible_ids]
    if mismatches:
        m_rows = []
        for m in mismatches:
            dirs = ", ".join(m["directories"][:5])
            if len(m["directories"]) > 5:
                dirs += f" (+{len(m['directories']) - 5})"
            m_rows.append(
                [
                    str(m["cluster_id"]),
                    m["cluster_label"],
                    str(m["mismatch_count"]),
                    dirs,
                ]
            )
        click.echo(
            format_table(
                ["Cluster", "Label", "Mismatches", "Directories"],
                m_rows,
                budget=20,
            )
        )
    else:
        click.echo("  (none -- clusters align with directories)")


# Preserve the lazy CLI registry attribute while keeping the callback's Python
# function name distinct from graph clone-detection helpers.
clusters = clusters_cmd
