#!/usr/bin/env python3
"""Generate pattern cataloguing diagrams for PYTANIE 14 (AIS).

  1. Pattern Template Structure — the standard fields every pattern has
  2. Catalog Classification Map — catalogs arranged by scope & domain
  3. Pattern Language Network — how patterns reference each other.

All: A4-compatible, B&W, 300 DPI, laser-printer-friendly.
"""

from __future__ import annotations

import logging
from typing import TYPE_CHECKING

import matplotlib as mpl

mpl.use("Agg")
from pathlib import Path

import matplotlib.patches as mpatches
from matplotlib.patches import FancyBboxPatch
import matplotlib.pyplot as plt
import numpy as np

if TYPE_CHECKING:
    from matplotlib.axes import Axes

_logger = logging.getLogger(__name__)

DPI = 300
BG = "white"
LN = "black"
FS = 9
FS_TITLE = 13
FS_SMALL = 7.5
OUTPUT_DIR = str(Path(__file__).resolve().parent / "img")
Path(OUTPUT_DIR).mkdir(parents=True, exist_ok=True)

GRAY1 = "#E8E8E8"
GRAY2 = "#D0D0D0"
GRAY3 = "#B8B8B8"
GRAY4 = "#F5F5F5"
GRAY5 = "#C0C0C0"

_BAND_HEIGHTS = [0.7, 1.3, 1.4, 1.5, 1.5]


def draw_box(
    ax: Axes,
    x: float,
    y: float,
    w: float,
    h: float,
    text: str,
    *,
    fill: str = "white",
    lw: float = 1.2,
    fontsize: float = FS,
    fontweight: str = "normal",
    ha: str = "center",
    va: str = "center",
    rounded: bool = True,
) -> None:
    """Draw a labeled box on the axes."""
    if rounded:
        rect = FancyBboxPatch(
            (x, y),
            w,
            h,
            boxstyle="round,pad=0.08",
            lw=lw,
            edgecolor=LN,
            facecolor=fill,
        )
    else:
        rect = mpatches.Rectangle(
            (x, y), w, h, lw=lw, edgecolor=LN, facecolor=fill
        )
    ax.add_patch(rect)
    ax.text(
        x + w / 2,
        y + h / 2,
        text,
        ha=ha,
        va=va,
        fontsize=fontsize,
        fontweight=fontweight,
        wrap=True,
    )


def draw_arrow(
    ax: Axes,
    x1: float,
    y1: float,
    x2: float,
    y2: float,
    *,
    lw: float = 1.2,
    style: str = "->",
    color: str = LN,
) -> None:
    """Draw an arrow between two points."""
    ax.annotate(
        "",
        xy=(x2, y2),
        xytext=(x1, y1),
        arrowprops={"arrowstyle": style, "color": color, "lw": lw},
    )


