"""
This module contains the `Strip` class and related objects.

A `Strip` contains the result of rendering a widget.
See [Line API](/guide/widgets#line-api) for how to use Strips.
"""

from __future__ import annotations

from functools import lru_cache
from typing import Any, Iterable, Iterator, Sequence

import rich.repr
from rich.cells import cell_len, set_cell_size
from rich.color import ColorSystem
from rich.console import Console, ConsoleOptions, RenderResult
from rich.measure import Measurement
from rich.segment import Segment
from rich.style import Style, StyleType

from textual._segment_tools import index_to_cell_position, line_pad
from textual.cache import FIFOCache
from textual.color import Color
from textual.css.types import AlignHorizontal, AlignVertical
from textual.filter import LineFilter

SGR_STYLES = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "21", "51", "52", "53"]


def get_line_length(segments: Iterable[Segment]) -> int:
    """Get the line length (total length of all segments).

    Args:
        segments: Iterable of segments.

    Returns:
        Length of line in cells.
    """
    _cell_len = cell_len
    return sum([_cell_len(text) for text, _, control in segments if not control])


class StripRenderable:
    """A renderable which renders a list of strips into lines."""

    def __init__(self, strips: list[Strip], width: int | None = None) -> None:
        self._strips = strips
        self._width = width

    def __rich_console__(
        self, console: Console, options: ConsoleOptions
    ) -> RenderResult:
        new_line = Segment.line()
        for strip in self._strips:
            yield from strip
            yield new_line

    def __rich_measure__(
        self, console: "Console", options: "ConsoleOptions"
    ) -> Measurement:
        if self._width is None:
            width = max(strip.cell_length for strip in self._strips)
        else:
            width = self._width
        return Measurement(width, width)


