"""Materialisation strategies for the output rendering framework.

Each strategy controls *when* elements are rendered.  The six strategies
correspond to the six supported ``--format`` values:

- **RichMaterializer** (``rich``): In-place terminal updates via Rich.
- **ColorMaterializer** (``color``): ANSI codes without cursor movement.
- **TableMaterializer** (``table``): Tabulate-style ASCII box-drawing.
- **PlainMaterializer** (``plain``): Sequential buffer-flush, ASCII-only.
- **JsonMaterializer** (``json``): Accumulate-then-serialize as JSON.
- **YamlMaterializer** (``yaml``): Accumulate-then-dump as YAML.

Based on the Output Rendering Framework specification (v3_spec.md §Output
Rendering Framework / Materialization Strategies).
"""

from __future__ import annotations

import json
import threading
from io import StringIO
from typing import TYPE_CHECKING, Any, Protocol

import yaml

from cleveragents.cli.output._boxdraw import render_element_table
from cleveragents.cli.output._color_renderers import render_element_color
from cleveragents.cli.output._renderers import (
    render_element_plain,
    strip_terminal_escapes,
)
from cleveragents.cli.output.handles import (
    MAX_TREE_DEPTH,
    ActionHint,
    CodeBlock,
    ColumnDef,
    DiffBlock,
    DiffLine,
    ElementClosed,
    ElementCreated,
    ElementSnapshot,
    ElementUpdated,
    Panel,
    PanelEntry,
    ProgressIndicator,
    Separator,
    SessionEnd,
    StatusMessage,
    Table,
    TextBlock,
    Tree,
    TreeNode,
)

if TYPE_CHECKING:
    from cleveragents.cli.output.session import OutputSession, StructuredOutput

# Re-export for backward compatibility
__all__ = [
    "ColorMaterializer",
    "JsonMaterializer",
    "LiveMaterializationStrategy",
    "MaterializationStrategy",
    "PlainMaterializer",
    "RichMaterializer",
    "TableMaterializer",
    "YamlMaterializer",
    "strip_terminal_escapes",
]


# ---------------------------------------------------------------------------
# MaterializationStrategy protocol
# ---------------------------------------------------------------------------


class MaterializationStrategy(Protocol):
    """Interface for format-driven output materialisation."""

    strategy_name: str
    supports_incremental_updates: bool

    def on_session_begin(self, session: OutputSession) -> None: ...  # pragma: no cover

    def on_element_created(self, event: ElementCreated) -> None: ...  # pragma: no cover

    def on_element_updated(self, event: ElementUpdated) -> None: ...  # pragma: no cover

    def on_element_closed(self, event: ElementClosed) -> None: ...  # pragma: no cover

    def on_session_end(self, event: SessionEnd) -> None: ...  # pragma: no cover


# ---------------------------------------------------------------------------
# JSON / YAML serialisation helpers
# ---------------------------------------------------------------------------


def _tree_node_to_dict(node: TreeNode, _depth: int = 0) -> dict[str, Any]:
    """Convert a TreeNode to a plain dict.

    Includes a depth guard (F4 fix) to prevent RecursionError on
    pathological trees that bypass the handle's depth enforcement.
    L3 fix (Luis review): includes the ``collapsed`` flag in the output.
    """
    result: dict[str, Any] = {"label": node.label}
    if node.style_hint:
        result["style_hint"] = node.style_hint
    if node.collapsed:
        result["collapsed"] = True
    if node.children:
        if _depth < MAX_TREE_DEPTH:
            result["children"] = [
                _tree_node_to_dict(c, _depth + 1) for c in node.children
            ]
        else:
            result["truncated"] = True
    if node.metadata:
        result["metadata"] = node.metadata
    return result


def _diff_line_to_dict(line: DiffLine) -> dict[str, Any]:
    """Convert a DiffLine to a plain dict.

    Serialises ``line_type`` under the key ``"type"`` for backward
    compatibility with the JSON/YAML schema (P2-1 alias).
    """
    result: dict[str, Any] = {"type": line.line_type, "content": line.content}
    if line.line_number_old is not None:
        result["line_number_old"] = line.line_number_old
    if line.line_number_new is not None:
        result["line_number_new"] = line.line_number_new
    return result