# ============================================================
# 1. Pattern Template Structure (NaPSiRoKo mnemonic)
# ============================================================
def generate_pattern_template() -> None:
    """Generate pattern template diagram with NaPSiRoKo mnemonic."""
    fig, ax = plt.subplots(figsize=(8.27, 6))
    ax.set_xlim(0, 10)
    ax.set_ylim(0, 8)
    ax.set_aspect("equal")
    ax.axis("off")
    fig.patch.set_facecolor(BG)
    ax.set_title(
        "Szablon opisu wzorca \u2014 \u201eNaPSiRoKo\u201d",
        fontsize=FS_TITLE,
        fontweight="bold",
        pad=15,
    )

    # Main card outline
    card_x, card_y, card_w, card_h = 1.5, 0.5, 7, 7
    card = FancyBboxPatch(
        (card_x, card_y),
        card_w,
        card_h,
        boxstyle="round,pad=0.15",
        lw=2.5,
        edgecolor=LN,
        facecolor=GRAY4,
    )
    ax.add_patch(card)

    # Title of card
    ax.text(
        card_x + card_w / 2,
        card_y + card_h - 0.35,
        "KARTA WZORCA",
        ha="center",
        va="center",
        fontsize=FS_TITLE,
        fontweight="bold",
    )

    # Fields as horizontal bands
    fields = [
        ("Na", "NAZWA", "Layered, Observer, Microservices", GRAY1),
        (
            "P",
            "PROBLEM / KONTEKST",
            "Kiedy stosować? Jaki problem rozwiązuje?",
            "white",
        ),
        (
            "Si",
            "SIŁY (forces)",
            "Konkurencyjne wymagania do pogodzenia\n"
            "(np. testowalność vs wydajność)",
            GRAY1,
        ),
        ("Ro", "ROZWIĄZANIE", "Struktura, diagram, zachowanie", "white"),
        (
            "Ko",
            "KONSEKWENCJE",
            "Tradeoffs: co zyskujemy, co tracimy",
            GRAY1,
        ),
    ]

    band_x = card_x + 0.3
    band_w = card_w - 0.6
    band_h = 1.05
    start_y = card_y + card_h - 1.1

    for i, (abbr, title, desc, fill) in enumerate(fields):
        by = start_y - i * (band_h + 0.15)

        # Abbreviation circle on the left
        circle = plt.Circle(
            (band_x + 0.35, by + band_h / 2),
            0.28,
            lw=1.5,
            edgecolor=LN,
            facecolor=GRAY2,
        )
        ax.add_patch(circle)
        ax.text(
            band_x + 0.35,
            by + band_h / 2,
            abbr,
            ha="center",
            va="center",
            fontsize=10,
            fontweight="bold",
        )

        # Field box
        fx = band_x + 0.8
        fw = band_w - 0.8
        rect = FancyBboxPatch(
            (fx, by),
            fw,
            band_h,
            boxstyle="round,pad=0.06",
            lw=1,
            edgecolor=LN,
            facecolor=fill,
        )
        ax.add_patch(rect)
        ax.text(
            fx + 0.15,
            by + band_h - 0.25,
            title,
            ha="left",
            va="center",
            fontsize=FS,
            fontweight="bold",
        )
        ax.text(
            fx + 0.15,
            by + 0.25,
            desc,
            ha="left",
            va="center",
            fontsize=FS_SMALL,
            fontstyle="italic",
            color="#444444",
        )

        # Arrow connecting fields
        if i < len(fields) - 1:
            draw_arrow(
                ax,
                band_x + 0.35,
                by - 0.02,
                band_x + 0.35,
                by - 0.13,
                lw=1.0,
            )

    # Extra fields note at bottom
    ax.text(
        card_x + card_w / 2,
        card_y + 0.25,
        "+ Powiązane wzorce  •  Znane zastosowania  •  Warianty",
        ha="center",
        va="center",
        fontsize=FS_SMALL,
        fontstyle="italic",
    )

    # Mnemonic reminder on the right
    ax.text(
        9.8,
        4,
        "Mnemonik:\nNaPSiRoKo",
        ha="center",
        va="center",
        fontsize=10,
        fontweight="bold",
        rotation=90,
        color="#666666",
    )

    fig.tight_layout()
    out = str(Path(OUTPUT_DIR) / "q14_pattern_template.png")
    fig.savefig(out, dpi=DPI, bbox_inches="tight", facecolor=BG)
    plt.close(fig)
    _logger.info("  Saved: %s", out)