@rich.repr.auto
class Strip:
    """Represents a 'strip' (horizontal line) of a Textual Widget.

    A Strip is like an immutable list of Segments. The immutability allows for effective caching.

    Args:
        segments: An iterable of segments.
        cell_length: The cell length if known, or None to calculate on demand.
    """

    __slots__ = [
        "_segments",
        "_cell_length",
        "_divide_cache",
        "_crop_cache",
        "_style_cache",
        "_filter_cache",
        "_render_cache",
        "_line_length_cache",
        "_crop_extend_cache",
        "_offsets_cache",
        "_link_ids",
        "_cell_count",
    ]

    def __init__(
        self, segments: Iterable[Segment], cell_length: int | None = None
    ) -> None:
        self._segments = list(segments)
        self._cell_length = cell_length
        self._divide_cache: FIFOCache[tuple[int, ...], list[Strip]] = FIFOCache(4)
        self._crop_cache: FIFOCache[tuple[int, int], Strip] = FIFOCache(16)
        self._style_cache: FIFOCache[Style, Strip] = FIFOCache(16)
        self._filter_cache: FIFOCache[tuple[LineFilter, Color], Strip] = FIFOCache(4)
        self._line_length_cache: FIFOCache[
            tuple[int, Style | None],
            Strip,
        ] = FIFOCache(4)
        self._crop_extend_cache: FIFOCache[
            tuple[int, int, Style | None],
            Strip,
        ] = FIFOCache(4)
        self._offsets_cache: FIFOCache[tuple[int, int], Strip] = FIFOCache(4)
        self._render_cache: str | None = None
        self._link_ids: set[str] | None = None
        self._cell_count: int | None = None

    def __rich_repr__(self) -> rich.repr.Result:
        try:
            yield self._segments
            yield self.cell_length
        except AttributeError:
            pass

    @property
    def text(self) -> str:
        """Segment text."""
        return "".join(segment.text for segment in self._segments)

    @property
    def link_ids(self) -> set[str]:
        """A set of the link ids in this Strip."""
        if self._link_ids is None:
            self._link_ids = {
                style._link_id for _, style, _ in self._segments if style is not None
            }
        return self._link_ids

    @classmethod
    def blank(cls, cell_length: int, style: StyleType | None = None) -> Strip:
        """Create a blank strip.

        Args:
            cell_length: Desired cell length.
            style: Style of blank.

        Returns:
            New strip.
        """
        segment_style = Style.parse(style) if isinstance(style, str) else style
        return cls([Segment(" " * cell_length, segment_style)], cell_length)

    @classmethod
    def from_lines(
        cls, lines: list[list[Segment]], cell_length: int | None = None
    ) -> list[Strip]:
        """Convert lines (lists of segments) to a list of Strips.

        Args:
            lines: List of lines, where a line is a list of segments.
            cell_length: Cell length of lines (must be same) or None if not known.

        Returns:
            List of strips.
        """
        return [cls(segments, cell_length) for segments in lines]

    @classmethod
    def align(
        cls,
        strips: list[Strip],
        style: Style,
        width: int,
        height: int | None,
        horizontal: AlignHorizontal,
        vertical: AlignVertical,
    ) -> Iterable[Strip]:
        """Align a list of strips on both axis.

        Args:
            strips: A list of strips, such as from a render.
            style: The Rich style of additional space.
            width: Width of container.
            height: Height of container.
            horizontal: Horizontal alignment method.
            vertical: Vertical alignment method.

        Returns:
            An iterable of strips, with additional padding.

        """
        if not strips:
            return
        line_lengths = [strip.cell_length for strip in strips]
        shape_width = max(line_lengths)
        shape_height = len(line_lengths)

        def blank_lines(count: int) -> Iterable[Strip]:
            """Create blank lines.

            Args:
                count: Desired number of blank lines.

            Returns:
                An iterable of blank lines.
            """
            blank = cls([Segment(" " * width, style)], width)
            for _ in range(count):
                yield blank

        top_blank_lines = bottom_blank_lines = 0
        if height is not None:
            vertical_excess_space = max(0, height - shape_height)

            if vertical == "top":
                bottom_blank_lines = vertical_excess_space
            elif vertical == "middle":
                top_blank_lines = vertical_excess_space // 2
                bottom_blank_lines = vertical_excess_space - top_blank_lines
            elif vertical == "bottom":
                top_blank_lines = vertical_excess_space

            if top_blank_lines:
                yield from blank_lines(top_blank_lines)

        if horizontal == "left":
            for strip in strips:
                if strip.cell_length == width:
                    yield strip
                else:
                    yield Strip(
                        line_pad(strip._segments, 0, width - strip.cell_length, style),
                        width,
                    )
        elif horizontal == "center":
            left_space = max(0, width - shape_width) // 2
            for strip in strips:
                if strip.cell_length == width:
                    yield strip
                else:
                    yield Strip(
                        line_pad(
                            strip._segments,
                            left_space,
                            width - strip.cell_length - left_space,
                            style,
                        ),
                        width,
                    )

        elif horizontal == "right":
            for strip in strips:
                if strip.cell_length == width:
                    yield strip
                else:
                    yield cls(
                        line_pad(strip._segments, width - strip.cell_length, 0, style),
                        width,
                    )

        if bottom_blank_lines:
            yield from blank_lines(bottom_blank_lines)

    def index_to_cell_position(self, index: int) -> int:
        """Given a character index, return the cell position of that character.
        This is the sum of the cell lengths of all the characters *before* the character
        at `index`.

        Args:
            index: The index to convert.

        Returns:
            The cell position of the character at `index`.
        """
        return index_to_cell_position(self._segments, index)

    @property
    def cell_length(self) -> int:
        """Get the number of cells required to render this object."""
        # Done on demand and cached, as this is an O(n) operation
        if self._cell_length is None:
            self._cell_length = get_line_length(self._segments)
        return self._cell_length

    @classmethod
    def join(cls, strips: Iterable[Strip | None]) -> Strip:
        """Join a number of strips into one.

        Args:
            strips: An iterable of Strips.

        Returns:
            A new combined strip.
        """
        join_strips = [
            strip for strip in strips if strip is not None and strip.cell_count
        ]
        segments = [segment for strip in join_strips for segment in strip._segments]
        cell_length: int | None = None
        if any([strip._cell_length is None for strip in join_strips]):
            cell_length = None
        else:
            cell_length = sum([strip._cell_length or 0 for strip in join_strips])
        joined_strip = cls(segments, cell_length)
        if all(strip._render_cache is not None for strip in join_strips):
            joined_strip._render_cache = "".join(
                [strip._render_cache for strip in join_strips]
            )
        return joined_strip

    def __add__(self, other: Strip) -> Strip:
        return Strip.join([self, other])

    def __bool__(self) -> bool:
        return not not self._segments  # faster than bool(...)

    def __iter__(self) -> Iterator[Segment]:
        return iter(self._segments)

    def __reversed__(self) -> Iterator[Segment]:
        return reversed(self._segments)

    def __len__(self) -> int:
        return len(self._segments)

    def __eq__(self, strip: object) -> bool:
        return isinstance(strip, Strip) and (self._segments == strip._segments)

    def __getitem__(self, index: int | slice) -> Strip:
        if isinstance(index, int):
            index = slice(index, index + 1)
        return self.crop(
            index.start, self.cell_count if index.stop is None else index.stop
        )

    @property
    def cell_count(self) -> int:
        """Number of cells in the strip"""
        if self._cell_count is None:
            self._cell_count = sum(len(segment.text) for segment in self._segments)
        return self._cell_count

    def extend_cell_length(self, cell_length: int, style: Style | None = None) -> Strip:
        """Extend the cell length if it is less than the given value.

        Args:
            cell_length: Required minimum cell length.
            style: Style for padding if the cell length is extended.

        Returns:
            A new Strip.
        """
        if self.cell_length < cell_length:
            missing_space = cell_length - self.cell_length
            segments = self._segments + [Segment(" " * missing_space, style)]
            return Strip(segments, cell_length)
        else:
            return self

    def adjust_cell_length(self, cell_length: int, style: Style | None = None) -> Strip:
        """Adjust the cell length, possibly truncating or extending.

        Args:
            cell_length: New desired cell length.
            style: Style when extending, or `None`.

        Returns:
            A new strip with the supplied cell length.
        """

        if self.cell_length == cell_length:
            return self

        cache_key = (cell_length, style)
        cached_strip = self._line_length_cache.get(cache_key)
        if cached_strip is not None:
            return cached_strip

        new_line: list[Segment]
        line = self._segments
        current_cell_length = self.cell_length

        _Segment = Segment

        if current_cell_length < cell_length:
            # Cell length is larger, so pad with spaces.
            new_line = line + [
                _Segment(" " * (cell_length - current_cell_length), style)
            ]
            strip = Strip(new_line, cell_length)

        elif current_cell_length > cell_length:
            # Cell length is shorter so we need to truncate.
            new_line = []
            append = new_line.append
            line_length = 0
            for segment in line:
                segment_length = segment.cell_length
                if line_length + segment_length < cell_length:
                    append(segment)
                    line_length += segment_length
                else:
                    text, segment_style, _ = segment
                    text = set_cell_size(text, cell_length - line_length)
                    append(_Segment(text, segment_style))
                    break
            strip = Strip(new_line, cell_length)
        else:
            # Strip is already the required cell length, so return self.
            strip = self

        self._line_length_cache[cache_key] = strip
        return strip

    def simplify(self) -> Strip:
        """Simplify the segments (join segments with same style).

        Returns:
            New strip.
        """
        line = Strip(
            Segment.simplify(self._segments),
            self._cell_length,
        )
        return line

    def discard_meta(self) -> Strip:
        """Remove all meta from segments.

        Returns:
            New strip.
        """

        def remove_meta_from_segment(segment: Segment) -> Segment:
            """Build a Segment with no meta.

            Args:
                segment: Segment.

            Returns:
                Segment, sans meta.
            """
            text, style, control = segment
            if style is None:
                return segment
            style = style.copy()
            style._meta = None
            return Segment(text, style, control)

        return Strip(
            [remove_meta_from_segment(segment) for segment in self._segments],
            self._cell_length,
        )

    def apply_filter(self, filter: LineFilter, background: Color) -> Strip:
        """Apply a filter to all segments in the strip.

        Args:
            filter: A line filter object.

        Returns:
            A new Strip.
        """
        cached_strip = self._filter_cache.get((filter, background))
        if cached_strip is None:
            cached_strip = Strip(
                filter.apply(self._segments, background), self._cell_length
            )
            self._filter_cache[(filter, background)] = cached_strip
        return cached_strip

    def style_links(self, link_id: str, link_style: Style) -> Strip:
        """Apply a style to Segments with the given link_id.

        Args:
            link_id: A link id.
            link_style: Style to apply.

        Returns:
            New strip (or same Strip if no changes).
        """

        _Segment = Segment
        if link_id not in self.link_ids:
            return self
        segments = [
            _Segment(
                text,
                (
                    (style + link_style if style is not None else None)
                    if (style and not style._null and style._link_id == link_id)
                    else style
                ),
                control,
            )
            for text, style, control in self._segments
        ]
        return Strip(segments, self._cell_length)

    def crop_extend(self, start: int, end: int, style: Style | None) -> Strip:
        """Crop between two points, extending the length if required.

        Args:
            start: Start offset of crop.
            end: End offset of crop.
            style: Style of additional padding.

        Returns:
            New cropped Strip.
        """
        cache_key = (start, end, style)
        cached_result = self._crop_extend_cache.get(cache_key)
        if cached_result is not None:
            return cached_result
        strip = self.extend_cell_length(end, style).crop(start, end)
        self._crop_extend_cache[cache_key] = strip
        return strip

    def crop(self, start: int, end: int | None = None) -> Strip:
        """Crop a strip between two cell positions.

        Args:
            start: The start cell position (inclusive).
            end: The end cell position (exclusive).

        Returns:
            A new Strip.
        """

        start = max(0, start)
        end = self.cell_length if end is None else min(self.cell_length, end)
        if start == 0 and end == self.cell_length:
            return self
        if end <= start:
            return Strip([], 0)
        cache_key = (start, end)
        cached = self._crop_cache.get(cache_key)
        if cached is not None:
            return cached
        _cell_len = cell_len
        pos = 0
        output_segments: list[Segment] = []
        add_segment = output_segments.append
        iter_segments = iter(self._segments)
        segment: Segment | None = None
        if start >= self.cell_length:
            strip = Strip([], 0)
        else:
            for segment in iter_segments:
                end_pos = pos + _cell_len(segment.text)
                if end_pos > start:
                    segment = segment.split_cells(start - pos)[1]
                    break
                pos = end_pos

            if end >= self.cell_length:
                # The end crop is the end of the segments, so we can collect all remaining segments
                if segment:
                    add_segment(segment)
                output_segments.extend(iter_segments)
                strip = Strip(output_segments, self.cell_length - start)
            else:
                pos = start
                while segment is not None:
                    end_pos = pos + _cell_len(segment.text)
                    if end_pos < end:
                        add_segment(segment)
                    else:
                        add_segment(segment.split_cells(end - pos)[0])
                        break
                    pos = end_pos
                    segment = next(iter_segments, None)
                strip = Strip(output_segments, end - start)
        self._crop_cache[cache_key] = strip
        return strip

    def divide(self, cuts: Iterable[int]) -> Sequence[Strip]:
        """Divide the strip into multiple smaller strips by cutting at given (cell) indices.

        Args:
            cuts: An iterable of cell positions as ints.

        Returns:
            A new list of strips.
        """

        pos = 0
        cell_length = self.cell_length
        cuts = [cut for cut in cuts if cut <= cell_length]
        cache_key = tuple(cuts)
        if (cached := self._divide_cache.get(cache_key)) is not None:
            return cached

        strips: list[Strip]
        if cuts == [cell_length]:
            strips = [self]
        else:
            strips = []
            add_strip = strips.append
            for segments, cut in zip(Segment.divide(self._segments, cuts), cuts):
                add_strip(Strip(segments, cut - pos))
                pos = cut

        self._divide_cache[cache_key] = strips
        return strips

    def apply_style(self, style: Style) -> Strip:
        """Apply a style to the Strip.

        Args:
            style: A Rich style.

        Returns:
            A new strip.
        """
        cached = self._style_cache.get(style)
        if cached is not None:
            return cached
        styled_strip = Strip(
            Segment.apply_style(self._segments, style), self.cell_length
        )
        self._style_cache[style] = styled_strip
        return styled_strip

    def apply_meta(self, meta: dict[str, Any]) -> Strip:
        """Apply meta to all segments.

        Args:
            meta: A dict of meta information.

        Returns:
            A new strip.

        """
        meta_style = Style.from_meta(meta)
        return self.apply_style(meta_style)

    def _apply_link_style(self, link_style: Style) -> Strip:
        segments = self._segments
        _Segment = Segment
        segments = [
            (
                _Segment(
                    text,
                    (
                        style
                        if style._meta is None
                        else (style + link_style if "@click" in style.meta else style)
                    ),
                    control,
                )
                if style
                else _Segment(text)
            )
            for text, style, control in segments
        ]
        return Strip(segments, self._cell_length)

    @classmethod
    @lru_cache(maxsize=16384)
    def render_ansi(cls, style: Style, color_system: ColorSystem) -> str:
        """Render ANSI codes for a give style.

        Args:
            style: A Rich style.
            color_system: Color system enumeration.

        Returns:
            A string of ANSI escape sequences to render the style.
        """
        sgr: list[str]
        if attributes := style._attributes & style._set_attributes:
            _style_map = SGR_STYLES
            sgr = [
                _style_map[bit_offset]
                for bit_offset in range(attributes.bit_length())
                if attributes & (1 << bit_offset)
            ]
        else:
            sgr = []
        if (color := style._color) is not None:
            sgr.extend(color.downgrade(color_system).get_ansi_codes())
        if (bgcolor := style._bgcolor) is not None:
            sgr.extend(bgcolor.downgrade(color_system).get_ansi_codes(False))
        ansi = style._ansi = ";".join(sgr)
        return ansi

    @classmethod
    def render_style(cls, style: Style, text: str, color_system: ColorSystem) -> str:
        """Render a Rich style and text.

        Args:
            style: Style to render.
            text: Content string.
            color_system: Color system enumeration.

        Returns:
            Text with ANSI escape sequences.
        """
        if (ansi := style._ansi) is None:
            ansi = cls.render_ansi(style, color_system)
        output = f"\x1b[{ansi}m{text}\x1b[0m" if ansi else text
        if style._link:
            output = (
                f"\x1b]8;id={style._link_id};{style._link}\x1b\\{output}\x1b]8;;\x1b\\"
            )
        return output

    def render(self, console: Console) -> str:
        """Render the strip into terminal sequences.

        Args:
            console: Console instance.

        Returns:
            Rendered sequences.
        """
        if self._render_cache is None:
            color_system = console._color_system or ColorSystem.TRUECOLOR
            render = self.render_style
            self._render_cache = "".join(
                [
                    (
                        text
                        if style is None
                        else render(style, text, color_system=color_system)
                    )
                    for text, style, _ in self._segments
                ]
            )

        return self._render_cache

    def crop_pad(self, cell_length: int, left: int, right: int, style: Style) -> Strip:
        """Crop the strip to `cell_length`, and add optional padding.

        Args:
            cell_length: Cell length of strip prior to padding.
            left: Additional padding on the left.
            right: Additional padding on the right.
            style: Style of any padding.

        Returns:
            Cropped and padded strip.
        """
        if cell_length != self.cell_length:
            strip = self.adjust_cell_length(cell_length, style)
        else:
            strip = self
        if not (left or right):
            return strip
        segments = strip._segments.copy()
        if left:
            segments.insert(0, Segment(" " * left, style))
        if right:
            segments.append(Segment(" " * right, style))
        return Strip(segments, cell_length + left + right)

    def text_align(self, width: int, align: AlignHorizontal) -> Strip:
        if align == "left":
            if self.cell_length == width:
                return self
            else:
                return Strip(
                    line_pad(self._segments, 0, width - self.cell_length, Style.null()),
                    width,
                )
        elif align == "center":
            left_space = max(0, width - self.cell_length) // 2

            if self.cell_length == width:
                return self
            else:
                return Strip(
                    line_pad(
                        self._segments,
                        left_space,
                        width - self.cell_length - left_space,
                        Style.null(),
                    ),
                    width,
                )

        elif align == "right":
            if self.cell_length == width:
                return self
            else:
                return Strip(
                    line_pad(self._segments, width - self.cell_length, 0, Style.null()),
                    width,
                )

    def apply_offsets(self, x: int, y: int) -> Strip:
        """Apply offsets used in text selection.

        Args:
            x: Offset on X axis (column).
            y: Offset on Y axis (row).

        Returns:
            New strip.
        """
        cache_key = (x, y)
        if (cached_strip := self._offsets_cache.get(cache_key)) is not None:
            return cached_strip
        segments = self._segments
        strip_segments: list[Segment] = []
        for segment in segments:
            text, style, _ = segment
            offset_style = Style.from_meta({"offset": (x, y)})
            strip_segments.append(
                Segment(text, style + offset_style if style else offset_style)
            )
            x += len(segment.text)
        strip = Strip(strip_segments, self._cell_length)
        strip._render_cache = self._render_cache
        self._offsets_cache[cache_key] = strip
        return strip
