#!/usr/bin/env python3
from __future__ import annotations

import json
import re
import subprocess
import textwrap
from collections import Counter, defaultdict
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Iterable

import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt
import networkx as nx
import numpy as np
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import cm
from reportlab.platypus import (
    Image,
    PageBreak,
    Paragraph,
    SimpleDocTemplate,
    Spacer,
    Table,
    TableStyle,
)


ROOT = Path(__file__).resolve().parents[1]
DOCS_DIR = ROOT / "docs"
ASSETS_DIR = DOCS_DIR / "techmap_assets"
PDF_PATH = DOCS_DIR / "tech_map_crm25.pdf"
SUMMARY_PATH = DOCS_DIR / "tech_map_summary.md"


@dataclass
class ModelInfo:
    name: str
    path: Path
    lines: int
    methods: list[str] = field(default_factory=list)
    relations: list[tuple[str, str]] = field(default_factory=list)


@dataclass
class ControllerInfo:
    name: str
    path: Path
    lines: int
    methods: list[str] = field(default_factory=list)
    model_uses: list[str] = field(default_factory=list)
    kind: str = "web"


@dataclass
class PolicyInfo:
    name: str
    path: Path
    lines: int
    methods: list[str] = field(default_factory=list)


@dataclass
class MigrationInfo:
    name: str
    path: Path
    lines: int
    timestamp: datetime
    creates: list[str] = field(default_factory=list)
    alters: list[str] = field(default_factory=list)


def read_text(path: Path) -> str:
    return path.read_text(encoding="utf-8", errors="ignore")


def php_const_keys(path: Path, const_name: str) -> list[str]:
    text = read_text(path)
    match = re.search(rf"private const {re.escape(const_name)}\s*=\s*\[(.*?)\];", text, re.S)
    if not match:
        return []
    body = match.group(1)
    keys: list[str] = []
    depth = 0
    i = 0
    while i < len(body):
        ch = body[i]
        if ch == "[":
            depth += 1
            i += 1
            continue
        if ch == "]":
            depth = max(0, depth - 1)
            i += 1
            continue
        if ch == "'" and depth == 0:
            end = body.find("'", i + 1)
            if end == -1:
                break
            key = body[i + 1 : end]
            probe = end + 1
            while probe < len(body) and body[probe].isspace():
                probe += 1
            if body[probe : probe + 2] == "=>":
                keys.append(key)
            i = end + 1
            continue
        i += 1
    return keys


def snake_case(name: str) -> str:
    return re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower()


def normalize_module(segment: str) -> str:
    segment = segment.strip("/")
    if segment == "":
        return "dashboard"
    segment = re.sub(r"\{[^}]+\}", "", segment).strip("/")
    if segment == "":
        return "misc"
    return segment.split("/")[0]


def route_module(uri: str) -> str:
    uri = (uri or "").strip("/")
    if uri == "":
        return "dashboard"
    parts = [x for x in uri.split("/") if x]
    if parts[0] == "api":
        idx = 1
        if len(parts) > idx and re.fullmatch(r"v\d+", parts[idx]):
            idx += 1
        return normalize_module(parts[idx] if len(parts) > idx else "api")
    return normalize_module(parts[0])


def parse_routes() -> list[dict]:
    cmd = ["php", "artisan", "route:list", "--json"]
    result = subprocess.run(cmd, cwd=ROOT, text=True, capture_output=True, check=True)
    return json.loads(result.stdout)


def parse_models() -> list[ModelInfo]:
    out: list[ModelInfo] = []
    relation_re = re.compile(
        r"->\s*(belongsTo|hasOne|hasMany|belongsToMany|morphOne|morphMany|morphTo|morphToMany|morphedByMany)\s*\((.*?)\)",
        re.S,
    )
    class_ref_re = re.compile(r"([A-Za-z_\\][A-Za-z0-9_\\]*)::class")
    method_re = re.compile(r"public function ([A-Za-z_][A-Za-z0-9_]*)\s*\(")
    for path in sorted((ROOT / "app" / "Models").glob("*.php")):
        text = read_text(path)
        methods = method_re.findall(text)
        relations: list[tuple[str, str]] = []
        for rel_type, args in relation_re.findall(text):
            target = "unknown"
            class_match = class_ref_re.search(args)
            if class_match:
                target = class_match.group(1).split("\\")[-1]
            relations.append((rel_type, target))
        out.append(
            ModelInfo(
                name=path.stem,
                path=path,
                lines=text.count("\n") + 1,
                methods=methods,
                relations=relations,
            )
        )
    return out