def _element_to_dict(element: ElementSnapshot) -> dict[str, Any]:
    """Convert an element snapshot to a plain dict for serialisation."""
    if isinstance(element, Panel):
        return {
            "type": "panel",
            "title": element.title,
            "entries": [_panel_entry_to_dict(e) for e in element.entries],
            "border_style": element.border_style,
            "priority": element.priority,
            "collapse_hint": element.collapse_hint,
            "metadata": element.metadata,
        }
    if isinstance(element, Table):
        return {
            "type": "table",
            "title": element.title,
            "columns": [_column_def_to_dict(c) for c in element.columns],
            "rows": element.rows,
            "summary": element.summary,
            "sort_key": element.sort_key,
            "sort_descending": element.sort_descending,
            "max_rows_hint": element.max_rows_hint,
            "priority": element.priority,
            "collapse_hint": element.collapse_hint,
            "metadata": element.metadata,
        }
    if isinstance(element, StatusMessage):
        return {
            "type": "status",
            "level": element.level,
            "message": element.message,
            "detail": element.detail,
            "priority": element.priority,
            "metadata": element.metadata,
        }
    if isinstance(element, ProgressIndicator):
        result: dict[str, Any] = {
            "type": "progress",
            "label": element.label,
            "current": element.current,
            "total": element.total,
            "indeterminate": element.indeterminate,
            "priority": element.priority,
            "metadata": element.metadata,
        }
        if element.steps:
            result["steps"] = [
                {"label": s.label, "status": s.status} for s in element.steps
            ]
        return result
    if isinstance(element, Tree):
        return {
            "type": "tree",
            "root": _tree_node_to_dict(element.root),
            "max_depth_hint": element.max_depth_hint,
            "show_guides": element.show_guides,
            "priority": element.priority,
            "collapse_hint": element.collapse_hint,
            "metadata": element.metadata,
        }
    if isinstance(element, TextBlock):
        return {
            "type": "text",
            "content": element.content,
            "wrap": element.wrap,
            "indent": element.indent,
            "priority": element.priority,
            "metadata": element.metadata,
        }
    if isinstance(element, CodeBlock):
        result_code: dict[str, Any] = {
            "type": "code",
            "content": element.content,
            "language": element.language,
            "line_numbers": element.line_numbers,
            "priority": element.priority,
            "metadata": element.metadata,
        }
        if element.highlight_lines:
            result_code["highlight_lines"] = element.highlight_lines
        return result_code
    if isinstance(element, DiffBlock):
        result_diff: dict[str, Any] = {
            "type": "diff",
            "file_a": element.file_a,
            "file_b": element.file_b,
            "hunks": [
                {
                    "header": h.header,
                    "lines": [_diff_line_to_dict(ln) for ln in h.lines],
                }
                for h in element.hunks
            ],
            "priority": element.priority,
            "metadata": element.metadata,
        }
        if element.stats:
            result_diff["stats"] = element.stats
        return result_diff
    if isinstance(element, Separator):
        return {"type": "separator", "style": element.style}
    if isinstance(element, ActionHint):
        return {
            "type": "action_hint",
            "commands": element.commands,
            "description": element.description,
        }
    return {"type": "unknown"}  # pragma: no cover


def _column_def_to_dict(col: ColumnDef) -> dict[str, Any]:
    """Convert a ColumnDef to a plain dict preserving all metadata.

    P2-4 fix (Luis review): always include all fields unconditionally
    so that machine consumers get a stable set of keys per ColumnDef,
    matching the spec's unconditional field listing.
    """
    return {
        "name": col.name,
        "col_type": col.col_type,
        "alignment": col.alignment,
        "width_hint": col.width_hint,
        "sortable": col.sortable,
        "style_hint": col.style_hint,
    }


def _panel_entry_to_dict(entry: PanelEntry) -> dict[str, Any]:
    """Convert a PanelEntry to a plain dict."""
    result: dict[str, Any] = {"key": entry.key, "value": entry.value}
    if entry.style_hint:
        result["style_hint"] = entry.style_hint
    if entry.icon:
        result["icon"] = entry.icon
    return result


