"""Plain-text render functions for the output rendering framework.

Plain renderers for all 10 element types and the terminal escape
sanitiser.  Each renderer sanitizes user-supplied strings via
``_sanitize`` to prevent terminal escape injection (CWE-150, CWE-116).

Colour renderers live in ``_color_renderers.py``; box-drawing renderers
live in ``_boxdraw.py``.  Both were extracted from this file for
file-length compliance (R2-C2 fix).
"""

from __future__ import annotations

import re
import textwrap
from typing import Any

from cleveragents.cli.output.handles import (
    MAX_TREE_DEPTH,
    ActionHint,
    CodeBlock,
    DiffBlock,
    ElementSnapshot,
    Panel,
    ProgressIndicator,
    Separator,
    StatusMessage,
    Table,
    TextBlock,
    Tree,
    TreeNode,
)

# ---------------------------------------------------------------------------
# Terminal escape sanitization (C2 — CWE-150, CWE-116)
# ---------------------------------------------------------------------------

# Matches ANSI/VT escape sequences including:
# - 7-bit CSI (ESC [ params final) with private-mode parameter bytes
# - 8-bit CSI (0x9B params final)
# - OSC terminated by BEL or ST (ESC \)
# - Character set designation (ESC ( letter)
# - Miscellaneous single-char ESC commands
_TERMINAL_ESCAPE_RE = re.compile(
    r"(?:"
    r"\x1b\[[\x20-\x3f]*[\x40-\x7e]"  # 7-bit CSI: ESC [ params final
    r"|\x9b[\x20-\x3f]*[\x40-\x7e]"  # 8-bit CSI: 0x9b params final
    r"|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)"  # OSC: ESC ] ... (BEL | ST)
    r"|\x1b\([A-Za-z]"  # Charset designation: ESC ( letter
    r"|\x1b[><=]"  # Misc single-char: ESC> ESC< ESC=
    r")"
)

# Control characters and 8-bit C1 control characters (0x80-0x9F),
# excluding tab (0x09), newline (0x0A), carriage return (0x0D).
_CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")


def strip_terminal_escapes(text: str) -> str:
    """Remove ANSI/VT escape sequences and dangerous control characters.

    This prevents user-supplied strings from injecting terminal commands
    such as title changes, cursor movement, or fake styling.

    Applied to ALL user-supplied text at the rendering boundary in every
    materializer (plain, color, table, rich).  JSON/YAML serializers
    output structured data and do not need terminal sanitization.
    """
    text = _TERMINAL_ESCAPE_RE.sub("", text)
    text = _CONTROL_CHAR_RE.sub("", text)
    return text


# Readable shorthand used throughout the render functions.
_sanitize = strip_terminal_escapes


# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------


def _numeric_sort_key(val: Any) -> float:
    """Parse a numeric value for sorting; non-numeric values sort last."""
    try:
        return float(val)
    except (ValueError, TypeError):
        return float("inf")


def _sort_table_rows(table: Table) -> list[dict[str, Any]]:
    """Return rows sorted by sort_key if set, otherwise in insertion order.

    N7 fix: sort_key/sort_descending were stored but never applied.
    L2 fix (Luis review): always return a new list so callers never
    accidentally mutate the internal ``table.rows`` reference.
    P2-3 fix (Luis review): consults ``ColumnDef.col_type`` — columns
    with ``col_type="number"`` sort numerically instead of
    lexicographically (``"9" < "10"`` instead of ``"9" > "10"``).
    """
    if not table.sort_key:
        return list(table.rows)
    key = table.sort_key
    # P2-3: resolve column type for sort-key-aware comparison.
    col_type = "string"
    for col in table.columns:
        if col.name == key:
            col_type = col.col_type
            break
    try:
        if col_type == "number":
            return sorted(
                table.rows,
                key=lambda r: _numeric_sort_key(r.get(key, "")),
                reverse=table.sort_descending,
            )
        return sorted(
            table.rows,
            key=lambda r: str(r.get(key, "")),
            reverse=table.sort_descending,
        )
    except TypeError:
        return list(table.rows)  # pragma: no cover


def compute_column_widths(col_names: list[str], str_rows: list[list[str]]) -> list[int]:
    """Compute the display width for each column.

    P2-6 fix (Luis review): extracted from three renderers (plain,
    color, boxdraw) to a single shared helper, eliminating duplication.
    """
    widths = [len(c) for c in col_names]
    for row in str_rows:
        for i, cell in enumerate(row):
            if i < len(widths):
                widths[i] = max(widths[i], len(cell))
    return widths


# ---------------------------------------------------------------------------
# Plain renderers
# ---------------------------------------------------------------------------