def parse_controllers() -> list[ControllerInfo]:
    out: list[ControllerInfo] = []
    method_re = re.compile(r"public function ([A-Za-z_][A-Za-z0-9_]*)\s*\(")
    model_use_re = re.compile(r"use App\\Models\\([A-Za-z_][A-Za-z0-9_]*);")
    for path in sorted((ROOT / "app" / "Http" / "Controllers").rglob("*Controller.php")):
        text = read_text(path)
        rel = path.relative_to(ROOT)
        kind = "api" if "Api/" in str(rel).replace("\\", "/") else "web"
        out.append(
            ControllerInfo(
                name=path.stem,
                path=path,
                lines=text.count("\n") + 1,
                methods=method_re.findall(text),
                model_uses=sorted(set(model_use_re.findall(text))),
                kind=kind,
            )
        )
    return out


def parse_policies() -> list[PolicyInfo]:
    out: list[PolicyInfo] = []
    method_re = re.compile(r"public function ([A-Za-z_][A-Za-z0-9_]*)\s*\(")
    for path in sorted((ROOT / "app" / "Policies").glob("*.php")):
        text = read_text(path)
        out.append(
            PolicyInfo(
                name=path.stem,
                path=path,
                lines=text.count("\n") + 1,
                methods=method_re.findall(text),
            )
        )
    return out


def parse_migrations() -> list[MigrationInfo]:
    out: list[MigrationInfo] = []
    create_re = re.compile(r"Schema::create\('([^']+)'")
    alter_re = re.compile(r"Schema::table\('([^']+)'")
    for path in sorted((ROOT / "database" / "migrations").glob("*.php")):
        stem = path.stem
        parts = stem.split("_")
        if len(parts) < 4:
            continue
        timestamp = datetime.strptime("_".join(parts[:4]), "%Y_%m_%d_%H%M%S")
        text = read_text(path)
        out.append(
            MigrationInfo(
                name=stem,
                path=path,
                lines=text.count("\n") + 1,
                timestamp=timestamp,
                creates=create_re.findall(text),
                alters=alter_re.findall(text),
            )
        )
    return out


def parse_views() -> Counter:
    counter: Counter = Counter()
    base = ROOT / "resources" / "views"
    for path in base.rglob("*.blade.php"):
        rel = path.relative_to(base)
        section = rel.parts[0] if len(rel.parts) > 1 else "_root"
        counter[section] += 1
    return counter


def parse_support_classes() -> dict[str, int]:
    out: dict[str, int] = {}
    for path in sorted((ROOT / "app" / "Support").glob("*.php")):
        text = read_text(path)
        out[path.stem] = text.count("\n") + 1
    return out


def parse_files_line_count(paths: Iterable[Path]) -> int:
    total = 0
    for path in paths:
        total += read_text(path).count("\n") + 1
    return total


def model_module_guess(model: str, known_modules: set[str]) -> str:
    special = {
        "User": "profile",
        "UserMenuItem": "profile",
        "AccessGroup": "profile",
        "Pipeline": "pipelines",
        "DealStage": "deals",
        "ProjectStage": "projects",
        "OrganizationSetting": "profile",
        "OrganizationCompany": "companies",
        "WebForm": "forms",
        "WebFormSubmission": "forms",
        "DiskFolder": "disks",
        "ChatMessage": "chat",
        "NewsView": "news",
        "CrmModule": "modules",
        "Theme": "profile",
        "TelephonyCall": "telephony",
        "TelephonySetting": "telephony",
        "MessengerSetting": "messengers",
        "MessengerChannel": "messengers",
        "MessengerConversation": "messengers",
        "MessengerMessage": "messengers",
    }
    if model in special:
        return special[model]
    snake = snake_case(model)
    if snake in known_modules:
        return snake
    plural = snake if snake.endswith("s") else f"{snake}s"
    if plural in known_modules:
        return plural
    for module in sorted(known_modules, key=len, reverse=True):
        base = module[:-1] if module.endswith("s") else module
        if base and base in snake:
            return module
    return "core"


def controller_module_guess(name: str, routes_by_controller: dict[str, list[dict]]) -> str:
    route_items = routes_by_controller.get(name, [])
    if route_items:
        mods = [route_module(item.get("uri", "")) for item in route_items]
        if mods:
            return Counter(mods).most_common(1)[0][0]
    norm = re.sub(r"Controller$", "", name)
    return snake_case(norm)