def _snapshot_to_dict(snapshot: StructuredOutput) -> dict[str, Any]:
    """Convert a StructuredOutput to a serialisable dict.

    P1-2 fix (Luis review): includes the ``timing`` field when present,
    matching the spec's JSON envelope structure (§27022).

    M4 fix: renamed ``elements`` → ``data``, ``metadata`` → ``messages``,
    and added ``status`` field to match the specification envelope.
    """
    result: dict[str, Any] = {
        "command": snapshot.command,
        "status": snapshot.status,
        "exit_code": snapshot.exit_code,
        "data": [_element_to_dict(e) for e in snapshot.elements],
        "timing": snapshot.timing,
        "messages": snapshot.metadata,
    }
    return result


def _error_envelope(
    code: str, message: str, details: dict[str, Any] | None = None
) -> dict[str, Any]:
    """Build a unified error envelope for JSON/YAML output."""
    envelope: dict[str, Any] = {"error": {"code": code, "message": message}}
    if details:
        envelope["error"]["details"] = details
    return envelope


# ---------------------------------------------------------------------------
# Concrete strategy implementations
# ---------------------------------------------------------------------------


class _BaseBufferStrategy:
    """Base for strategies that buffer output and flush on element close.

    Thread-safe: all buffer mutations are serialised via ``_buf_lock`` (M9).
    """

    strategy_name: str = "base_buffer"
    supports_incremental_updates: bool = False

    def __init__(self) -> None:
        self._session: OutputSession | None = None
        self._stream: StringIO = StringIO()
        self._buffers: dict[int, str] = {}
        self._closed_indices: set[int] = set()
        self._next_render_index: int = 0
        self._index_map: dict[str, int] = {}  # handle_id -> declaration_index
        self._buf_lock: threading.Lock = threading.Lock()

    def on_session_begin(self, session: OutputSession) -> None:
        self._session = session

    def on_element_created(self, event: ElementCreated) -> None:
        with self._buf_lock:
            self._index_map[event.handle_id] = event.declaration_index

    def on_element_updated(self, event: ElementUpdated) -> None:
        pass  # Buffer strategies accumulate via the handle's element state

    def _render_element(self, element: ElementSnapshot) -> str:
        """Override in subclasses for format-specific rendering."""
        return render_element_plain(element)  # pragma: no cover

    def on_element_closed(self, event: ElementClosed) -> None:
        # Render outside the lock (may be expensive).
        if event.final_state is None:
            return  # pragma: no cover
        rendered = self._render_element(event.final_state)
        with self._buf_lock:
            idx = self._index_map.get(event.handle_id)
            if idx is None:
                return  # pragma: no cover
            self._closed_indices.add(idx)
            self._buffers[idx] = rendered
            self._flush_ready()

    def _flush_ready(self) -> None:
        """Flush buffered content in declaration order.

        Caller must hold ``_buf_lock``.
        """
        while self._next_render_index in self._closed_indices:
            content = self._buffers.pop(self._next_render_index, "")
            if content:
                self._stream.write(content)
                self._stream.write("\n\n")
            self._next_render_index += 1

    def on_session_end(self, event: SessionEnd) -> None:
        with self._buf_lock:
            for idx in sorted(self._buffers.keys()):
                content = self._buffers[idx]
                if content:
                    self._stream.write(content)
                    self._stream.write("\n\n")
            self._buffers.clear()

    def get_output(self) -> str:
        """Return all accumulated output."""
        with self._buf_lock:
            return self._stream.getvalue().rstrip("\n")


class PlainMaterializer(_BaseBufferStrategy):
    """Materialisation strategy for the ``plain`` format."""

    strategy_name: str = "plain"

    def _render_element(self, element: ElementSnapshot) -> str:
        return render_element_plain(element)


class ColorMaterializer(_BaseBufferStrategy):
    """Materialisation strategy for the ``color`` format."""

    strategy_name: str = "color"

    def _render_element(self, element: ElementSnapshot) -> str:
        return render_element_color(element)


class TableMaterializer(_BaseBufferStrategy):
    """Materialisation strategy for the ``table`` format."""

    strategy_name: str = "table"

    def _render_element(self, element: ElementSnapshot) -> str:
        return render_element_table(element)