def _render_panel_plain(panel: Panel) -> str:
    """Render a panel as plain ASCII text."""
    lines: list[str] = [_sanitize(panel.title)]
    for entry in panel.entries:
        lines.append(f"  {_sanitize(entry.key)}: {_sanitize(entry.value)}")
    return "\n".join(lines)


def _render_table_plain(table: Table) -> str:
    """Render a table as plain ASCII text with aligned columns."""
    if not table.columns:
        return "(empty table)"

    col_names = [_sanitize(c.name) for c in table.columns]
    rows = _sort_table_rows(table)

    str_rows: list[list[str]] = []
    for row in rows:
        str_rows.append([_sanitize(str(row.get(c.name, ""))) for c in table.columns])

    widths = compute_column_widths(col_names, str_rows)

    header = "  ".join(n.ljust(w) for n, w in zip(col_names, widths, strict=False))
    sep = "  ".join("-" * w for w in widths)

    lines: list[str] = []
    if table.title:
        lines.append(_sanitize(table.title))
    lines.append(header)
    lines.append(sep)
    for row in str_rows:
        lines.append("  ".join(c.ljust(w) for c, w in zip(row, widths, strict=False)))

    if table.summary:
        lines.append(sep)
        summary_parts: list[str] = []
        for key, val in table.summary.items():
            summary_parts.append(f"{_sanitize(str(key))}: {_sanitize(str(val))}")
        lines.append("  ".join(summary_parts))

    return "\n".join(lines)


def _render_status_plain(status: StatusMessage) -> str:
    """Render a status message as plain ASCII."""
    prefix_map = {
        "ok": "[OK]",
        "warn": "[WARN]",
        "error": "[ERROR]",
        "info": "[INFO]",
    }
    prefix = prefix_map.get(status.level, f"[{status.level.upper()}]")
    line = f"{prefix} {_sanitize(status.message)}"
    if status.detail:
        line += f"\n  {_sanitize(status.detail)}"
    return line


def _render_progress_plain(progress: ProgressIndicator) -> str:
    """Render a progress indicator as plain ASCII."""
    label = _sanitize(progress.label)
    lines: list[str] = []
    if progress.indeterminate:
        lines.append(f"{label}: ...")
    elif progress.total is not None and progress.current is not None:
        pct = int(progress.current * 100 / progress.total) if progress.total > 0 else 0
        lines.append(f"{label}: {progress.current}/{progress.total} ({pct}%)")
    elif progress.current is not None:
        lines.append(f"{label}: {progress.current}")
    else:
        lines.append(f"{label}: pending")

    if progress.steps:
        for step in progress.steps:
            marker_map = {
                "done": "[x]",
                "active": "[>]",
                "pending": "[ ]",
                "error": "[!]",
                "skipped": "[-]",
            }
            marker = marker_map.get(step.status, "[ ]")
            lines.append(f"  {marker} {_sanitize(step.label)}")

    return "\n".join(lines)


def _render_tree_plain(tree: Tree) -> str:
    """Render a tree as plain ASCII text with ``+--`` and ``|`` guides.

    Uses an output accumulator list (N9 fix) and a depth guard (F4 fix)
    to prevent O(N^2) list copies and unbounded recursion.
    """
    max_render_depth = MAX_TREE_DEPTH

    def _walk(
        node: TreeNode,
        prefix: str,
        is_last: bool,
        depth: int,
        output: list[str],
    ) -> None:
        connector = "+-- " if depth > 0 else ""
        output.append(f"{prefix}{connector}{_sanitize(node.label)}")
        if depth >= max_render_depth:
            if node.children:
                child_prefix = prefix + ("    " if is_last else "|   ")
                count = len(node.children)
                output.append(
                    f"{child_prefix}... ({count} more "
                    f"{'children' if count != 1 else 'child'})"
                )
            return
        if tree.max_depth_hint is not None and depth >= tree.max_depth_hint:
            if node.children:
                child_prefix = prefix + ("    " if is_last else "|   ")
                count = len(node.children)
                output.append(
                    f"{child_prefix}... ({count} more "
                    f"{'children' if count != 1 else 'child'})"
                )
            return
        for i, child in enumerate(node.children):
            last = i == len(node.children) - 1
            child_prefix = prefix + ("    " if is_last else "|   ") if depth > 0 else ""
            # L3 fix (Luis review): skip collapsed subtrees.
            if child.collapsed:
                output.append(f"{child_prefix}+-- {_sanitize(child.label)} [collapsed]")
                continue
            _walk(child, child_prefix, last, depth + 1, output)

    if not tree.show_guides:
        # Flat indented rendering without guide characters.
        effective_max = max_render_depth
        if tree.max_depth_hint is not None:
            effective_max = min(effective_max, tree.max_depth_hint)

        def _flat(node: TreeNode, indent: int, depth: int, output: list[str]) -> None:
            output.append(" " * indent + _sanitize(node.label))
            if depth >= effective_max:
                if node.children:
                    count = len(node.children)
                    output.append(
                        " " * (indent + 2)
                        + f"... ({count} more "
                        + f"{'children' if count != 1 else 'child'})"
                    )
                return
            for child in node.children:
                # L3 fix: skip collapsed subtrees in flat mode too.
                if child.collapsed:
                    output.append(
                        " " * (indent + 2) + _sanitize(child.label) + " [collapsed]"
                    )
                    continue
                _flat(child, indent + 2, depth + 1, output)

        out: list[str] = []
        _flat(tree.root, 0, 0, out)
        return "\n".join(out)

    out_lines: list[str] = []
    _walk(tree.root, "", True, 0, out_lines)
    return "\n".join(out_lines)