def build_charts(
    module_stats: dict[str, dict],
    model_infos: list[ModelInfo],
    module_edges: Counter,
    migration_infos: list[MigrationInfo],
    access_entities: list[str],
    api_modules: list[str],
    volume_by_layer: dict[str, int],
) -> dict[str, Path]:
    ASSETS_DIR.mkdir(parents=True, exist_ok=True)
    out: dict[str, Path] = {}

    sorted_modules = sorted(module_stats.items(), key=lambda x: (-(x[1]["web"] + x[1]["api"]), x[0]))[:20]
    labels = [x[0] for x in sorted_modules]
    web_vals = [x[1]["web"] for x in sorted_modules]
    api_vals = [x[1]["api"] for x in sorted_modules]
    fig, ax = plt.subplots(figsize=(14, 7))
    ax.bar(labels, web_vals, label="Web routes")
    ax.bar(labels, api_vals, bottom=web_vals, label="API routes")
    ax.set_title("Route coverage by module")
    ax.set_ylabel("Count")
    ax.tick_params(axis="x", rotation=55)
    ax.legend()
    fig.tight_layout()
    path = ASSETS_DIR / "chart_route_coverage.png"
    fig.savefig(path, dpi=160)
    plt.close(fig)
    out["route_coverage"] = path

    graph = nx.DiGraph()
    for model in model_infos:
        graph.add_node(model.name)
        for _, target in model.relations:
            if target != "unknown":
                graph.add_edge(model.name, target)
    fig, ax = plt.subplots(figsize=(14, 10))
    if graph.number_of_nodes() > 0:
        pos = nx.spring_layout(graph, seed=42, k=1.1 / max(1, len(graph.nodes)))
        nx.draw_networkx_nodes(graph, pos, node_color="#2563eb", node_size=520, alpha=0.9, ax=ax)
        nx.draw_networkx_labels(graph, pos, font_size=7, font_color="white", ax=ax)
        nx.draw_networkx_edges(graph, pos, edge_color="#94a3b8", arrowsize=8, alpha=0.65, ax=ax)
    ax.set_title("Entity relationship graph (model-level)")
    ax.axis("off")
    fig.tight_layout()
    path = ASSETS_DIR / "chart_model_relations.png"
    fig.savefig(path, dpi=180)
    plt.close(fig)
    out["model_relations"] = path

    edge_items = module_edges.most_common(30)
    mod_graph = nx.DiGraph()
    for edge, weight in edge_items:
        src, dst = edge.split("->", 1)
        mod_graph.add_edge(src, dst, weight=weight)
    fig, ax = plt.subplots(figsize=(14, 9))
    if mod_graph.number_of_nodes() > 0:
        pos = nx.spring_layout(mod_graph, seed=11)
        weights = [mod_graph[u][v]["weight"] for u, v in mod_graph.edges]
        nx.draw_networkx_nodes(mod_graph, pos, node_color="#0891b2", node_size=980, alpha=0.92, ax=ax)
        nx.draw_networkx_labels(mod_graph, pos, font_size=8, font_color="white", ax=ax)
        nx.draw_networkx_edges(mod_graph, pos, width=[0.6 + w * 0.4 for w in weights], edge_color="#0f172a", arrowsize=10, alpha=0.6, ax=ax)
    ax.set_title("Module dependency graph (from model relations)")
    ax.axis("off")
    fig.tight_layout()
    path = ASSETS_DIR / "chart_module_dependencies.png"
    fig.savefig(path, dpi=180)
    plt.close(fig)
    out["module_dependencies"] = path

    entities = sorted(set(access_entities) | set(api_modules))
    matrix = np.zeros((2, len(entities)))
    for i, entity in enumerate(entities):
        matrix[0, i] = 1 if entity in access_entities else 0
        matrix[1, i] = 1 if entity in api_modules else 0
    fig, ax = plt.subplots(figsize=(max(8, len(entities) * 0.5), 4))
    heat = ax.imshow(matrix, cmap="YlGnBu", aspect="auto", vmin=0, vmax=1)
    ax.set_xticks(range(len(entities)))
    ax.set_xticklabels(entities, rotation=60, ha="right", fontsize=8)
    ax.set_yticks([0, 1])
    ax.set_yticklabels(["User permissions", "API token scopes"])
    ax.set_title("Permission surface map")
    fig.colorbar(heat, ax=ax, fraction=0.02, pad=0.01)
    fig.tight_layout()
    path = ASSETS_DIR / "chart_permission_surface.png"
    fig.savefig(path, dpi=180)
    plt.close(fig)
    out["permission_surface"] = path

    by_day: Counter = Counter()
    for migration in migration_infos:
        by_day[migration.timestamp.date().isoformat()] += 1
    dates = sorted(by_day)
    cumulative = []
    total = 0
    for day in dates:
        total += by_day[day]
        cumulative.append(total)
    fig, ax = plt.subplots(figsize=(14, 6))
    ax.plot(dates, cumulative, marker="o", linewidth=2, color="#7c3aed")
    ax.fill_between(dates, cumulative, alpha=0.2, color="#7c3aed")
    ax.set_title("Migration growth timeline")
    ax.set_ylabel("Cumulative migrations")
    ax.tick_params(axis="x", rotation=45, labelsize=8)
    fig.tight_layout()
    path = ASSETS_DIR / "chart_migration_timeline.png"
    fig.savefig(path, dpi=180)
    plt.close(fig)
    out["migration_timeline"] = path

    keys = list(volume_by_layer)
    values = [volume_by_layer[k] for k in keys]
    fig, ax = plt.subplots(figsize=(10, 7))
    ax.pie(values, labels=keys, autopct="%1.1f%%", startangle=90)
    ax.set_title("Code volume by layer (line count)")
    fig.tight_layout()
    path = ASSETS_DIR / "chart_code_volume.png"
    fig.savefig(path, dpi=180)
    plt.close(fig)
    out["code_volume"] = path
    return out


