"""Deterministic inline-SVG builders with table-compatible node IDs."""
from __future__ import annotations

import html
import unicodedata
from dataclasses import replace
from typing import Iterable, Sequence

from .models import FigureModel, VisualEdge, VisualNode


_ROWS_PER_COLUMN = 8
_COLUMN_WIDTH = 260
_ROW_HEIGHT = 100
_NODE_WIDTH = 180
_NODE_MIN_HEIGHT = 50
_LABEL_PADDING = 12
_LINE_HEIGHT = 16
_MAX_LABEL_LINES = 3


def _group_columns(nodes: Sequence[VisualNode]) -> list[list[str]]:
    """One column per group, wrapping a tall group into extra columns.

    This is the layout for a figure with no edges — a coverage or matrix view,
    where the group is the only structure there is to show.
    """
    counts: dict[str, int] = {}
    column_offset: dict[str, int] = {}
    offset = 0
    for group in sorted({n.group for n in nodes}):
        column_offset[group] = offset
        size = sum(1 for n in nodes if n.group == group)
        offset += max(1, -(-size // _ROWS_PER_COLUMN))
    columns: list[list[str]] = [[] for _ in range(max(offset, 1))]
    for node in nodes:
        index = counts.get(node.group, 0)
        counts[node.group] = index + 1
        columns[column_offset[node.group] + index // _ROWS_PER_COLUMN].append(node.id)
    return columns


def _depths(node_ids: list[str], edges: Sequence[VisualEdge]) -> dict[str, int]:
    """Longest path from a root, so a node sits past every one it depends on.

    Relaxing until nothing moves settles the longest path; a cycle would relax
    forever, so the pass count bounds it — a dependency graph that loops has no
    correct layering anyway, and stopping leaves it readable rather than hung.
    """
    depth = {node_id: 0 for node_id in node_ids}
    links = [(e.source, e.target) for e in edges if e.source in depth and e.target in depth]
    for _ in range(len(depth)):
        settled = True
        for source, target in links:
            if depth[target] < depth[source] + 1:
                depth[target] = depth[source] + 1
                settled = False
        if settled:
            break
    return depth


_ORDERING_SWEEPS = 4


def _ordered_by_neighbours(
    column: list[str], neighbours: list[str], links: list[tuple[str, str]], *, backward: bool
) -> list[str]:
    """Sort a column to sit opposite the nodes it connects to.

    One direction is not enough: ordering a column by its parents can pull it
    out of line with its children, so the sweeps alternate.
    """
    rank = {node_id: index for index, node_id in enumerate(neighbours)}
    original = {node_id: index for index, node_id in enumerate(column)}

    def key(node_id: str) -> tuple[float, int]:
        if backward:
            related = [rank[t] for s, t in links if s == node_id and t in rank]
        else:
            related = [rank[s] for s, t in links if t == node_id and s in rank]
        centre = sum(related) / len(related) if related else float(original[node_id])
        return (centre, original[node_id])

    return sorted(column, key=key)


def _uncross(columns: list[list[str]], links: list[tuple[str, str]]) -> list[list[str]]:
    """Alternate forward and backward barycentre sweeps until they settle."""
    for sweep in range(_ORDERING_SWEEPS):
        before = [list(column) for column in columns]
        if sweep % 2 == 0:
            for index in range(1, len(columns)):
                columns[index] = _ordered_by_neighbours(
                    columns[index], columns[index - 1], links, backward=False
                )
        else:
            for index in range(len(columns) - 2, -1, -1):
                columns[index] = _ordered_by_neighbours(
                    columns[index], columns[index + 1], links, backward=True
                )
        if columns == before:
            break
    return columns


def _layer_columns(nodes: Sequence[VisualNode], edges: Sequence[VisualEdge]) -> list[list[str]]:
    """Columns by dependency depth, with the unconnected nodes held back.

    A node with no edge has no place in the dependency story, and letting it
    take a slot pushed the nodes that do relate to each other apart. They get
    their own columns after the graph.
    """
    linked = {e.source for e in edges} | {e.target for e in edges}
    connected = [n.id for n in nodes if n.id in linked]
    loose = [n.id for n in nodes if n.id not in linked]
    depth = _depths(connected, edges)
    links = [(e.source, e.target) for e in edges if e.source in depth and e.target in depth]

    columns: list[list[str]] = []
    for level in range(max(depth.values(), default=-1) + 1):
        columns.append([node_id for node_id in connected if depth[node_id] == level])
    columns = _uncross(columns, links)
    for index in range(0, len(loose), _ROWS_PER_COLUMN):
        columns.append(loose[index:index + _ROWS_PER_COLUMN])
    return columns or [[]]


def _node_positions(
    nodes: Sequence[VisualNode], edges: Sequence[VisualEdge] = ()
) -> dict[str, tuple[int, int]]:
    columns = _layer_columns(nodes, edges) if edges else _group_columns(nodes)
    positions: dict[str, tuple[int, int]] = {}
    for column_index, column in enumerate(columns):
        for row_index, node_id in enumerate(column):
            positions[node_id] = (
                50 + column_index * _COLUMN_WIDTH,
                55 + row_index * _ROW_HEIGHT,
            )
    return positions


_ARROW_MARKER = (
    '<defs><marker id="edge-arrow" viewBox="0 0 10 10" refX="9" refY="5" '
    'markerWidth="6" markerHeight="6" orient="auto-start-reverse">'
    '<path d="M 0 0 L 10 5 L 0 10 z" class="edge-arrow-head" /></marker></defs>'
)


def _drawable_text(value: str) -> str:
    """Strip the markdown code fences a drawing cannot render.

    Labels carry backticks around identifiers because every other text surface
    turns them into `<code>`. Drawn literally they are stray characters inside
    the box, and they consume width the label needs.
    """
    return value.replace("`", "")


def _text_width(text: str) -> float:
    """Approximate the advance width of the 13px label font.

    An SVG carries no font metrics, so wrapping has to estimate. East-Asian
    characters occupy a full em; the rest average a little over half of one.
    """
    return sum(13.0 if unicodedata.east_asian_width(c) in ("W", "F") else 6.8 for c in text)


def _split_oversized_word(word: str, limit: float) -> list[str]:
    """Cut a word wider than the box into box-width pieces.

    Korean and Japanese labels arrive as long unbroken runs, and a path or an
    identifier can be wider than the box on its own.
    """
    pieces: list[str] = []
    current = ""
    for char in word:
        if current and _text_width(current + char) > limit:
            pieces.append(current)
            current = char
        else:
            current += char
    return pieces + [current] if current else pieces


def _ellipsize(line: str, limit: float) -> str:
    while line and _text_width(line + "…") > limit:
        line = line[:-1]
    return line + "…"


def _wrap_label(label: str) -> list[str]:
    """Break a label into the lines that fit inside one node box.

    The label used to be drawn as a single line whatever its length, so a node
    carrying a sentence painted it straight out of the box and off the canvas.
    Text past the last line is cut here rather than drawn, because the
    figure's fallback table prints the label in full.
    """
    limit = _NODE_WIDTH - 2 * _LABEL_PADDING
    lines: list[str] = []
    current = ""
    for word in label.split():
        pieces = _split_oversized_word(word, limit) if _text_width(word) > limit else [word]
        for piece in pieces:
            candidate = f"{current} {piece}".strip()
            if current and _text_width(candidate) > limit:
                lines.append(current)
                current = piece
            else:
                current = candidate
    if current:
        lines.append(current)
    if len(lines) > _MAX_LABEL_LINES:
        lines = lines[:_MAX_LABEL_LINES]
        lines[-1] = _ellipsize(lines[-1], limit)
    return lines or [""]


def _node_group(node: VisualNode, x: int, y: int, lines: Sequence[str], box_height: int) -> str:
    baseline = y + (box_height - len(lines) * _LINE_HEIGHT) // 2 + _LINE_HEIGHT - 4
    spans = "".join(
        f'<tspan x="{x + _LABEL_PADDING}" y="{baseline + index * _LINE_HEIGHT}">'
        f"{html.escape(line)}</tspan>"
        for index, line in enumerate(lines)
    )
    status = html.escape(node.status)
    detail = _drawable_text(node.detail)
    tooltip = f"{_drawable_text(node.label)}: {node.status}." + (f" {detail}" if detail else "")
    return (
        f'<g data-node-id="{html.escape(node.id)}" class="node node-{status}">'
        f"<title>{html.escape(tooltip)}</title>"
        f'<rect x="{x}" y="{y}" width="{_NODE_WIDTH}" height="{box_height}" rx="8"/>'
        f"<text>{spans}</text></g>"
    )


def _svg_document(nodes: Sequence[VisualNode], edges: Sequence[VisualEdge]) -> str:
    positions = _node_positions(nodes, edges)
    wrapped = {node.id: _wrap_label(_drawable_text(node.label)) for node in nodes}
    line_count = max((len(lines) for lines in wrapped.values()), default=1)
    # One height for every box: a figure whose rows are all the same depth
    # keeps the arrow between two columns horizontal.
    box_height = max(_NODE_MIN_HEIGHT, line_count * _LINE_HEIGHT + 2 * _LABEL_PADDING)
    width = max((x for x, _ in positions.values()), default=50) + _NODE_WIDTH + 50
    height = max((y for _, y in positions.values()), default=55) + box_height + 40
    parts = [f'<svg viewBox="0 0 {width} {height}" role="img" xmlns="http://www.w3.org/2000/svg">']
    parts.append(_ARROW_MARKER)
    for edge in edges:
        if edge.source not in positions or edge.target not in positions:
            continue
        x1, y1 = positions[edge.source]
        x2, y2 = positions[edge.target]
        # Leave the source box on its right edge and arrive on the target's
        # left, so a left-to-right layering reads as one direction of travel.
        forward = (x1 + _NODE_WIDTH, x2)
        backward = (x1, x2 + _NODE_WIDTH)
        same_column = (x1 + _NODE_WIDTH // 2, x2 + _NODE_WIDTH // 2)
        start_x, end_x = forward if x2 > x1 else (backward if x2 < x1 else same_column)
        start_y, end_y = y1 + box_height // 2, y2 + box_height // 2
        span = abs(x2 - x1) // _COLUMN_WIDTH
        if span > 1:
            # An edge that skips a column would otherwise be drawn straight
            # through the boxes standing in it. Arcing it clear of the band is
            # what removes the crossings the layering itself cannot.
            # Clamped so a long skip from the top row still arcs inside
            # the canvas instead of being drawn off it.
            lift = max(10, min(start_y, end_y) - 30 - 12 * span)
            geometry = (
                f'<path d="M {start_x} {start_y} Q {(start_x + end_x) // 2} {lift} {end_x} {end_y}" '
                f'fill="none"'
            )
        else:
            geometry = f'<line x1="{start_x}" y1="{start_y}" x2="{end_x}" y2="{end_y}"'
        parts.append(
            f'<g class="edge-group"><title>{html.escape(edge.label)}</title>'
            f'{geometry} class="edge edge-{html.escape(edge.kind)}" '
            f'marker-end="url(#edge-arrow)" /></g>'
        )
    for node in nodes:
        x, y = positions[node.id]
        parts.append(_node_group(node, x, y, wrapped[node.id], box_height))
    parts.append("</svg>")
    return "".join(parts)


def dependency_figure(*, nodes: Sequence[VisualNode], edges: Sequence[VisualEdge], title: str) -> FigureModel:
    ordered_nodes = tuple(sorted(nodes, key=lambda node: node.id))
    ordered_edges = tuple(sorted(edges, key=lambda edge: (edge.source, edge.target, edge.label)))
    return FigureModel(
        figure_id="dependency-graph",
        kind="dependency",
        title=title,
        summary=f"{len(ordered_nodes)} nodes and {len(ordered_edges)} relationships",
        nodes=ordered_nodes,
        edges=ordered_edges,
        svg=_svg_document(ordered_nodes, ordered_edges),
    )


def infrastructure_figure(*, nodes: Sequence[VisualNode], edges: Sequence[VisualEdge], title: str) -> FigureModel:
    return replace(
        dependency_figure(nodes=nodes, edges=edges, title=title),
        kind="infrastructure",
        figure_id="infrastructure-graph",
    )


def workflow_figure(*, nodes: Sequence[VisualNode], edges: Sequence[VisualEdge], title: str) -> FigureModel:
    return replace(
        dependency_figure(nodes=nodes, edges=edges, title=title),
        kind="workflow",
        figure_id="workflow-graph",
    )


def flow_figure(*, nodes: Sequence[VisualNode], edges: Sequence[VisualEdge], title: str) -> FigureModel:
    return replace(dependency_figure(nodes=nodes, edges=edges, title=title), kind="flow", figure_id="flow-graph")


def decision_flow_figure(*, nodes: Sequence[VisualNode], edges: Sequence[VisualEdge], title: str) -> FigureModel:
    return replace(
        dependency_figure(nodes=nodes, edges=edges, title=title),
        kind="decision-flow",
        figure_id="decision-flow",
    )


def cause_graph_figure(*, nodes: Sequence[VisualNode], edges: Sequence[VisualEdge], title: str) -> FigureModel:
    return replace(
        dependency_figure(nodes=nodes, edges=edges, title=title),
        kind="cause-graph",
        figure_id="cause-graph",
    )


def stage_map_figure(*, nodes: Sequence[VisualNode], edges: Sequence[VisualEdge], title: str) -> FigureModel:
    return replace(
        dependency_figure(nodes=nodes, edges=edges, title=title),
        kind="stage-map",
        figure_id="stage-map",
    )


def change_map_figure(*, nodes: Sequence[VisualNode], title: str) -> FigureModel:
    return replace(
        dependency_figure(nodes=nodes, edges=(), title=title),
        kind="change-map",
        figure_id="change-map",
    )


def matrix_figure(*, items: Iterable[VisualNode], title: str, x_label: str, y_label: str) -> FigureModel:
    nodes = tuple(items)
    summary = f"{len(nodes)} items compared by {x_label} and {y_label}"
    return replace(dependency_figure(nodes=nodes, edges=(), title=title), kind="matrix", figure_id="matrix", summary=summary)


def coverage_figure(*, rows: Iterable[VisualNode], title: str) -> FigureModel:
    nodes = tuple(rows)
    return replace(dependency_figure(nodes=nodes, edges=(), title=title), kind="coverage", figure_id="coverage")


def timeline_figure(*, events: Iterable[VisualNode], title: str) -> FigureModel:
    nodes = tuple(events)
    edges = tuple(VisualEdge(nodes[i].id, nodes[i + 1].id, "next", "sequence") for i in range(len(nodes) - 1))
    return replace(dependency_figure(nodes=nodes, edges=edges, title=title), kind="timeline", figure_id="timeline")