class _LiveMaterializationStrategy:
    """Base for strategies that render in-place terminal updates at ~15 fps.

    The live strategy maintains a virtual screen of element regions arranged
    vertically in declaration order.  On each frame tick (capped at
    ``_frame_rate`` fps), all dirty elements are re-rendered using ANSI
    cursor movement so that updates appear in-place rather than scrolling.

    This is the third materialization strategy type alongside
    ``_BaseBufferStrategy`` (sequential buffer) and ``_AccumulateStrategy``
    (accumulate).  It is used by the ``rich`` format.

    Spec reference: LiveMaterializer (§26456-26492).

    Screen Layout
    -------------
    Elements are arranged vertically in declaration order.  Each element
    occupies a contiguous block of terminal lines tracked by ``_heights``.
    When an element's height changes (e.g. a table gains rows), subsequent
    elements are shifted down on the next frame.

    Concurrent Updates
    ------------------
    Updates from multiple handles are coalesced into a single frame refresh
    at the target frame rate.  The strategy maintains a ``_dirty_set`` and
    redraws all dirty elements in a single pass per frame.

    Thread Safety
    -------------
    All mutable state is protected by ``_lock``.  The public event methods
    acquire the lock, mark elements dirty, and check the frame timer.
    """

    strategy_name: str = "live"
    supports_incremental_updates: bool = True

    _FRAME_RATE: float = 15.0  # Maximum redraws per second

    def __init__(self) -> None:
        self._session: OutputSession | None = None
        self._stream: StringIO = StringIO()
        # Ordered mapping of handle_id -> declaration_index
        self._index_map: dict[str, int] = {}
        # Most recent rendered content per declaration index
        self._rendered: dict[int, str] = {}
        # Track heights (line count) per declaration index
        self._heights: dict[int, int] = {}
        # Set of declaration indices that have been modified since last frame
        self._dirty: set[int] = set()
        # Set of declaration indices whose handles have been closed (frozen)
        self._closed_indices: set[int] = set()
        # Last frame render timestamp
        self._last_frame: float = 0.0
        # Total lines written to the virtual screen in the last frame
        self._total_lines_written: int = 0
        self._lock: threading.Lock = threading.Lock()
        # Track whether session has ended (no more frame redraws)
        self._ended: bool = False

    @property
    def _frame_interval(self) -> float:
        return 1.0 / self._FRAME_RATE

    def _render_element(self, element: ElementSnapshot) -> str:
        """Render an element to a string.  Override in subclasses."""
        return render_element_color(element)

    def on_session_begin(self, session: OutputSession) -> None:
        self._session = session

    def on_element_created(self, event: ElementCreated) -> None:
        with self._lock:
            self._index_map[event.handle_id] = event.declaration_index
            if event.initial_state is not None:
                rendered = self._render_element(event.initial_state)
                self._rendered[event.declaration_index] = rendered
                self._heights[event.declaration_index] = rendered.count("\n") + 1
            else:
                self._rendered[event.declaration_index] = ""
                self._heights[event.declaration_index] = 0
            self._dirty.add(event.declaration_index)
            self._maybe_render_frame()

    def on_element_updated(self, event: ElementUpdated) -> None:
        with self._lock:
            idx = self._index_map.get(event.handle_id)
            if idx is None:
                return  # pragma: no cover
            if idx in self._closed_indices:
                return  # Frozen — no more updates
            if event.element_snapshot is not None:
                rendered = self._render_element(event.element_snapshot)
                self._rendered[idx] = rendered
                self._heights[idx] = rendered.count("\n") + 1
                self._dirty.add(idx)
            self._maybe_render_frame()

    def on_element_closed(self, event: ElementClosed) -> None:
        with self._lock:
            idx = self._index_map.get(event.handle_id)
            if idx is None:
                return  # pragma: no cover
            self._closed_indices.add(idx)
            if event.final_state is not None:
                rendered = self._render_element(event.final_state)
                self._rendered[idx] = rendered
                self._heights[idx] = rendered.count("\n") + 1
                self._dirty.add(idx)
            self._maybe_render_frame()

    def on_session_end(self, event: SessionEnd) -> None:
        with self._lock:
            self._ended = True
            # Force a final frame with all remaining dirty elements
            self._render_frame()

    def _maybe_render_frame(self) -> None:
        """Render a frame if enough time has elapsed since the last one.

        Must be called with ``_lock`` held.
        """
        import time as _time

        now = _time.monotonic()
        if (now - self._last_frame) >= self._frame_interval:
            self._render_frame()

    def _render_frame(self) -> None:
        """Compose all elements in declaration order and write to stream.

        Must be called with ``_lock`` held.

        For simplicity (and to avoid requiring real cursor movement which
        cannot be captured by ``StringIO``), this strategy appends each
        frame's complete render to the internal stream.  Only the *last*
        frame matters for ``get_output()``; earlier frames are overwritten.
        The final ``get_output()`` returns the content of the most recent
        complete frame.
        """
        import time as _time

        if not self._dirty:
            return
        self._dirty.clear()
        self._last_frame = _time.monotonic()

        # Compose elements in declaration order
        parts: list[str] = []
        for idx in sorted(self._rendered.keys()):
            content = self._rendered.get(idx, "")
            if content:
                parts.append(content)
        frame = "\n\n".join(parts)

        # Overwrite stream with latest frame
        self._stream = StringIO()
        self._stream.write(frame)
        self._total_lines_written = frame.count("\n") + 1

    def get_output(self) -> str:
        """Return the most recent frame output."""
        with self._lock:
            # If there are still dirty elements, do a final render
            if self._dirty:
                self._render_frame()
            return self._stream.getvalue().rstrip("\n")


