"""Shared CLI output formatting helpers.

Provides ``format_output`` to render data as JSON, YAML, plain text,
ASCII table, Rich console markup, or ANSI colour.  All serialisation
uses stable field names from the domain models' ``as_cli_dict`` methods.

The implementation now delegates to the Output Rendering Framework
(``cleveragents.cli.output``) while preserving the original public API
for backward compatibility.

Based on v3_spec.md implementation plan Stage A4b / G5b.render.
"""

from __future__ import annotations

import json
import sys
import time
from collections.abc import Mapping, Sequence
from datetime import datetime
from enum import Enum, StrEnum
from io import StringIO
from typing import Any

import yaml
from rich import box
from rich.console import Console
from rich.table import Table

from cleveragents.cli.output.materializers import (
    ColorMaterializer,
    JsonMaterializer,
    PlainMaterializer,
    RichMaterializer,
    TableMaterializer,
    YamlMaterializer,
)
from cleveragents.cli.output.session import OutputSession


class OutputFormat(StrEnum):
    """Supported CLI output formats."""

    JSON = "json"
    YAML = "yaml"
    PLAIN = "plain"
    TABLE = "table"
    RICH = "rich"
    COLOR = "color"


def _serialize_value(value: Any) -> Any:
    """Recursively normalise values for JSON/YAML serialisation.

    * ``datetime`` -> ISO-8601 string
    * ``Enum``     -> ``.value``
    * ``dict``     -> recursed
    * ``list``     -> recursed
    * everything else -> unchanged
    """
    if isinstance(value, datetime):
        return value.isoformat()
    if isinstance(value, Enum):
        return value.value
    if isinstance(value, dict):
        return {k: _serialize_value(v) for k, v in value.items()}
    if isinstance(value, list):
        return [_serialize_value(item) for item in value]
    return value


def _format_json(data: dict[str, Any] | list[dict[str, Any]]) -> str:
    """Render data as indented JSON."""
    return json.dumps(_serialize_value(data), indent=2, default=str)


def _format_yaml(data: dict[str, Any] | list[dict[str, Any]]) -> str:
    """Render data as YAML."""
    return yaml.dump(
        _serialize_value(data),
        default_flow_style=False,
        sort_keys=False,
        allow_unicode=True,
    ).rstrip("\n")


def _format_plain(data: dict[str, Any] | list[dict[str, Any]]) -> str:
    """Render data as plain key: value lines (no Rich markup)."""
    if isinstance(data, list):
        parts: list[str] = []
        for idx, item in enumerate(data):
            if idx > 0:
                parts.append("---")
            parts.append(_format_plain_dict(item))
        return "\n".join(parts)
    return _format_plain_dict(data)


def _format_plain_dict(data: dict[str, Any]) -> str:
    """Render a single dict as plain text."""
    lines: list[str] = []
    for key, value in data.items():
        serialised = _serialize_value(value)
        if isinstance(serialised, dict):
            lines.append(f"{key}:")
            for k2, v2 in serialised.items():
                lines.append(f"  {k2}: {v2}")
        elif isinstance(serialised, list):
            lines.append(f"{key}:")
            for item in serialised:
                if isinstance(item, dict):
                    lines.append(f"  - {json.dumps(item, default=str)}")
                else:
                    lines.append(f"  - {item}")
        else:
            lines.append(f"{key}: {serialised}")
    return "\n".join(lines)


def _format_table(data: dict[str, Any] | list[dict[str, Any]]) -> str:
    """Render data as an ASCII table (no Rich styling)."""
    rows: list[dict[str, Any]] = [data] if isinstance(data, dict) else data

    if not rows:
        return "(empty)"

    # Collect all keys preserving order from first row
    columns: list[str] = list(rows[0].keys())
    for row in rows[1:]:
        for key in row:
            if key not in columns:
                columns.append(key)

    # Build Rich table and capture to string. Use ASCII box style so the
    # output uses '|' and '+' border characters (matches the docstring
    # contract "ASCII table" and what `--format table` callers expect).
    table = Table(show_header=True, show_edge=True, box=box.ASCII)
    for col in columns:
        table.add_column(col)
    for row in rows:
        cells: list[str] = []
        for col in columns:
            val = row.get(col, "")
            serialised = _serialize_value(val)
            if isinstance(serialised, (dict, list)):
                cells.append(json.dumps(serialised, default=str))
            else:
                cells.append(str(serialised))
        table.add_row(*cells)

    buf = StringIO()
    console = Console(file=buf, width=200, no_color=True)
    console.print(table)
    return buf.getvalue().rstrip("\n")