def make_styles():
    base = getSampleStyleSheet()
    styles = {
        "title": ParagraphStyle(
            "title",
            parent=base["Title"],
            fontName="Helvetica-Bold",
            fontSize=24,
            leading=28,
            spaceAfter=16,
        ),
        "h1": ParagraphStyle(
            "h1",
            parent=base["Heading1"],
            fontName="Helvetica-Bold",
            fontSize=18,
            leading=22,
            spaceAfter=10,
            spaceBefore=8,
        ),
        "h2": ParagraphStyle(
            "h2",
            parent=base["Heading2"],
            fontName="Helvetica-Bold",
            fontSize=13,
            leading=16,
            spaceAfter=6,
            spaceBefore=6,
        ),
        "body": ParagraphStyle(
            "body",
            parent=base["BodyText"],
            fontName="Helvetica",
            fontSize=10,
            leading=13,
            spaceAfter=6,
        ),
        "mono": ParagraphStyle(
            "mono",
            parent=base["BodyText"],
            fontName="Courier",
            fontSize=8,
            leading=10,
            spaceAfter=4,
        ),
    }
    return styles


def image_flow(path: Path, width_cm: float = 18.0) -> Image:
    img = Image(str(path))
    ratio = img.imageHeight / float(img.imageWidth)
    img.drawWidth = width_cm * cm
    img.drawHeight = img.drawWidth * ratio
    return img


def page_number(canvas, doc):
    canvas.saveState()
    canvas.setFont("Helvetica", 8)
    canvas.setFillColor(colors.HexColor("#64748b"))
    canvas.drawRightString(A4[0] - 1.5 * cm, 0.9 * cm, f"CRM25 technical map | page {doc.page}")
    canvas.restoreState()