class RichMaterializer(_LiveMaterializationStrategy):
    """Materialisation strategy for the ``rich`` format.

    Uses the live materialization strategy for in-place terminal updates
    at ~15 fps.  Element updates are coalesced into frame redraws using
    a dirty-element tracking set (spec §26456-26492).

    The visual rendering delegates to the colour renderer for each
    element type, maintaining backward compatibility with the existing
    ANSI colour output while enabling incremental live updates.
    """

    strategy_name: str = "rich"

    def _render_element(self, element: ElementSnapshot) -> str:
        return render_element_color(element)


class _AccumulateStrategy:
    """Base for strategies that accumulate all output and serialise on close."""

    strategy_name: str = "accumulate"
    supports_incremental_updates: bool = False

    def __init__(self) -> None:
        self._session: OutputSession | None = None

    def on_session_begin(self, session: OutputSession) -> None:
        self._session = session

    def on_element_created(self, event: ElementCreated) -> None:
        pass

    def on_element_updated(self, event: ElementUpdated) -> None:
        return  # accumulate strategies ignore incremental updates

    def on_element_closed(self, event: ElementClosed) -> None:
        return  # accumulate strategies ignore element close events

    def _serialise(self, snapshot: StructuredOutput) -> str:
        """Override in subclasses."""
        return ""  # pragma: no cover

    def on_session_end(self, event: SessionEnd) -> None:
        pass  # serialisation happens via get_output

    def get_output(self) -> str:
        """Serialise the complete session output."""
        if self._session is None:
            return ""
        snapshot = self._session.snapshot()
        return self._serialise(snapshot)

    def get_error_output(
        self,
        code: str,
        message: str,
        details: dict[str, Any] | None = None,
    ) -> str:
        """Serialise a unified error envelope."""
        envelope = _error_envelope(code, message, details)
        return self._serialise_dict(envelope)

    def _serialise_dict(self, data: dict[str, Any]) -> str:
        """Override in subclasses."""
        return ""  # pragma: no cover


class JsonMaterializer(_AccumulateStrategy):
    """Materialisation strategy for the ``json`` format."""

    strategy_name: str = "json"

    def _serialise(self, snapshot: StructuredOutput) -> str:
        return json.dumps(_snapshot_to_dict(snapshot), indent=2, default=str)

    def _serialise_dict(self, data: dict[str, Any]) -> str:
        return json.dumps(data, indent=2, default=str)


class YamlMaterializer(_AccumulateStrategy):
    """Materialisation strategy for the ``yaml`` format."""

    strategy_name: str = "yaml"

    def _serialise(self, snapshot: StructuredOutput) -> str:
        return yaml.safe_dump(
            _snapshot_to_dict(snapshot),
            default_flow_style=False,
            sort_keys=False,
            allow_unicode=True,
        ).rstrip("\n")

    def _serialise_dict(self, data: dict[str, Any]) -> str:
        return yaml.safe_dump(
            data,
            default_flow_style=False,
            sort_keys=False,
            allow_unicode=True,
        ).rstrip("\n")


# Public alias for the live materialization strategy base class.
LiveMaterializationStrategy = _LiveMaterializationStrategy
