from __future__ import annotations

from abc import ABC, abstractmethod
from dataclasses import dataclass
from itertools import islice
from typing import TYPE_CHECKING, Callable, Protocol

import rich.repr
from rich.console import Console, ConsoleOptions, RenderableType
from rich.measure import Measurement
from rich.protocol import is_renderable, rich_cast
from rich.segment import Segment
from rich.style import NULL_STYLE as RICH_NULL_STYLE
from rich.style import Style as RichStyle
from rich.text import Text

from textual._context import active_app
from textual.css.styles import RulesMap
from textual.geometry import Spacing
from textual.render import measure
from textual.selection import Selection
from textual.strip import Strip
from textual.style import Style

if TYPE_CHECKING:
    from typing_extensions import TypeAlias

    from textual.widget import Widget


def is_visual(obj: object) -> bool:
    """Check if the given object is a Visual or supports the Visual protocol."""
    return isinstance(obj, Visual) or hasattr(obj, "textualize")


@dataclass(frozen=True)
class RenderOptions:
    """Additional options passed to `Visual.render_strips`."""

    get_style: Callable[[str | Style], Style]
    """Callable to get a style."""
    rules: RulesMap
    """Mapping of style rules."""
    selection: Selection | None = None
    """Text selection information."""
    selection_style: Style | None = None
    """Style of text selection."""
    post_style: Style | None = None
    """Optional style to apply post render."""


# Note: not runtime checkable currently, as I've found that to be slow
class SupportsVisual(Protocol):
    """An object that supports the textualize protocol."""

    def visualize(self, widget: Widget, obj: object) -> Visual | None:
        """Convert the result of a Widget.render() call into a Visual, using the Visual protocol.

        Args:
            widget: The widget that generated the render.
            obj: The result of the render.

        Returns:
            A Visual instance, or `None` if it wasn't possible.

        """


class VisualError(Exception):
    """An error with the visual protocol."""


VisualType: TypeAlias = "RenderableType | SupportsVisual | Visual"


def visualize(widget: Widget, obj: object, markup: bool = True) -> Visual:
    """Get a visual instance from an object.

    If the object does not support the Visual protocol and is a Rich renderable, it
    will be wrapped in a [RichVisual][textual.visual.RichVisual].

    Args:
        widget: The parent widget.
        obj: An object.
        markup: Enable markup.

    Raises:
        VisualError: If there is no Visual could be returned to render `obj`.

    Returns:
        A Visual instance to render the object, or `None` if there is no associated visual.
    """
    _rich_traceback_omit = True
    if isinstance(obj, Visual):
        # Already a visual
        return obj
    # The visualize method should return a Visual if present.
    visualize = getattr(obj, "visualize", None)
    if visualize is None:
        # Doesn't expose the textualize protocol
        from textual.content import Content

        if isinstance(obj, str):
            return Content.from_markup(obj) if markup else Content(obj)

        if is_renderable(obj):
            if isinstance(obj, Text):
                return Content.from_rich_text(obj, console=widget.app.console)

            # If its is a Rich renderable, wrap it with a RichVisual
            return RichVisual(widget, rich_cast(obj))
        else:
            # We don't know how to make a visual from this object
            raise VisualError(
                f"unable to display {obj.__class__.__name__!r} type; must be a str, Rich renderable, or Textual Visual object"
            )
    # Call the textualize method to create a visual
    visual = visualize()
    if not isinstance(visual, Visual) and is_renderable(visual):
        return RichVisual(widget, visual)
    return visual


class Visual(ABC):
    """A Textual 'Visual' object.

    Analogous to a Rich renderable, but with support for transparency.

    """

    @abstractmethod
    def render_strips(
        self, width: int, height: int | None, style: Style, options: RenderOptions
    ) -> list[Strip]:
        """Render the Visual into an iterable of strips.

        Args:
            width: Width of desired render.
            height: Height of desired render or `None` for any height.
            style: The base style to render on top of.
            options: Additional render options.

        Returns:
            An list of Strips.
        """

    @abstractmethod
    def get_optimal_width(self, rules: RulesMap, container_width: int) -> int:
        """Get optimal width of the Visual to display its content.

        The exact definition of "optimal width" is dependant on the Visual, but
        will typically be wide enough to display output without cropping or wrapping,
        and without superfluous space.

        Args:
            rules: A mapping of style rules, such as the Widgets `styles` object.
            container_width: The width of the container, used by Rich Renderables.
                May be ignored for Textual Visuals.

        Returns:
            A width in cells.

        """

    def get_minimal_width(self, rules: RulesMap) -> int:
        """Get a minimal width (the smallest width before data loss occurs).

        Args:
            rules: A mapping of style rules, such as the Widgets `styles` object.
            container_width: The width of the container, used by Rich Renderables.
                May be ignored for Textual Visuals.

        Returns:
            A width in cells.

        """
        return 1

    @abstractmethod
    def get_height(self, rules: RulesMap, width: int) -> int:
        """Get the height of the Visual if rendered at the given width.

        Args:
            rules: A mapping of style rules, such as the Widgets `styles` object.
            width: Width of visual in cells.

        Returns:
            A height in lines.
        """

    @classmethod
    def to_strips(
        cls,
        widget: Widget,
        visual: Visual,
        width: int,
        height: int | None,
        style: Style,
        *,
        apply_selection: bool = True,
        pad: bool = False,
        post_style: Style | None = None,
    ) -> list[Strip]:
        """High level function to render a visual to strips.

        Args:
            widget: Widget that produced the visual.
            visual: A Visual instance.
            width: Desired width (in cells).
            height: Desired height (in lines) or `None` for no limit.
            style: A (Visual) Style instance.
            apply_selection: Automatically apply selection styles?
            pad: Pad to desired width?
            post_style: Optional Style to apply to strips after rendering.

        Returns:
            A list of Strips containing the render.
        """

        selection = widget.text_selection
        if selection is not None:
            selection_style: Style | None = Style.from_rich_style(
                widget.screen.get_component_rich_style(
                    "screen--selection", default=RICH_NULL_STYLE
                )
            )
        else:
            selection_style = None

        strips = visual.render_strips(
            width,
            height,
            style,
            RenderOptions(
                widget._get_style,
                widget.styles,
                selection if apply_selection else None,
                selection_style,
            ),
        )
        if widget.auto_links and not widget.is_container:
            # TODO: This is suprisingly expensive (why?)
            link_style = widget.link_style
            strips = [strip._apply_link_style(link_style) for strip in strips]

        if height is None:
            height = len(strips)
        rich_style = (style + Style(reverse=False)).rich_style
        if pad:
            strips = [strip.extend_cell_length(width, rich_style) for strip in strips]
        content_align = widget.styles.content_align
        if content_align != ("left", "top"):
            align_horizontal, align_vertical = content_align
            strips = list(
                Strip.align(
                    strips,
                    rich_style,
                    width,
                    height,
                    align_horizontal,
                    align_vertical,
                )
            )
        return strips


