"""
Generate ConnectWise security-role documentation from the source XLSX.

Produces:
  - api-member-security-roles.yaml : machine-readable source of truth
  - api-member-security-roles.md   : human-readable reference

Re-run this script after editing the source XLSX. Both outputs are
deterministic — they will produce identical content from identical input,
so diffs in version control reflect only real permission-model changes.

Usage:
    python generate_role_docs.py \
        --source source/Security_Roles_Matrix_11132017.xlsx \
        --out-yaml ../api-member-security-roles.yaml \
        --out-md   ../api-member-security-roles.md
"""
from __future__ import annotations

import argparse
import re
from dataclasses import dataclass, field
from datetime import date
from pathlib import Path
from typing import Dict, List, Optional

import yaml
from openpyxl import load_workbook

# ---------------------------------------------------------------------------
# Parsing
# ---------------------------------------------------------------------------

# A level description line looks like "ALL: text..." or "NONE: text..."
# We capture the prefix (ALL | NONE | MINE | MY) and the trailing description.
LEVEL_LINE = re.compile(r"^(ALL|NONE|MINE|MY)\s*:\s*(.*)$", re.DOTALL)

# Recognized ConnectWise permission levels, most-to-least privileged.
LEVEL_ORDER = ["ALL", "MY", "MINE", "NONE"]

VERBS = ["add", "edit", "delete", "inquire"]
VERB_COLS = {"add": 3, "edit": 4, "delete": 5, "inquire": 6}


@dataclass
class CellPermission:
    """Parsed contents of a single (action, verb) cell."""

    levels: Dict[str, str] = field(default_factory=dict)  # level -> description
    note: Optional[str] = None  # for "Not applicable. See Inquire level." etc.
    raw: str = ""  # original cell text, preserved for audit


@dataclass
class ActionRow:
    module: str
    action: str
    permissions: Dict[str, CellPermission]  # verb -> CellPermission


def parse_cell(raw: Optional[str]) -> CellPermission:
    """Parse a single cell's multi-line content into levels + note."""
    if raw is None:
        return CellPermission(raw="")
    text = str(raw).strip()
    cp = CellPermission(raw=text)
    if not text:
        return cp

    # Split into candidate entries. Each entry is typically one line that
    # starts with a level prefix, but description text can itself contain
    # newlines. We therefore split on newlines and accumulate continuation
    # lines into the preceding entry.
    current_level: Optional[str] = None
    current_buf: List[str] = []
    note_buf: List[str] = []

    def flush_level() -> None:
        nonlocal current_level, current_buf
        if current_level is not None:
            cp.levels[current_level] = " ".join(current_buf).strip()
        current_level = None
        current_buf = []

    for line in text.splitlines():
        line = line.strip()
        if not line:
            continue
        m = LEVEL_LINE.match(line)
        if m:
            flush_level()
            current_level = m.group(1).upper()
            current_buf = [m.group(2).strip()]
        elif current_level is not None:
            current_buf.append(line)
        else:
            # No level prefix yet — belongs to the note.
            note_buf.append(line)
    flush_level()

    if note_buf:
        cp.note = " ".join(note_buf).strip()

    return cp


def read_matrix(xlsx_path: Path) -> List[ActionRow]:
    wb = load_workbook(xlsx_path, data_only=True)
    ws = wb.active  # Single sheet in this workbook.

    # Header row is row 2 per the source file; data begins row 3.
    actions: List[ActionRow] = []
    for r in range(3, ws.max_row + 1):
        module = ws.cell(row=r, column=1).value
        action = ws.cell(row=r, column=2).value
        if not (module or action):
            continue  # skip fully empty rows
        if not module or not action:
            # Partial row — keep but flag. This shouldn't happen in the
            # current source; if it does, the generator should fail loudly
            # rather than silently produce wrong output.
            raise ValueError(
                f"Row {r} has a missing Module or Action: "
                f"module={module!r}, action={action!r}"
            )

        perms: Dict[str, CellPermission] = {}
        for verb, col in VERB_COLS.items():
            perms[verb] = parse_cell(ws.cell(row=r, column=col).value)

        actions.append(
            ActionRow(module=module.strip(), action=action.strip(), permissions=perms)
        )
    return actions