def _format_rich(data: dict[str, Any] | list[dict[str, Any]]) -> str:
    """Render data using the Rich materializer (ANSI-styled terminal output).

    Delegates to :func:`format_output_session` with the ``rich`` strategy so
    that the output is consistent with the newer session-based rendering path.
    The data passed here has already been redacted by the caller.
    """
    strategy = RichMaterializer()
    with OutputSession(
        format="rich", command="format_output", strategy=strategy
    ) as session:
        if isinstance(data, list):
            for idx, item in enumerate(data):
                if isinstance(item, dict):
                    panel = session.panel(f"Item {idx + 1}")
                    for key, val in item.items():
                        panel.set_entry(key, str(_serialize_value(val)))
                    panel.close()
        elif isinstance(data, dict):
            panel = session.panel("Output")
            for key, val in data.items():
                panel.set_entry(key, str(_serialize_value(val)))
            panel.close()
    return strategy.get_output()


def _format_color(data: dict[str, Any] | list[dict[str, Any]]) -> str:
    """Render data using the Color materializer (ANSI-colored terminal output).

    Delegates to :func:`format_output_session` with the ``color`` strategy so
    that the output is consistent with the newer session-based rendering path.
    The data passed here has already been redacted by the caller.
    """
    strategy = ColorMaterializer()
    with OutputSession(
        format="color", command="format_output", strategy=strategy
    ) as session:
        if isinstance(data, list):
            for idx, item in enumerate(data):
                if isinstance(item, dict):
                    panel = session.panel(f"Item {idx + 1}")
                    for key, val in item.items():
                        panel.set_entry(key, str(_serialize_value(val)))
                    panel.close()
        elif isinstance(data, dict):
            panel = session.panel("Output")
            for key, val in data.items():
                panel.set_entry(key, str(_serialize_value(val)))
            panel.close()
    return strategy.get_output()


def _redact_data(
    data: dict[str, Any] | list[dict[str, Any]],
) -> dict[str, Any] | list[dict[str, Any]]:
    """Apply secrets redaction to output data before rendering."""
    from cleveragents.shared.redaction import redact_dict

    if isinstance(data, list):
        return [redact_dict(item) if isinstance(item, dict) else item for item in data]
    return redact_dict(data)


_VALID_STATUSES: frozenset[str] = frozenset({"ok", "warn", "error"})


def _prepare_messages(
    messages: Sequence[str | Mapping[str, Any]] | None,
    command: str,
    status: str,
) -> tuple[list[str | dict[str, str]], list[tuple[str, str]]]:
    """Normalise messages for envelope and plain output rendering."""
    if not messages:
        text_msg = f"{command} completed" if command else "ok"
        return ([{"level": status, "text": text_msg}], [(status, text_msg)])

    envelope_messages: list[str | dict[str, str]] = []
    plain_pairs: list[tuple[str, str]] = []
    for msg in messages:
        if isinstance(msg, str):
            envelope_messages.append(msg)
            plain_pairs.append((status, msg))
            continue
        if "level" not in msg or "text" not in msg:
            raise ValueError(
                f"Each message must have 'level' and 'text' keys, got {msg!r}"
            )
        level = str(msg["level"])
        text_value = msg["text"]
        text_str = str(text_value)
        envelope_messages.append({"level": level, "text": text_str})
        plain_pairs.append((level, text_str))
    return envelope_messages, plain_pairs


def _format_plain_messages(messages: Sequence[tuple[str, str]]) -> list[str]:
    """Render message pairs for plain output."""
    lines: list[str] = []
    for level, text_value in messages:
        level_str = level.upper() if level else ""
        prefix = f"[{level_str}] " if level_str else ""
        lines.append(f"{prefix}{text_value}")
    return lines


def _build_envelope(
    data: dict[str, Any] | list[dict[str, Any]],
    command: str,
    status: str,
    exit_code: int,
    duration_ms: int,
    messages: Sequence[str | dict[str, str]],
    started_at: datetime | None = None,
) -> dict[str, Any]:
    """Build the spec-required JSON/YAML output envelope."""
    if status not in _VALID_STATUSES:
        raise ValueError(f"status must be one of {_VALID_STATUSES!r}, got {status!r}")
    if exit_code < 0:
        raise ValueError(f"exit_code must be non-negative, got {exit_code!r}")
    timing: dict[str, Any] = {"duration_ms": duration_ms}
    if started_at is not None:
        timing["started"] = started_at.isoformat()
    return {
        "command": command,
        "status": status,
        "exit_code": exit_code,
        "data": data,
        "timing": timing,
        "messages": list(messages),
    }