# ============================================================
# 2. Catalog Classification Map
# ============================================================
def generate_catalog_map() -> None:
    """Generate catalog classification map diagram."""
    fig, ax = plt.subplots(figsize=(8.27, 7))
    ax.set_xlim(0, 12)
    ax.set_ylim(0, 9)
    ax.set_aspect("equal")
    ax.axis("off")
    fig.patch.set_facecolor(BG)
    ax.set_title(
        "Mapa katalog\u00f3w wzorc\u00f3w \u2014"
        " \u201ePawe\u0142 Gra\u0142 Efektownie"
        " Pod Chmurami\u201d",
        fontsize=FS_TITLE,
        fontweight="bold",
        pad=15,
    )

    # Y-axis: Scale (architectural -> design -> idiom)
    ax.text(
        0.3,
        7.8,
        "SKALA",
        fontsize=10,
        fontweight="bold",
        ha="center",
        va="center",
        rotation=90,
    )
    ax.annotate(
        "",
        xy=(0.3, 2.0),
        xytext=(0.3, 7.5),
        arrowprops={"arrowstyle": "->", "lw": 1.5, "color": LN},
    )

    scale_labels = [
        (7.0, "Architektoniczny\n(cały system)"),
        (5.0, "Projektowy\n(klasa/obiekt)"),
        (3.0, "Idiomatyczny\n(linia kodu)"),
    ]
    for sy, label in scale_labels:
        ax.text(
            1.0,
            sy,
            label,
            fontsize=FS_SMALL,
            ha="left",
            va="center",
            fontstyle="italic",
        )
        ax.plot(
            [0.15, 0.45], [sy, sy], color=GRAY3, lw=0.8, ls="--"
        )

    # X-axis: Domain
    ax.text(
        6.5,
        1.2,
        "DOMENA ZASTOSOWANIA",
        fontsize=10,
        fontweight="bold",
        ha="center",
        va="center",
    )
    ax.annotate(
        "",
        xy=(11.5, 1.5),
        xytext=(2.0, 1.5),
        arrowprops={"arrowstyle": "->", "lw": 1.5, "color": LN},
    )

    # Catalog boxes positioned by scale and domain
    catalogs = [
        (
            2.5,
            6.2,
            2.5,
            1.4,
            "POSA",
            "1996 • Buschmann\nLayers, Broker,\n"
            "Pipes & Filters, MVC",
            GRAY1,
            "P",
        ),
        (
            2.5,
            4.2,
            2.5,
            1.4,
            "GoF",
            "1994 • Gamma et al.\n23 wzorce:\n"
            "5 kreac. / 7 strukt. / 11 behaw.",
            GRAY2,
            "G",
        ),
        (
            5.5,
            6.2,
            2.5,
            1.4,
            "EIP",
            "2003 • Hohpe & Woolf\nMessage Channel,\n"
            "Router, Aggregator",
            GRAY1,
            "E",
        ),
        (
            5.5,
            4.2,
            2.5,
            1.4,
            "PoEAA",
            "2002 • M. Fowler\nRepository,"
            " Unit of Work,\nDomain Model",
            "white",
            "P",
        ),
        (
            8.5,
            6.2,
            2.8,
            1.4,
            "Cloud\nPatterns",
            "~2015 • Azure/AWS\nCircuit Breaker,\n"
            "Saga, Sidecar",
            GRAY1,
            "C",
        ),
    ]

    for cx, cy, cw, ch, name, sub, fill, ml in catalogs:
        rect = FancyBboxPatch(
            (cx, cy),
            cw,
            ch,
            boxstyle="round,pad=0.1",
            lw=1.5,
            edgecolor=LN,
            facecolor=fill,
        )
        ax.add_patch(rect)
        ax.text(
            cx + cw / 2,
            cy + ch - 0.3,
            name,
            ha="center",
            va="center",
            fontsize=10,
            fontweight="bold",
        )
        ax.text(
            cx + cw / 2,
            cy + 0.4,
            sub,
            ha="center",
            va="center",
            fontsize=FS_SMALL,
            linespacing=1.3,
        )

        # Mnemonic letter in corner
        circle = plt.Circle(
            (cx + 0.25, cy + ch - 0.25),
            0.2,
            lw=1,
            edgecolor=LN,
            facecolor=GRAY5,
        )
        ax.add_patch(circle)
        ax.text(
            cx + 0.25,
            cy + ch - 0.25,
            ml,
            ha="center",
            va="center",
            fontsize=8,
            fontweight="bold",
        )

    # Mnemonic bar at bottom
    mnem_y = 2.2
    ax.text(
        6.0,
        mnem_y,
        "PGEP+C → Paweł Grał Efektownie Pod Chmurami",
        ha="center",
        va="center",
        fontsize=10,
        fontweight="bold",
        bbox={
            "boxstyle": "round,pad=0.3",
            "facecolor": GRAY4,
            "edgecolor": LN,
            "lw": 1.5,
        },
    )

    # Domain labels along x-axis
    domains = [
        (3.75, 1.7, "Architektura"),
        (6.75, 1.7, "Integracja / Enterprise"),
        (9.9, 1.7, "Chmura"),
    ]
    for dx, dy, dlabel in domains:
        ax.text(
            dx,
            dy,
            dlabel,
            ha="center",
            va="center",
            fontsize=FS_SMALL,
            fontstyle="italic",
        )

    fig.tight_layout()
    out = str(Path(OUTPUT_DIR) / "q14_catalog_map.png")
    fig.savefig(out, dpi=DPI, bbox_inches="tight", facecolor=BG)
    plt.close(fig)
    _logger.info("  Saved: %s", out)