# ---------------------------------------------------------------------------
# Output: YAML
# ---------------------------------------------------------------------------

def build_yaml_document(actions: List[ActionRow], source_file: str) -> dict:
    """Build a plain-dict representation that YAML dumps cleanly."""
    # Group by module, preserving action order within each module.
    modules: Dict[str, List[ActionRow]] = {}
    for a in actions:
        modules.setdefault(a.module, []).append(a)

    doc = {
        "metadata": {
            "source_file": source_file,
            "generated_on": date.today().isoformat(),
            "generator": "docs/integrations/connectwise/source/generate_role_docs.py",
            "description": (
                "ConnectWise security-role matrix. Each (module, action) entry "
                "describes what each access level (ALL, MY, MINE, NONE) means "
                "for the Add, Edit, Delete, and Inquire verbs. This is a "
                "reference catalog, not a per-role assignment — role "
                "assignments live in ConnectWise and are mirrored in the "
                "ResolutionFlow integration config."
            ),
            "level_order_most_to_least_privileged": LEVEL_ORDER,
        },
        "modules": {},
    }

    for module_name, rows in modules.items():
        module_block = {"actions": {}}
        for a in rows:
            action_block: Dict[str, object] = {}
            for verb in VERBS:
                cell = a.permissions[verb]
                entry: Dict[str, object] = {}
                if cell.levels:
                    # Emit levels in canonical order, only those present.
                    entry["levels"] = {
                        lvl: cell.levels[lvl]
                        for lvl in LEVEL_ORDER
                        if lvl in cell.levels
                    }
                if cell.note:
                    entry["note"] = cell.note
                if not entry:
                    # Truly empty cell — represent explicitly so downstream
                    # consumers can distinguish "empty" from "missing".
                    entry["note"] = "(no description provided)"
                action_block[verb] = entry
            module_block["actions"][a.action] = action_block
        doc["modules"][module_name] = module_block

    return doc


class _LiteralStr(str):
    """Marker type so PyYAML renders long strings as block literals."""


def _literal_presenter(dumper, data):
    return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|")


yaml.add_representer(_LiteralStr, _literal_presenter)


def _use_block_style_for_long_strings(obj):
    """Recursively wrap long strings so the YAML is readable, not one-line."""
    if isinstance(obj, dict):
        return {k: _use_block_style_for_long_strings(v) for k, v in obj.items()}
    if isinstance(obj, list):
        return [_use_block_style_for_long_strings(v) for v in obj]
    if isinstance(obj, str) and (len(obj) > 80 or "\n" in obj):
        return _LiteralStr(obj)
    return obj


def dump_yaml(doc: dict, out_path: Path) -> None:
    prepared = _use_block_style_for_long_strings(doc)
    out_path.parent.mkdir(parents=True, exist_ok=True)
    with out_path.open("w", encoding="utf-8") as f:
        f.write("# ConnectWise API Member Security Roles — reference matrix.\n")
        f.write("# Generated from the source XLSX; do not edit by hand.\n")
        f.write("# Re-run generate_role_docs.py after updating the XLSX.\n\n")
        yaml.dump(
            prepared,
            f,
            sort_keys=False,
            allow_unicode=True,
            width=100,
            default_flow_style=False,
        )


# ---------------------------------------------------------------------------
# Output: Markdown
# ---------------------------------------------------------------------------

def _md_escape(text: str) -> str:
    """Escape pipes and collapse whitespace for Markdown table cells."""
    return text.replace("|", "\\|").replace("\n", " ").strip()