def format_output(
    data: dict[str, Any] | list[dict[str, Any]],
    format_type: str,
    *,
    command: str = "",
    status: str = "ok",
    exit_code: int = 0,
    messages: Sequence[str | Mapping[str, Any]] | None = None,
    started_at: datetime | None = None,
) -> str:
    """Format *data* according to *format_type*.

    All output is passed through :func:`redact_dict` before
    rendering so that sensitive values are masked unless the
    global ``show_secrets`` flag is enabled.

    For machine-readable formats (json, yaml) the output is wrapped in
    the spec-required envelope (``command``, ``status``, ``exit_code``,
    ``data``, ``timing``, ``messages``) before serialisation.  Plain
    format renders the raw data without an envelope but still appends
    the human-readable messages block.

    For machine-readable formats (json, yaml, plain) the output is
    written directly to ``sys.stdout`` to avoid Rich ``console.print``
    line-wrapping which can introduce literal newlines inside JSON
    string values.  An empty string is returned so the caller's
    ``console.print(result)`` simply emits a trailing newline.

    Parameters
    ----------
    data:
        A single dict or a list of dicts (from ``model.as_cli_dict()``).
    format_type:
        One of ``json``, ``yaml``, ``plain``, ``table``, ``rich``, ``color``.
    command:
        The CLI command string that produced this output (e.g.
        ``"agents actor list"``).  Used in the JSON/YAML envelope.
    status:
        Envelope status field: ``"ok"``, ``"warn"``, or ``"error"``.
    exit_code:
        Envelope exit_code field (integer, typically 0 for success).
    messages:
        Envelope messages array.  Entries may be plain strings or dicts
        with ``level``/``text`` keys.  If *None*, a default single-entry
        list is generated from *command* and *status*.

    Returns
    -------
    str
        The rendered string.  For machine-readable formats the output
        is written directly to stdout and an empty string is returned.
    """
    t_start = time.monotonic()
    safe_data = _redact_data(data)
    fmt = format_type.lower()

    envelope_messages, plain_pairs = _prepare_messages(messages, command, status)

    if fmt in (
        OutputFormat.JSON.value,
        OutputFormat.YAML.value,
        OutputFormat.PLAIN.value,
    ):
        if fmt == OutputFormat.JSON.value:
            duration_ms = int((time.monotonic() - t_start) * 1000)
            envelope = _build_envelope(
                safe_data,
                command,
                status,
                exit_code,
                duration_ms,
                envelope_messages,
                started_at=started_at,
            )
            rendered = _format_json(envelope)
        elif fmt == OutputFormat.YAML.value:
            duration_ms = int((time.monotonic() - t_start) * 1000)
            envelope = _build_envelope(
                safe_data,
                command,
                status,
                exit_code,
                duration_ms,
                envelope_messages,
                started_at=started_at,
            )
            rendered = _format_yaml(envelope)
        else:
            rendered = _format_plain(safe_data)
            message_lines = _format_plain_messages(plain_pairs)
            if message_lines:
                prefix = f"{rendered}\n" if rendered else ""
                message_block = "\n".join(message_lines)
                rendered = f"{prefix}{message_block}"
        sys.stdout.write(rendered + "\n")
        sys.stdout.flush()
        return ""

    if fmt == OutputFormat.TABLE.value:
        return _format_table(safe_data)
    if fmt == OutputFormat.RICH.value:
        return _format_rich(safe_data)
    if fmt == OutputFormat.COLOR.value:
        return _format_color(safe_data)
    # Unknown format: fall back to JSON
    return _format_json(safe_data)


def format_output_session(
    data: dict[str, Any] | list[dict[str, Any]],
    format_type: str,
) -> str:
    """Format *data* via the ``OutputSession`` framework.

    This is the new entry-point that fully utilises the output rendering
    framework.  It converts a dict/list payload into panels in an
    ``OutputSession``, applies the requested materialiser, and returns
    the rendered string.
    """
    safe_data = _redact_data(data)
    strategy_map: dict[str, type] = {
        "rich": RichMaterializer,
        "color": ColorMaterializer,
        "table": TableMaterializer,
        "plain": PlainMaterializer,
        "json": JsonMaterializer,
        "yaml": YamlMaterializer,
    }

    fmt = format_type.lower()
    strategy_cls = strategy_map.get(fmt, PlainMaterializer)
    strategy = strategy_cls()

    with OutputSession(
        format=fmt, command="format_output", strategy=strategy
    ) as session:
        if isinstance(safe_data, list):
            for idx, item in enumerate(safe_data):
                if isinstance(item, dict):
                    panel = session.panel(f"Item {idx + 1}")
                    for key, val in item.items():
                        panel.set_entry(key, str(_serialize_value(val)))
                    panel.close()
        elif isinstance(safe_data, dict):
            panel = session.panel("Output")
            for key, val in safe_data.items():
                panel.set_entry(key, str(_serialize_value(val)))
            panel.close()

    return strategy.get_output()