# ============================================================
# 3. Three Pillars of Cataloguing
# ============================================================
def generate_three_pillars() -> None:
    """Generate three pillars of cataloguing diagram."""
    fig, ax = plt.subplots(figsize=(8.27, 5.5))
    ax.set_xlim(0, 12)
    ax.set_ylim(0, 7)
    ax.set_aspect("equal")
    ax.axis("off")
    fig.patch.set_facecolor(BG)
    ax.set_title(
        "Jak są katalogowane wzorce? — Trzy filary",
        fontsize=FS_TITLE,
        fontweight="bold",
        pad=15,
    )

    # Roof / banner
    roof_pts = np.array([[1, 5.5], [6, 6.8], [11, 5.5]])
    roof = plt.Polygon(
        roof_pts,
        closed=True,
        lw=2,
        edgecolor=LN,
        facecolor=GRAY4,
    )
    ax.add_patch(roof)
    ax.text(
        6,
        6.0,
        "KATALOGOWANIE WZORCÓW",
        ha="center",
        va="center",
        fontsize=11,
        fontweight="bold",
    )

    # Three pillars
    pillars = [
        (
            1.3,
            "1. SZABLON\nOPISU",
            "Każdy wzorzec ma\nte same pola:\n"
            "Nazwa → Problem\n→ Siły → Rozwiązanie\n"
            "→ Konsekwencje",
            "Analogia:\nformatka\nencyklopedii",
        ),
        (
            4.8,
            "2. KLASYFIKACJA\nWIELOOSIOWA",
            "Osie podziału:\n"
            "• Skala (arch/proj/idiom)\n"
            "• Domena problemu\n"
            "• Atrybut jakościowy\n"
            "• Domena zastosowania",
            "Analogia:\nkategorie\nw bibliotece",
        ),
        (
            8.3,
            "3. JĘZYK\nWZORCÓW",
            "Wzorce referują się\nwzajemnie tworząc\n"
            "sieć/graf:\nA → wymaga → B\n"
            "B → wariant → C",
            "Analogia:\n\u201ezobacz te\u017c\u201d\n"
            "w encyklopedii",
        ),
    ]

    for px, title, desc, analogy in pillars:
        pw, ph = 2.8, 5.0
        py = 0.5

        # Pillar rectangle
        rect = FancyBboxPatch(
            (px, py),
            pw,
            ph,
            boxstyle="round,pad=0.1",
            lw=1.8,
            edgecolor=LN,
            facecolor="white",
        )
        ax.add_patch(rect)

        # Title
        ax.text(
            px + pw / 2,
            py + ph - 0.55,
            title,
            ha="center",
            va="center",
            fontsize=9,
            fontweight="bold",
        )

        # Horizontal line under title
        ax.plot(
            [px + 0.2, px + pw - 0.2],
            [py + ph - 1.0, py + ph - 1.0],
            color=LN,
            lw=0.8,
        )

        # Description
        ax.text(
            px + pw / 2,
            py + ph / 2 - 0.3,
            desc,
            ha="center",
            va="center",
            fontsize=FS_SMALL,
            linespacing=1.4,
        )

        # Analogy box at bottom
        analogy_rect = FancyBboxPatch(
            (px + 0.2, py + 0.15),
            pw - 0.4,
            1.0,
            boxstyle="round,pad=0.06",
            lw=0.8,
            edgecolor=GRAY3,
            facecolor=GRAY1,
        )
        ax.add_patch(analogy_rect)
        ax.text(
            px + pw / 2,
            py + 0.65,
            analogy,
            ha="center",
            va="center",
            fontsize=FS_SMALL,
            fontstyle="italic",
            color="#555555",
        )

    fig.tight_layout()
    out = str(Path(OUTPUT_DIR) / "q14_three_pillars.png")
    fig.savefig(out, dpi=DPI, bbox_inches="tight", facecolor=BG)
    plt.close(fig)
    _logger.info("  Saved: %s", out)