def build_pdf(
    routes: list[dict],
    models: list[ModelInfo],
    controllers: list[ControllerInfo],
    policies: list[PolicyInfo],
    migrations: list[MigrationInfo],
    module_stats: dict[str, dict],
    model_module: dict[str, str],
    module_edges: Counter,
    charts: dict[str, Path],
    menu_modules: list[str],
    access_entities: list[str],
    api_modules: list[str],
    support_lines: dict[str, int],
    view_counts: Counter,
):
    styles = make_styles()
    story = []

    generated = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    story.append(Paragraph("CRM25 Technical Map", styles["title"]))
    story.append(Paragraph("Comprehensive architecture and capability documentation", styles["h2"]))
    story.append(Spacer(1, 0.5 * cm))
    intro = (
        f"Generated at: {generated}<br/>"
        f"Modules (discovered): {len(module_stats)}<br/>"
        f"Routes: {len(routes)}<br/>"
        f"Models: {len(models)}<br/>"
        f"Controllers: {len(controllers)}<br/>"
        f"Policies: {len(policies)}<br/>"
        f"Migrations: {len(migrations)}<br/>"
    )
    story.append(Paragraph(intro, styles["body"]))
    story.append(Spacer(1, 0.4 * cm))
    story.append(
        Paragraph(
            "This document is generated directly from the Laravel codebase and includes module inventory, "
            "relationships, APIs, permissions, storage schema evolution, and implementation-level references.",
            styles["body"],
        )
    )
    story.append(PageBreak())

    story.append(Paragraph("Architecture Overview", styles["h1"]))
    story.append(
        Paragraph(
            "Layer map: UI (Blade + JS), Web controllers, API controllers (Sanctum), policies/gates, "
            "support managers, Eloquent models, migrations, and integration layers (telephony, messengers, forms, disk).",
            styles["body"],
        )
    )
    arch_rows = [
        ["Layer", "Artifacts", "Purpose"],
        ["Presentation", "resources/views, resources/js, resources/css", "Desktop UI, sidebars, boards, and settings pages"],
        ["Web app", "app/Http/Controllers/*", "Server-rendered flows, CRUD, settings, and UX state operations"],
        ["REST API", "app/Http/Controllers/Api/* + routes/api.php", "Mobile/integration endpoints with token scopes"],
        ["Domain", "app/Models/*", "Business entities and relationship graph"],
        ["Security", "app/Policies/* + AccessControl/SectionAccess", "RBAC, per-entity action matrix, section-level grants"],
        ["Infrastructure", "migrations, queue/cache tables", "Persistence, evolution history, and runtime support"],
        ["Extensions", "CrmModuleManager hooks", "Module hook points for platform customization"],
    ]
    table = Table(arch_rows, colWidths=[3.5 * cm, 7.2 * cm, 7.6 * cm])
    table.setStyle(
        TableStyle(
            [
                ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1e293b")),
                ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
                ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
                ("FONTSIZE", (0, 0), (-1, -1), 9),
                ("VALIGN", (0, 0), (-1, -1), "TOP"),
                ("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#cbd5e1")),
                ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.HexColor("#f8fafc"), colors.HexColor("#f1f5f9")]),
            ]
        )
    )
    story.append(table)
    story.append(PageBreak())

    story.append(Paragraph("Core Charts", styles["h1"]))
    for chart_key, title in [
        ("route_coverage", "Route coverage by module"),
        ("module_dependencies", "Module dependency graph"),
        ("model_relations", "Entity relationship graph"),
        ("permission_surface", "Permission surface map"),
        ("migration_timeline", "Migration growth timeline"),
        ("code_volume", "Code volume by layer"),
    ]:
        story.append(Paragraph(title, styles["h2"]))
        story.append(image_flow(charts[chart_key], width_cm=18.5))
        story.append(PageBreak())

    story.append(Paragraph("Module Inventory", styles["h1"]))
    module_rows = [["Module", "Web routes", "API routes", "Controllers", "Models", "Menu", "RBAC", "API scopes"]]
    for module in sorted(module_stats):
        stats = module_stats[module]
        module_rows.append(
            [
                module,
                str(stats["web"]),
                str(stats["api"]),
                str(len(stats["controllers"])),
                str(len(stats["models"])),
                "yes" if module in menu_modules else "no",
                "yes" if module in access_entities else "no",
                "yes" if module in api_modules else "no",
            ]
        )
    table = Table(module_rows, colWidths=[3.1 * cm, 1.8 * cm, 1.8 * cm, 2.2 * cm, 1.8 * cm, 1.4 * cm, 1.4 * cm, 1.6 * cm], repeatRows=1)
    table.setStyle(
        TableStyle(
            [
                ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#0f172a")),
                ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
                ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
                ("FONTSIZE", (0, 0), (-1, -1), 8),
                ("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#94a3b8")),
                ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f8fafc")]),
                ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
            ]
        )
    )
    story.append(table)
    story.append(PageBreak())

    for module in sorted(module_stats):
        stats = module_stats[module]
        story.append(Paragraph(f"Module: {module}", styles["h1"]))
        story.append(
            Paragraph(
                "Capability profile combines route surface, data models, controller ownership, and security surface. "
                "The section can be used as a baseline for scaling, decomposition, and API hardening.",
                styles["body"],
            )
        )
        info_rows = [
            ["Metric", "Value"],
            ["Web route count", str(stats["web"])],
            ["API route count", str(stats["api"])],
            ["Controllers", ", ".join(stats["controllers"]) if stats["controllers"] else "-"],
            ["Models", ", ".join(stats["models"]) if stats["models"] else "-"],
            ["Route examples", "; ".join(stats["sample_routes"][:6]) if stats["sample_routes"] else "-"],
            ["Menu-visible", "yes" if module in menu_modules else "no"],
            ["RBAC matrix entity", "yes" if module in access_entities else "no"],
            ["API token scopes", "yes" if module in api_modules else "no"],
        ]
        table = Table(info_rows, colWidths=[4.4 * cm, 13.2 * cm])
        table.setStyle(
            TableStyle(
                [
                    ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#334155")),
                    ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
                    ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
                    ("FONTSIZE", (0, 0), (-1, -1), 9),
                    ("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#cbd5e1")),
                    ("VALIGN", (0, 0), (-1, -1), "TOP"),
                    ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.HexColor("#f8fafc"), colors.white]),
                ]
            )
        )
        story.append(table)
        related = [edge for edge, _ in module_edges.items() if edge.startswith(f"{module}->") or edge.endswith(f"->{module}")]
        story.append(
            Paragraph(
                "Dependency edges: " + (", ".join(related[:12]) if related else "no cross-module model dependencies detected."),
                styles["body"],
            )
        )
        story.append(PageBreak())

    for model in models:
        story.append(Paragraph(f"Model: {model.name}", styles["h1"]))
        story.append(
            Paragraph(
                f"Source: {model.path.relative_to(ROOT)}<br/>"
                f"Line count: {model.lines}<br/>"
                f"Assigned module: {model_module.get(model.name, 'core')}",
                styles["body"],
            )
        )
        if model.relations:
            relation_rows = [["Relation type", "Target model"]]
            for rel_type, target in model.relations:
                relation_rows.append([rel_type, target])
            table = Table(relation_rows, colWidths=[5.0 * cm, 12.6 * cm])
            table.setStyle(
                TableStyle(
                    [
                        ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1d4ed8")),
                        ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
                        ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
                        ("FONTSIZE", (0, 0), (-1, -1), 9),
                        ("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#bfdbfe")),
                        ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.HexColor("#eff6ff"), colors.white]),
                    ]
                )
            )
            story.append(table)
        else:
            story.append(Paragraph("No explicit Eloquent relations detected by static scan.", styles["body"]))

        methods = ", ".join(model.methods[:28]) if model.methods else "-"
        story.append(Paragraph(f"Public methods: {methods}", styles["body"]))
        story.append(PageBreak())

    routes_by_controller: dict[str, list[dict]] = defaultdict(list)
    for route in routes:
        action = str(route.get("action") or "")
        controller = action.split("@")[0].split("\\")[-1] if "@" in action else ""
        if controller:
            routes_by_controller[controller].append(route)

    for controller in controllers:
        story.append(Paragraph(f"Controller: {controller.name}", styles["h1"]))
        story.append(
            Paragraph(
                f"Source: {controller.path.relative_to(ROOT)}<br/>"
                f"Controller type: {controller.kind}<br/>"
                f"Line count: {controller.lines}<br/>"
                f"Inferred module: {controller_module_guess(controller.name, routes_by_controller)}",
                styles["body"],
            )
        )
        story.append(Paragraph(f"Methods ({len(controller.methods)}): {', '.join(controller.methods) if controller.methods else '-'}", styles["body"]))
        story.append(Paragraph(f"Model dependencies: {', '.join(controller.model_uses) if controller.model_uses else '-'}", styles["body"]))
        croutes = routes_by_controller.get(controller.name, [])
        if croutes:
            rows = [["Method", "URI", "Route name"]]
            for item in croutes[:18]:
                rows.append([item.get("method", ""), item.get("uri", ""), item.get("name", "") or "-"])
            table = Table(rows, colWidths=[3.0 * cm, 7.4 * cm, 7.2 * cm])
            table.setStyle(
                TableStyle(
                    [
                        ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#0f766e")),
                        ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
                        ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
                        ("FONTSIZE", (0, 0), (-1, -1), 8),
                        ("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#99f6e4")),
                        ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.HexColor("#f0fdfa"), colors.white]),
                    ]
                )
            )
            story.append(table)
        story.append(PageBreak())

    story.append(Paragraph("Policies and Access Surface", styles["h1"]))
    story.append(
        Paragraph(
            f"Policy classes: {len(policies)}<br/>"
            f"RBAC entities: {', '.join(sorted(access_entities))}<br/>"
            f"API token modules: {', '.join(sorted(api_modules))}",
            styles["body"],
        )
    )
    rows = [["Policy", "Methods", "Line count", "Path"]]
    for policy in policies:
        rows.append([policy.name, ", ".join(policy.methods) if policy.methods else "-", str(policy.lines), str(policy.path.relative_to(ROOT))])
    table = Table(rows, colWidths=[3.5 * cm, 6.2 * cm, 2.0 * cm, 6.0 * cm], repeatRows=1)
    table.setStyle(
        TableStyle(
            [
                ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#7c2d12")),
                ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
                ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
                ("FONTSIZE", (0, 0), (-1, -1), 8),
                ("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#fdba74")),
                ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.HexColor("#fff7ed"), colors.white]),
                ("VALIGN", (0, 0), (-1, -1), "TOP"),
            ]
        )
    )
    story.append(table)
    story.append(PageBreak())

    story.append(Paragraph("Migrations and Schema Evolution", styles["h1"]))
    rows = [["Timestamp", "Migration", "Creates", "Alters", "Path"]]
    for migration in migrations:
        rows.append(
            [
                migration.timestamp.strftime("%Y-%m-%d %H:%M:%S"),
                migration.name,
                ", ".join(migration.creates) if migration.creates else "-",
                ", ".join(migration.alters) if migration.alters else "-",
                str(migration.path.relative_to(ROOT)),
            ]
        )
    table = Table(rows, colWidths=[3.2 * cm, 5.7 * cm, 2.8 * cm, 2.8 * cm, 4.0 * cm], repeatRows=1)
    table.setStyle(
        TableStyle(
            [
                ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#4c1d95")),
                ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
                ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
                ("FONTSIZE", (0, 0), (-1, -1), 7),
                ("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#ddd6fe")),
                ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.HexColor("#f5f3ff"), colors.white]),
                ("VALIGN", (0, 0), (-1, -1), "TOP"),
            ]
        )
    )
    story.append(table)
    story.append(PageBreak())

    story.append(Paragraph("Support Layer and View Surface", styles["h1"]))
    story.append(Paragraph("Support classes (line count):", styles["h2"]))
    support_rows = [["Support class", "Lines"]]
    for name, lines in sorted(support_lines.items()):
        support_rows.append([name, str(lines)])
    table = Table(support_rows, colWidths=[12.0 * cm, 5.8 * cm], repeatRows=1)
    table.setStyle(
        TableStyle(
            [
                ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#111827")),
                ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
                ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
                ("FONTSIZE", (0, 0), (-1, -1), 9),
                ("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#d1d5db")),
                ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f9fafb")]),
            ]
        )
    )
    story.append(table)
    story.append(Spacer(1, 0.4 * cm))
    story.append(Paragraph("View sections (blade file count):", styles["h2"]))
    view_rows = [["View section", "Templates"]]
    for section, count in view_counts.most_common():
        view_rows.append([section, str(count)])
    table = Table(view_rows, colWidths=[12.0 * cm, 5.8 * cm], repeatRows=1)
    table.setStyle(
        TableStyle(
            [
                ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#0b3b2e")),
                ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
                ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
                ("FONTSIZE", (0, 0), (-1, -1), 9),
                ("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#86efac")),
                ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.HexColor("#f0fdf4"), colors.white]),
            ]
        )
    )
    story.append(table)
    story.append(PageBreak())

    story.append(Paragraph("Appendix: Route Catalog by Module", styles["h1"]))
    routes_by_module: dict[str, list[dict]] = defaultdict(list)
    for item in routes:
        routes_by_module[route_module(item.get("uri", ""))].append(item)
    for module in sorted(routes_by_module):
        story.append(Paragraph(f"Routes | {module}", styles["h2"]))
        rows = [["Method", "URI", "Name", "Action"]]
        for item in routes_by_module[module][:40]:
            action = str(item.get("action", ""))
            rows.append([item.get("method", ""), item.get("uri", ""), item.get("name", "") or "-", action[:56]])
        table = Table(rows, colWidths=[2.5 * cm, 4.8 * cm, 4.2 * cm, 6.3 * cm], repeatRows=1)
        table.setStyle(
            TableStyle(
                [
                    ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1e1b4b")),
                    ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
                    ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
                    ("FONTSIZE", (0, 0), (-1, -1), 7),
                    ("GRID", (0, 0), (-1, -1), 0.2, colors.HexColor("#c7d2fe")),
                    ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.HexColor("#eef2ff"), colors.white]),
                    ("VALIGN", (0, 0), (-1, -1), "TOP"),
                ]
            )
        )
        story.append(table)
        story.append(PageBreak())

    DOCS_DIR.mkdir(parents=True, exist_ok=True)
    doc = SimpleDocTemplate(
        str(PDF_PATH),
        pagesize=A4,
        leftMargin=1.2 * cm,
        rightMargin=1.2 * cm,
        topMargin=1.2 * cm,
        bottomMargin=1.5 * cm,
        title="CRM25 Technical Map",
        author="Codex",
        subject="Architecture map for CRM25",
    )
    doc.build(story, onFirstPage=page_number, onLaterPages=page_number)