def _render_text_plain(text: TextBlock) -> str:
    """Render a text block as plain ASCII.

    When ``wrap`` is True, applies word-wrapping at 80 columns minus
    indent (N6 fix).  Splits on explicit newlines first so that
    ``textwrap.fill`` does not collapse them (R2-C2 fix #9).
    When False, preserves content verbatim with indent.
    """
    indent_str = " " * text.indent
    content = _sanitize(text.content)
    if text.wrap:
        width = max(80 - text.indent, 20)
        # Wrap each line independently to preserve explicit newlines.
        wrapped_lines: list[str] = []
        for line in content.split("\n"):
            if line:
                wrapped_lines.append(
                    textwrap.fill(
                        line,
                        width=width,
                        initial_indent=indent_str,
                        subsequent_indent=indent_str,
                    )
                )
            else:
                wrapped_lines.append("")
        return "\n".join(wrapped_lines)
    return "\n".join(f"{indent_str}{line}" for line in content.split("\n"))


def _render_code_plain(code: CodeBlock) -> str:
    """Render a code block as plain ASCII with optional line numbers."""
    content = _sanitize(code.content)
    lines = content.split("\n")
    if code.line_numbers:
        width = len(str(len(lines)))
        numbered: list[str] = []
        for i, line in enumerate(lines, 1):
            numbered.append(f"{i:>{width}} | {line}")
        return "\n".join(numbered)
    return content


def _render_diff_plain(diff: DiffBlock) -> str:
    """Render a diff block as plain unified diff."""
    lines: list[str] = []
    if diff.file_a or diff.file_b:
        lines.append(f"--- {_sanitize(diff.file_a or '/dev/null')}")
        lines.append(f"+++ {_sanitize(diff.file_b or '/dev/null')}")
    for hunk in diff.hunks:
        lines.append(_sanitize(hunk.header))
        for dline in hunk.lines:
            prefix_map = {"add": "+", "remove": "-", "context": " "}
            prefix = prefix_map.get(dline.line_type, " ")
            lines.append(f"{prefix}{_sanitize(dline.content)}")
    if diff.stats:
        ins = diff.stats.get("insertions", 0)
        dels = diff.stats.get("deletions", 0)
        lines.append(f"{ins} insertion(s), {dels} deletion(s)")
    return "\n".join(lines)


def _render_separator_plain(sep: Separator) -> str:
    """Render a separator as plain ASCII."""
    if sep.style == "blank":
        return ""
    if sep.style == "double":
        return "=" * 40
    return "-" * 40


def _render_action_hint_plain(hint: ActionHint) -> str:
    """Render an action hint as plain ASCII."""
    lines: list[str] = []
    if hint.description:
        lines.append(_sanitize(hint.description))
    if hint.commands:
        lines.append("Next steps:")
        for cmd in hint.commands:
            lines.append(f"  $ {_sanitize(cmd)}")
    return "\n".join(lines)


def render_element_plain(element: ElementSnapshot) -> str:
    """Dispatch to the correct plain renderer.

    All individual renderers apply sanitization to user strings internally.
    The fallback path also sanitizes.
    """
    if isinstance(element, Panel):
        return _render_panel_plain(element)
    if isinstance(element, Table):
        return _render_table_plain(element)
    if isinstance(element, StatusMessage):
        return _render_status_plain(element)
    if isinstance(element, ProgressIndicator):
        return _render_progress_plain(element)
    if isinstance(element, Tree):
        return _render_tree_plain(element)
    if isinstance(element, TextBlock):
        return _render_text_plain(element)
    if isinstance(element, CodeBlock):
        return _render_code_plain(element)
    if isinstance(element, DiffBlock):
        return _render_diff_plain(element)
    if isinstance(element, Separator):
        return _render_separator_plain(element)
    if isinstance(element, ActionHint):
        return _render_action_hint_plain(element)
    return _sanitize(str(element))  # pragma: no cover