# ============================================================
# 4. Filled-in Observer Pattern Card
# ============================================================
def _get_observer_band_height(index: int) -> float:
    """Return band height for the given field index."""
    return _BAND_HEIGHTS[index]


def generate_observer_card_filled() -> None:
    """Generate filled-in Observer pattern card diagram."""
    fig, ax = plt.subplots(figsize=(8.27, 8.5))
    ax.set_xlim(0, 10)
    ax.set_ylim(0, 10)
    ax.set_aspect("equal")
    ax.axis("off")
    fig.patch.set_facecolor(BG)
    ax.set_title(
        "Wypełniona karta wzorca — Observer (GoF)",
        fontsize=FS_TITLE,
        fontweight="bold",
        pad=15,
    )

    # Main card outline
    card_x, card_y, card_w, card_h = 0.8, 0.3, 8.4, 9.2
    card = FancyBboxPatch(
        (card_x, card_y),
        card_w,
        card_h,
        boxstyle="round,pad=0.15",
        lw=2.5,
        edgecolor=LN,
        facecolor=GRAY4,
    )
    ax.add_patch(card)

    # Fields with actual Observer content
    fields = [
        ("Na", "NAZWA", "Observer", GRAY2, True),
        (
            "P",
            "PROBLEM",
            "Obiekt (Subject) zmienia stan → wielu"
            " zależnych\n"
            "obiektów musi zareagować, ale Subject nie\n"
            "powinien znać ich konkretnych typów.",
            GRAY1,
            False,
        ),
        (
            "Si",
            "SIŁY",
            "• loose coupling (nie znać obserwatorów"
            " z nazwy)\n"
            "  vs koszt powiadomień"
            " (N obserwatorów = N wywołań)\n"
            "• otwartość na rozszerzenia"
            " vs złożoność debugowania",
            "white",
            False,
        ),
        (
            "Ro",
            "ROZWIĄZANIE",
            "Subject przechowuje listę Observer.\n"
            "Metody: attach(o), detach(o), notify().\n"
            "notify() iteruje po liście i woła update()\n"
            "na każdym obserwatorze.",
            GRAY1,
            False,
        ),
        (
            "Ko",
            "KONSEKWENCJE",
            "(+) Luźne wiązanie — Subject ↔ Observer\n"
            "(+) Nowi obserwatorzy bez zmian w Subject\n"
            "(-) Kaskada powiadomień może być kosztowna\n"
            "(-) Memory leaks jeśli nie detach()",
            "white",
            False,
        ),
    ]

    band_x = card_x + 0.3
    band_w = card_w - 0.6
    start_y = card_y + card_h - 0.65

    for i, (abbr, title, content, fill, is_title_field) in enumerate(
        fields
    ):
        band_h = _get_observer_band_height(i)

        by = start_y - sum(
            _get_observer_band_height(j) + 0.15 for j in range(i)
        )

        # Abbreviation circle
        circle = plt.Circle(
            (band_x + 0.35, by + band_h / 2),
            0.28,
            lw=1.5,
            edgecolor=LN,
            facecolor=GRAY3,
        )
        ax.add_patch(circle)
        ax.text(
            band_x + 0.35,
            by + band_h / 2,
            abbr,
            ha="center",
            va="center",
            fontsize=10,
            fontweight="bold",
        )

        # Field box
        fx = band_x + 0.8
        fw = band_w - 0.8
        rect = FancyBboxPatch(
            (fx, by),
            fw,
            band_h,
            boxstyle="round,pad=0.06",
            lw=1,
            edgecolor=LN,
            facecolor=fill,
        )
        ax.add_patch(rect)

        if is_title_field:
            ax.text(
                fx + fw / 2,
                by + band_h / 2,
                f"{title}: {content}",
                ha="center",
                va="center",
                fontsize=12,
                fontweight="bold",
            )
        else:
            ax.text(
                fx + 0.15,
                by + band_h - 0.2,
                title,
                ha="left",
                va="center",
                fontsize=FS,
                fontweight="bold",
            )
            ax.text(
                fx + 0.15,
                by + band_h / 2 - 0.15,
                content,
                ha="left",
                va="center",
                fontsize=FS_SMALL,
                family="monospace",
                linespacing=1.3,
            )

        # Arrow
        if i < len(fields) - 1:
            draw_arrow(
                ax,
                band_x + 0.35,
                by - 0.02,
                band_x + 0.35,
                by - 0.13,
                lw=1.0,
            )

    # Extra info at bottom
    extra_y = 0.55
    extras = [
        "Powiązane: Mediator (centralizuje),"
        " Pub/Sub (rozproszony),"
        " MVC (View = Observer)",
        "Znane użycia: Java Swing listeners,"
        " C# event/delegate,"
        " React useState, DOM addEventListener",
    ]
    for j, txt in enumerate(extras):
        ax.text(
            card_x + card_w / 2,
            extra_y + (1 - j) * 0.25,
            txt,
            ha="center",
            va="center",
            fontsize=FS_SMALL,
            fontstyle="italic",
            color="#444444",
        )

    fig.tight_layout()
    out = str(Path(OUTPUT_DIR) / "q14_observer_card_filled.png")
    fig.savefig(out, dpi=DPI, bbox_inches="tight", facecolor=BG)
    plt.close(fig)
    _logger.info("  Saved: %s", out)