def write_summary(module_stats: dict[str, dict], module_edges: Counter):
    lines = []
    lines.append("# CRM25 Technical Map Summary")
    lines.append("")
    lines.append(f"- Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
    lines.append(f"- Modules: {len(module_stats)}")
    lines.append("")
    lines.append("## Modules")
    lines.append("")
    lines.append("| Module | Web routes | API routes | Controllers | Models |")
    lines.append("|---|---:|---:|---:|---:|")
    for module in sorted(module_stats):
        stats = module_stats[module]
        lines.append(
            f"| {module} | {stats['web']} | {stats['api']} | {len(stats['controllers'])} | {len(stats['models'])} |"
        )
    lines.append("")
    lines.append("## Top Module Dependencies")
    lines.append("")
    lines.append("| Edge | Weight |")
    lines.append("|---|---:|")
    for edge, weight in module_edges.most_common(40):
        lines.append(f"| {edge} | {weight} |")
    SUMMARY_PATH.write_text("\n".join(lines), encoding="utf-8")


def main():
    menu_modules = php_const_keys(ROOT / "app" / "Support" / "MenuManager.php", "CORE_ITEMS")
    access_entities = php_const_keys(ROOT / "app" / "Support" / "AccessControl.php", "ENTITY_LABELS")
    api_modules = php_const_keys(ROOT / "app" / "Support" / "ApiTokenPermissionMatrix.php", "MODULES")

    routes = parse_routes()
    models = parse_models()
    controllers = parse_controllers()
    policies = parse_policies()
    migrations = parse_migrations()
    view_counts = parse_views()
    support_lines = parse_support_classes()

    routes_by_controller: dict[str, list[dict]] = defaultdict(list)
    for route in routes:
        action = str(route.get("action") or "")
        controller = action.split("@")[0].split("\\")[-1] if "@" in action else ""
        if controller:
            routes_by_controller[controller].append(route)

    route_modules = sorted({route_module(item.get("uri", "")) for item in routes})
    known_modules = set(menu_modules) | set(access_entities) | set(api_modules) | set(route_modules)

    model_module = {model.name: model_module_guess(model.name, known_modules) for model in models}

    module_stats: dict[str, dict] = {}
    for module in sorted(known_modules):
        module_stats[module] = {
            "web": 0,
            "api": 0,
            "controllers": set(),
            "models": set(),
            "sample_routes": [],
        }

    for route in routes:
        module = route_module(route.get("uri", ""))
        is_api = str(route.get("uri", "")).startswith("api/")
        stat = module_stats.setdefault(module, {"web": 0, "api": 0, "controllers": set(), "models": set(), "sample_routes": []})
        if is_api:
            stat["api"] += 1
        else:
            stat["web"] += 1
        uri = str(route.get("uri", ""))
        if len(stat["sample_routes"]) < 12:
            stat["sample_routes"].append(uri)
        action = str(route.get("action") or "")
        controller = action.split("@")[0].split("\\")[-1] if "@" in action else ""
        if controller:
            stat["controllers"].add(controller)

    for model in models:
        module = model_module.get(model.name, "core")
        stat = module_stats.setdefault(module, {"web": 0, "api": 0, "controllers": set(), "models": set(), "sample_routes": []})
        stat["models"].add(model.name)

    controller_map = {controller.name: controller for controller in controllers}
    module_edges: Counter = Counter()
    for model in models:
        source_module = model_module.get(model.name, "core")
        for _, target in model.relations:
            if target not in model_module:
                continue
            target_module = model_module[target]
            if source_module != target_module:
                module_edges[f"{source_module}->{target_module}"] += 1

    for controller_name, route_items in routes_by_controller.items():
        controller = controller_map.get(controller_name)
        if controller is None:
            continue
        source_module = controller_module_guess(controller_name, routes_by_controller)
        for model_name in controller.model_uses:
            target_module = model_module.get(model_name, "core")
            if source_module != target_module:
                module_edges[f"{source_module}->{target_module}"] += 1

    volume_by_layer = {
        "Models": parse_files_line_count((ROOT / "app" / "Models").glob("*.php")),
        "Controllers": parse_files_line_count((ROOT / "app" / "Http" / "Controllers").rglob("*.php")),
        "Policies": parse_files_line_count((ROOT / "app" / "Policies").glob("*.php")),
        "Support": parse_files_line_count((ROOT / "app" / "Support").glob("*.php")),
        "Views": parse_files_line_count((ROOT / "resources" / "views").rglob("*.blade.php")),
        "Migrations": parse_files_line_count((ROOT / "database" / "migrations").glob("*.php")),
    }

    charts = build_charts(
        module_stats=module_stats,
        model_infos=models,
        module_edges=module_edges,
        migration_infos=migrations,
        access_entities=access_entities,
        api_modules=api_modules,
        volume_by_layer=volume_by_layer,
    )

    write_summary(module_stats=module_stats, module_edges=module_edges)
    build_pdf(
        routes=routes,
        models=models,
        controllers=controllers,
        policies=policies,
        migrations=migrations,
        module_stats=module_stats,
        model_module=model_module,
        module_edges=module_edges,
        charts=charts,
        menu_modules=menu_modules,
        access_entities=access_entities,
        api_modules=api_modules,
        support_lines=support_lines,
        view_counts=view_counts,
    )

    planned_pages = 1
    planned_pages += 1
    planned_pages += 6
    planned_pages += 1
    planned_pages += len(module_stats)
    planned_pages += len(models)
    planned_pages += len(controllers)
    planned_pages += 1
    planned_pages += 1
    planned_pages += 1
    planned_pages += len(sorted({route_module(item.get("uri", "")) for item in routes}))

    print(f"PDF: {PDF_PATH}")
    print(f"Summary: {SUMMARY_PATH}")
    print(f"Estimated pages (minimum): {planned_pages}")
    print(f"Modules discovered: {len(module_stats)}")
    print(f"Routes discovered: {len(routes)}")


if __name__ == "__main__":
    main()