@rich.repr.auto
class RichVisual(Visual):
    """A Visual to wrap a Rich renderable."""

    def __init__(self, widget: Widget, renderable: RenderableType) -> None:
        """

        Args:
            widget: The associated Widget.
            renderable: A Rich renderable.
        """
        self._widget = widget
        self._renderable = renderable
        self._measurement: Measurement | None = None

    def __rich_repr__(self) -> rich.repr.Result:
        yield self._widget
        yield self._renderable

    def _measure(self, console: Console, options: ConsoleOptions) -> Measurement:
        if self._measurement is None:
            self._measurement = Measurement.get(
                console,
                options,
                self._widget.post_render(self._renderable, RichStyle.null()),
            )
        return self._measurement

    def get_optimal_width(self, rules: RulesMap, container_width: int) -> int:
        console = active_app.get().console
        width = measure(
            console, self._renderable, container_width, container_width=container_width
        )
        return width

    def get_height(self, rules: RulesMap, width: int) -> int:
        app = active_app.get()
        console = app.console
        renderable = self._renderable
        if isinstance(renderable, Text):
            height = len(
                Text(renderable.plain).wrap(
                    console,
                    width,
                    no_wrap=renderable.no_wrap,
                    tab_size=renderable.tab_size or 8,
                )
            )
        else:
            console_options = app.console_options
            options = console_options.update_width(width).update(highlight=False)
            segments = console.render(renderable, options)
            # Cheaper than counting the lines returned from render_lines!
            height = sum([text.count("\n") for text, _, _ in segments])

        return height

    def render_strips(
        self, width: int, height: int | None, style: Style, options: RenderOptions
    ) -> list[Strip]:
        """Render the Visual into an iterable of strips. Part of the Visual protocol.

        Args:
            width: Width of desired render.
            height: Height of desired render or `None` for any height.
            style: The base style to render on top of.
            options: Additional render options.

        Returns:
            An list of Strips.
        """
        app = active_app.get()
        console = app.console
        console_options = app.console_options.update(
            highlight=False,
            width=width,
            height=height,
        )
        rich_style = style.rich_style
        renderable = self._widget.post_render(self._renderable, rich_style)
        segments = console.render(renderable, console_options.update_width(width))
        strips = [
            Strip(line)
            for line in islice(
                Segment.split_and_crop_lines(
                    segments, width, include_new_lines=False, pad=False
                ),
                None,
                height,
            )
        ]

        return strips


@rich.repr.auto
class Padding(Visual):
    """A Visual to pad another visual."""

    def __init__(self, visual: Visual, spacing: Spacing) -> None:
        """

        Args:
            Visual: A Visual.
            spacing: A Spacing object containing desired padding dimensions.
        """
        self._visual = visual
        self._spacing = spacing

    def __rich_repr__(self) -> rich.repr.Result:
        yield self._visual
        yield self._spacing

    def get_optimal_width(self, rules: RulesMap, container_width: int) -> int:
        return (
            self._visual.get_optimal_width(rules, container_width) + self._spacing.width
        )

    def get_height(self, rules: RulesMap, width: int) -> int:
        return (
            self._visual.get_height(rules, width - self._spacing.width)
            + self._spacing.height
        )

    def render_strips(
        self, width: int, height: int | None, style: Style, options: RenderOptions
    ) -> list[Strip]:
        """Render the Visual into an iterable of strips. Part of the Visual protocol.

        Args:
            width: Width of desired render.
            height: Height of desired render or `None` for any height.
            style: The base style to render on top of.
            options: Additional render options.

        Returns:
            An list of Strips.
        """
        padding = self._spacing
        top, right, bottom, left = self._spacing
        render_width = width - (left + right)
        if render_width <= 0:
            return []

        strips = self._visual.render_strips(
            render_width,
            None if height is None else height - padding.height,
            style,
            options,
        )

        if padding:
            rich_style = style.rich_style
            top_padding = [Strip.blank(width, rich_style)] * top if top else []
            bottom_padding = [Strip.blank(width, rich_style)] * bottom if bottom else []
            strips = [
                *top_padding,
                *[
                    strip.crop_pad(render_width, left, right, rich_style)
                    for strip in strips
                ],
                *bottom_padding,
            ]

        return strips