def build_markdown(actions: List[ActionRow], source_file: str) -> str:
    modules: Dict[str, List[ActionRow]] = {}
    for a in actions:
        modules.setdefault(a.module, []).append(a)

    lines: List[str] = []
    lines.append("# ConnectWise API Member — Security Roles Reference")
    lines.append("")
    lines.append(
        f"_Generated {date.today().isoformat()} from "
        f"`{source_file}`. Do not edit by hand — update the XLSX and "
        f"re-run `generate_role_docs.py`._"
    )
    lines.append("")
    lines.append("## How to read this document")
    lines.append("")
    lines.append(
        "Each ConnectWise module lists the actions it governs. For every "
        "action, four permission verbs — **Add**, **Edit**, **Delete**, "
        "**Inquire** — can be granted at one of these levels, most to "
        "least privileged:"
    )
    lines.append("")
    lines.append("| Level | Meaning |")
    lines.append("|-------|---------|")
    lines.append("| `ALL` | Access to all records in the system. |")
    lines.append("| `MY` | Access to records owned by the user's team. |")
    lines.append("| `MINE` | Access only to records owned by the user. |")
    lines.append("| `NONE` | No access. |")
    lines.append("")
    lines.append(
        "Not every level applies to every action — the source matrix "
        "only documents the levels that are meaningful for each cell. "
        "Cells marked _Not applicable_ reference another verb (usually "
        "Inquire) where the meaningful level is defined."
    )
    lines.append("")
    lines.append(
        "The machine-readable form of this document is "
        "[`api-member-security-roles.yaml`](./api-member-security-roles.yaml). "
        "Use the YAML when writing integration code; use this Markdown "
        "when reviewing, discussing, or onboarding."
    )
    lines.append("")
    lines.append("## Table of contents")
    lines.append("")
    for module_name in modules:
        anchor = module_name.lower().replace(" ", "-").replace("/", "")
        lines.append(f"- [{module_name}](#{anchor}) — {len(modules[module_name])} actions")
    lines.append("")

    for module_name, rows in modules.items():
        lines.append(f"## {module_name}")
        lines.append("")
        for a in rows:
            lines.append(f"### {a.action}")
            lines.append("")
            lines.append("| Verb | Level | Description |")
            lines.append("|------|-------|-------------|")
            wrote_any = False
            for verb in VERBS:
                cell = a.permissions[verb]
                if cell.levels:
                    for lvl in LEVEL_ORDER:
                        if lvl in cell.levels:
                            lines.append(
                                f"| {verb.capitalize()} | `{lvl}` | "
                                f"{_md_escape(cell.levels[lvl])} |"
                            )
                            wrote_any = True
                elif cell.note:
                    lines.append(
                        f"| {verb.capitalize()} | — | "
                        f"_{_md_escape(cell.note)}_ |"
                    )
                    wrote_any = True
            if not wrote_any:
                lines.append("| — | — | _(no description provided)_ |")
            lines.append("")

    return "\n".join(lines) + "\n"


def write_markdown(md_text: str, out_path: Path) -> None:
    out_path.parent.mkdir(parents=True, exist_ok=True)
    out_path.write_text(md_text, encoding="utf-8")


# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------

def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--source", type=Path, required=True,
                        help="Path to the source .xlsx")
    parser.add_argument("--out-yaml", type=Path, required=True,
                        help="Path to write the YAML output")
    parser.add_argument("--out-md", type=Path, required=True,
                        help="Path to write the Markdown output")
    args = parser.parse_args()

    actions = read_matrix(args.source)
    doc = build_yaml_document(actions, source_file=args.source.name)
    dump_yaml(doc, args.out_yaml)

    md = build_markdown(actions, source_file=args.source.name)
    write_markdown(md, args.out_md)

    # Quick data-quality summary to stdout — helpful when re-running after edits.
    from collections import Counter
    modules_seen = Counter(a.module for a in actions)
    print(f"Parsed {len(actions)} actions across {len(modules_seen)} modules:")
    for m, n in modules_seen.most_common():
        print(f"  {m}: {n}")
    print(f"\nWrote {args.out_yaml}")
    print(f"Wrote {args.out_md}")


if __name__ == "__main__":
    main()