# ============================================================
# 5. Pattern Language Navigation Graph
# ============================================================
def generate_pattern_language_navigation() -> None:
    """Generate pattern language navigation graph diagram."""
    fig, ax = plt.subplots(figsize=(8.27, 9))
    ax.set_xlim(0, 12)
    ax.set_ylim(0, 12)
    ax.set_aspect("equal")
    ax.axis("off")
    fig.patch.set_facecolor(BG)
    ax.set_title(
        "Język wzorców \u2014 nawigacja"
        " \u201eproblem \u2192 wzorzec"
        " \u2192 nowy problem\u201d",
        fontsize=FS_TITLE,
        fontweight="bold",
        pad=15,
    )

    # Node positions: (x, y, label, is_pattern, fill)
    nodes = [
        (1.5, 10.5, "Monolith\nnie skaluje się", False, "white"),
        (
            1.5, 8.2,
            "Jak routować\nżądania do\nserwisów?",
            False, "white",
        ),
        (
            1.5, 5.9,
            "Co gdy serwis\nnie odpowiada?",
            False, "white",
        ),
        (
            1.5, 3.6,
            "Jak zachować\nspójność\ntransakcji?",
            False, "white",
        ),
        (
            1.5, 1.3,
            "Jak odnaleźć\nadres serwisu?",
            False, "white",
        ),
        (7.0, 9.3, "Microservices", True, GRAY2),
        (7.0, 7.0, "API Gateway", True, GRAY2),
        (7.0, 4.7, "Circuit Breaker", True, GRAY2),
        (7.0, 2.4, "Saga", True, GRAY2),
        (10.0, 5.9, "Service\nDiscovery", True, GRAY1),
    ]

    # Draw nodes
    node_w_prob = 2.8
    node_h_prob = 1.3
    node_w_pat = 2.5
    node_h_pat = 1.0

    for nx, ny, label, is_pattern, fill in nodes:
        if is_pattern:
            w, h = node_w_pat, node_h_pat
            rect = FancyBboxPatch(
                (nx - w / 2, ny - h / 2),
                w,
                h,
                boxstyle="round,pad=0.1",
                lw=2,
                edgecolor=LN,
                facecolor=fill,
            )
            ax.add_patch(rect)
            ax.text(
                nx,
                ny,
                label,
                ha="center",
                va="center",
                fontsize=10,
                fontweight="bold",
            )
        else:
            w, h = node_w_prob, node_h_prob
            rect = FancyBboxPatch(
                (nx - w / 2, ny - h / 2),
                w,
                h,
                boxstyle="round,pad=0.1",
                lw=1.2,
                edgecolor=LN,
                facecolor=fill,
                linestyle="--",
            )
            ax.add_patch(rect)
            ax.text(
                nx,
                ny,
                label,
                ha="center",
                va="center",
                fontsize=FS_SMALL,
                fontstyle="italic",
            )

    # Arrows: problem -> pattern, pattern -> problem
    arrows = [
        (2.9, 10.5, 5.75, 9.5, "rozwiązuje →", "->", 1.5),
        (7.0, 8.8, 2.9, 8.5, "← rodzi problem", "->", 1.0),
        (2.9, 8.0, 5.75, 7.2, "rozwiązuje →", "->", 1.5),
        (7.0, 6.5, 2.9, 6.2, "← rodzi problem", "->", 1.0),
        (2.9, 5.7, 5.75, 5.0, "rozwiązuje →", "->", 1.5),
        (7.0, 4.2, 2.9, 3.9, "← rodzi problem", "->", 1.0),
        (2.9, 3.3, 5.75, 2.6, "rozwiązuje →", "->", 1.5),
        (8.25, 9.0, 9.5, 6.5, "wymaga →", "->", 1.0),
        (2.9, 1.3, 8.75, 5.6, "rozwiązuje →", "->", 1.2),
    ]

    for x1, y1, x2, y2, label, style, lw in arrows:
        ax.annotate(
            "",
            xy=(x2, y2),
            xytext=(x1, y1),
            arrowprops={
                "arrowstyle": style,
                "color": LN,
                "lw": lw,
                "connectionstyle": "arc3,rad=0.05",
            },
        )
        mx, my = (x1 + x2) / 2, (y1 + y2) / 2
        ax.text(
            mx,
            my + 0.2,
            label,
            ha="center",
            va="center",
            fontsize=6.5,
            fontstyle="italic",
            color="#555555",
            bbox={
                "boxstyle": "round,pad=0.1",
                "facecolor": "white",
                "edgecolor": "none",
                "alpha": 0.8,
            },
        )

    # Legend
    legend_y = 0.3
    r1 = FancyBboxPatch(
        (1.0, legend_y - 0.2),
        1.5,
        0.4,
        boxstyle="round,pad=0.05",
        lw=1,
        edgecolor=LN,
        facecolor="white",
        linestyle="--",
    )
    ax.add_patch(r1)
    ax.text(
        1.75, legend_y, "Problem",
        ha="center", va="center", fontsize=7,
    )
    r2 = FancyBboxPatch(
        (3.5, legend_y - 0.2),
        1.5,
        0.4,
        boxstyle="round,pad=0.05",
        lw=1.5,
        edgecolor=LN,
        facecolor=GRAY2,
    )
    ax.add_patch(r2)
    ax.text(
        4.25,
        legend_y,
        "Wzorzec",
        ha="center",
        va="center",
        fontsize=7,
        fontweight="bold",
    )
    ax.text(
        6.5,
        legend_y,
        "Nawigacja: Problem \u2192 Wzorzec"
        " \u2192 Nowy Problem \u2192 Wzorzec \u2192 ...",
        ha="left",
        va="center",
        fontsize=7,
        fontstyle="italic",
    )

    fig.tight_layout()
    out = str(
        Path(OUTPUT_DIR) / "q14_pattern_language_navigation.png"
    )
    fig.savefig(out, dpi=DPI, bbox_inches="tight", facecolor=BG)
    plt.close(fig)
    _logger.info("  Saved: %s", out)


# ============================================================
# Main
# ============================================================
if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)
    _logger.info("Generating PYTANIE 14 diagrams...")
    generate_pattern_template()
    generate_catalog_map()
    generate_three_pillars()
    generate_observer_card_filled()
    generate_pattern_language_navigation()
    _logger.info("Done!")
