from __future__ import annotations

from dataclasses import dataclass
from typing import ClassVar

import rich.repr
from rich.console import RenderableType
from rich.style import Style
from rich.text import Text, TextType

from textual import events
from textual.app import ComposeResult, RenderResult
from textual.await_complete import AwaitComplete
from textual.binding import Binding, BindingType
from textual.containers import Container, Horizontal, Vertical
from textual.css.query import NoMatches
from textual.events import Mount
from textual.geometry import Offset
from textual.message import Message
from textual.reactive import reactive
from textual.renderables.bar import Bar
from textual.widget import Widget
from textual.widgets import Static


class Underline(Widget):
    """The animated underline beneath tabs."""

    DEFAULT_CSS = """
    Underline {
        width: 1fr;
        height: 1;
    }
    Underline > .underline--bar {
        background: $foreground 10%;
        color: $accent;
    }
    """

    COMPONENT_CLASSES = {"underline--bar"}
    """
    | Class | Description |
    | :- | :- |
    | `underline--bar` | Style of the bar (may be used to change the color). |
    """

    highlight_start = reactive(0)
    """First cell in highlight."""
    highlight_end = reactive(0)
    """Last cell (inclusive) in highlight."""
    show_highlight: reactive[bool] = reactive(True)
    """Flag to indicate if a highlight should be shown at all."""

    class Clicked(Message):
        """Inform ancestors the underline was clicked."""

        offset: Offset
        """The offset of the click, relative to the origin of the bar."""

        def __init__(self, offset: Offset) -> None:
            self.offset = offset
            super().__init__()

    @property
    def _highlight_range(self) -> tuple[int, int]:
        """Highlighted range for underline bar."""
        return (
            (self.highlight_start, self.highlight_end)
            if self.show_highlight
            else (0, 0)
        )

    def render(self) -> RenderResult:
        """Render the bar."""
        bar_style = self.get_component_rich_style("underline--bar")
        return Bar(
            highlight_range=self._highlight_range,
            highlight_style=Style.from_color(bar_style.color),
            background_style=Style.from_color(bar_style.bgcolor),
        )

    def _on_click(self, event: events.Click):
        """Catch clicks, so that the underline can activate the tabs."""
        event.stop()
        self.post_message(self.Clicked(event.screen_offset))


class Tab(Static):
    """A Widget to manage a single tab within a Tabs widget."""

    DEFAULT_CSS = """
    Tab {
        width: auto;
        height: 2;
        padding: 1 1 0 2;
        text-align: center;
        color: $text-disabled;
    }
    Tab.-active {
        text-style: bold;
        color: $text;
    }
    Tab:hover {
        text-style: bold;
    }
    Tab.-active:hover {
        color: $text;
    }
    Tab:disabled {
        color: $text-disabled;
        text-opacity: 50%;
    }
    Tab.-hidden {
        display: none;
    }
    """

    @dataclass
    class TabMessage(Message):
        """Tab-related messages.

        These are mostly intended for internal use when interacting with `Tabs`.
        """

        tab: Tab
        """The tab that is the object of this message."""

        @property
        def control(self) -> Tab:
            """The tab that is the object of this message.

            This is an alias for the attribute `tab` and is used by the
            [`on`][textual.on] decorator.
            """
            return self.tab

    class Clicked(TabMessage):
        """A tab was clicked."""

    class Disabled(TabMessage):
        """A tab was disabled."""

    class Enabled(TabMessage):
        """A tab was enabled."""

    class Relabelled(TabMessage):
        """A tab was relabelled."""

    def __init__(
        self,
        label: TextType,
        *,
        id: str | None = None,
        classes: str | None = None,
        disabled: bool = False,
    ) -> None:
        """Initialise a Tab.

        Args:
            label: The label to use in the tab.
            id: Optional ID for the widget.
            classes: Space separated list of class names.
            disabled: Whether the tab is disabled or not.
        """
        super().__init__(id=id, classes=classes, disabled=disabled)
        self._label: Text
        # Setter takes Text or str
        self.label = label  # type: ignore[assignment]

    @property
    def label(self) -> Text:
        """The label for the tab."""
        return self._label

    @label.setter
    def label(self, label: TextType) -> None:
        self._label = Text.from_markup(label) if isinstance(label, str) else label
        self.update(self._label)

    def update(self, renderable: RenderableType = "") -> None:
        self.post_message(self.Relabelled(self))
        return super().update(renderable)

    @property
    def label_text(self) -> str:
        """Undecorated text of the label."""
        return self.label.plain

    def _on_click(self):
        """Inform the message that the tab was clicked."""
        self.post_message(self.Clicked(self))

    def _watch_disabled(self, disabled: bool) -> None:
        """Notify the parent `Tabs` that a tab was enabled/disabled."""
        self.post_message(self.Disabled(self) if disabled else self.Enabled(self))


class Tabs(Widget, can_focus=True):
    """A row of tabs."""

    DEFAULT_CSS = """
    Tabs {
        width: 100%;
        height: 3;
    }
    Tabs > #tabs-scroll {
        overflow: hidden;
    }
    Tabs #tabs-list {
       width: auto;
       min-height: 2;
    }
    Tabs #tabs-list-bar, Tabs #tabs-list {
        width: auto;
        height: auto;
        min-width: 100%;
        overflow: hidden hidden;
    }
    Tabs:focus .underline--bar {
        background: $foreground 20%;
    }
    """

    BINDINGS: ClassVar[list[BindingType]] = [
        Binding("left", "previous_tab", "Previous tab", show=False),
        Binding("right", "next_tab", "Next tab", show=False),
    ]
    """
    | Key(s) | Description |
    | :- | :- |
    | left | Move to the previous tab. |
    | right | Move to the next tab. |
    """

    class TabError(Exception):
        """Exception raised when there is an error relating to tabs."""

    class TabMessage(Message):
        """Parent class for all messages that have to do with a specific tab."""

        ALLOW_SELECTOR_MATCH = {"tab"}
        """Additional message attributes that can be used with the [`on` decorator][textual.on]."""

        def __init__(self, tabs: Tabs, tab: Tab) -> None:
            """Initialize event.

            Args:
                tabs: The Tabs widget.
                tab: The tab that is the object of this message.
            """
            self.tabs: Tabs = tabs
            """The tabs widget containing the tab."""
            self.tab: Tab = tab
            """The tab that is the object of this message."""
            super().__init__()

        @property
        def control(self) -> Tabs:
            """The tabs widget containing the tab that is the object of this message.

            This is an alias for the attribute `tabs` and is used by the
            [`on`][textual.on] decorator.
            """
            return self.tabs

        def __rich_repr__(self) -> rich.repr.Result:
            yield self.tabs
            yield self.tab

    class TabActivated(TabMessage):
        """Sent when a new tab is activated."""

    class TabDisabled(TabMessage):
        """Sent when a tab is disabled."""

    class TabEnabled(TabMessage):
        """Sent when a tab is enabled."""

    class TabHidden(TabMessage):
        """Sent when a tab is hidden."""

    class TabShown(TabMessage):
        """Sent when a tab is shown."""

    class Cleared(Message):
        """Sent when there are no active tabs.

        This can occur when Tabs are cleared, if all tabs are hidden, or if the
        currently active tab is unset.
        """

        def __init__(self, tabs: Tabs) -> None:
            """Initialize the event.

            Args:
                tabs: The tabs widget.
            """
            self.tabs: Tabs = tabs
            """The tabs widget which was cleared."""
            super().__init__()

        @property
        def control(self) -> Tabs:
            """The tabs widget which was cleared.

            This is an alias for [`Cleared.tabs`][textual.widgets.Tabs.Cleared] which
            is used by the [`on`][textual.on] decorator.
            """
            return self.tabs

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

    active: reactive[str] = reactive("", init=False)
    """The ID of the active tab, or empty string if none are active."""

    def __init__(
        self,
        *tabs: Tab | TextType,
        active: str | None = None,
        name: str | None = None,
        id: str | None = None,
        classes: str | None = None,
        disabled: bool = False,
    ):
        """Construct a Tabs widget.

        Args:
            *tabs: Positional argument should be explicit Tab objects, or a str or Text.
            active: ID of the tab which should be active on start.
            name: Optional name for the tabs widget.
            id: Optional ID for the widget.
            classes: Optional initial classes for the widget.
            disabled: Whether the widget is disabled or not.
        """
        self._tabs_counter = 0

        add_tabs = [
            (
                Tab(tab, id=f"tab-{self._new_tab_id}")
                if isinstance(tab, (str, Text))
                else self._auto_tab_id(tab)
            )
            for tab in tabs
        ]
        super().__init__(
            name=name,
            id=id,
            classes=classes,
            disabled=disabled,
        )
        self._tabs = add_tabs
        self._first_active = active

    def _auto_tab_id(self, tab: Tab) -> Tab:
        """Set an automatic ID if not supplied."""
        if tab.id is None:
            tab.id = f"tab-{self._new_tab_id}"
        return tab

    @property
    def _new_tab_id(self) -> int:
        """Get the next tab id in a sequence."""
        self._tabs_counter += 1
        return self._tabs_counter

    @property
    def tab_count(self) -> int:
        """Total number of tabs."""
        return len(self.query("#tabs-list > Tab"))

    @property
    def _potentially_active_tabs(self) -> list[Tab]:
        """List of all tabs that could be active.

        This list is comprised of all tabs that are shown and enabled,
        plus the active tab in case it is disabled.
        """
        return [
            tab
            for tab in self.query("#tabs-list > Tab").results(Tab)
            if ((not tab.disabled or tab is self.active_tab) and tab.display)
        ]

    @property
    def _next_active(self) -> Tab | None:
        """Next tab to make active if the active tab is removed."""
        tabs = self._potentially_active_tabs
        if self.active_tab is None:
            return None
        try:
            active_index = tabs.index(self.active_tab)
        except ValueError:
            return None
        del tabs[active_index]
        try:
            return tabs[active_index]
        except IndexError:
            try:
                return tabs[active_index - 1]
            except IndexError:
                pass
        return None

    def add_tab(
        self,
        tab: Tab | str | Text,
        *,
        before: Tab | str | None = None,
        after: Tab | str | None = None,
    ) -> AwaitComplete:
        """Add a new tab to the end of the tab list.

        Args:
            tab: A new tab object, or a label (str or Text).
            before: Optional tab or tab ID to add the tab before.
            after: Optional tab or tab ID to add the tab after.

        Returns:
            An optionally awaitable object that waits for the tab to be mounted and
                internal state to be fully updated to reflect the new tab.

        Raises:
            Tabs.TabError: If there is a problem with the addition request.

        Note:
            Only one of `before` or `after` can be provided. If both are
            provided a `Tabs.TabError` will be raised.
        """

        if before and after:
            raise self.TabError("Unable to add a tab both before and after a tab")

        if isinstance(before, str):
            try:
                before = self.query_one(f"#tabs-list > #{before}", Tab)
            except NoMatches:
                raise self.TabError(
                    f"There is no tab with ID '{before}' to mount before"
                )
        elif isinstance(before, Tab) and self not in before.ancestors:
            raise self.TabError(
                "Request to add a tab before a tab that isn't part of this tab collection"
            )

        if isinstance(after, str):
            try:
                after = self.query_one(f"#tabs-list > #{after}", Tab)
            except NoMatches:
                raise self.TabError(f"There is no tab with ID '{after}' to mount after")
        elif isinstance(after, Tab) and self not in after.ancestors:
            raise self.TabError(
                "Request to add a tab after a tab that isn't part of this tab collection"
            )

        from_empty = self.tab_count == 0
        tab_widget = (
            Tab(tab, id=f"tab-{self._new_tab_id}")
            if isinstance(tab, (str, Text))
            else self._auto_tab_id(tab)
        )

        mount_await = self.query_one("#tabs-list").mount(
            tab_widget, before=before, after=after
        )

        if from_empty:
            tab_widget.add_class("-active")
            activated_message = self.TabActivated(self, tab_widget)

            async def refresh_active() -> None:
                """Wait for things to be mounted before highlighting."""
                await mount_await
                self.active = tab_widget.id or ""
                self._highlight_active(animate=False)
                self.post_message(activated_message)

            return AwaitComplete(refresh_active())
        elif before or after:

            async def refresh_active() -> None:
                await mount_await
                self._highlight_active(animate=False)

            return AwaitComplete(refresh_active())

        return AwaitComplete(mount_await())

    def clear(self) -> AwaitComplete:
        """Clear all the tabs.

        Returns:
            An awaitable object that waits for the tabs to be removed.
        """
        underline = self.query_one(Underline)
        underline.highlight_start = 0
        underline.highlight_end = 0
        self.call_after_refresh(self.post_message, self.Cleared(self))
        self.active = ""
        return AwaitComplete(self.query("#tabs-list > Tab").remove())

    def remove_tab(self, tab_or_id: Tab | str | None) -> AwaitComplete:
        """Remove a tab.

        Args:
            tab_or_id: The Tab to remove or its id.

        Returns:
            An optionally awaitable object that waits for the tab to be removed.
        """
        if not tab_or_id:
            return AwaitComplete()

        if isinstance(tab_or_id, Tab):
            remove_tab = tab_or_id
        else:
            try:
                remove_tab = self.query_one(f"#tabs-list > #{tab_or_id}", Tab)
            except NoMatches:
                return AwaitComplete()

        if remove_tab.has_class("-active"):
            next_tab = self._next_active
        else:
            next_tab = None

        async def do_remove() -> None:
            """Perform the remove after refresh so the underline bar gets new positions."""
            await remove_tab.remove()
            if next_tab is not None:
                self.active = next_tab.id or ""
            if not self.query("#tabs-list > Tab"):
                self.active = ""

        return AwaitComplete(do_remove())

    def validate_active(self, active: str) -> str:
        """Check id assigned to active attribute is a valid tab."""
        if active and not self.query(f"#tabs-list > #{active}"):
            raise ValueError(f"No Tab with id {active!r}")
        return active

    @property
    def active_tab(self) -> Tab | None:
        """The currently active tab, or None if there are no active tabs."""
        try:
            return self.query_one("#tabs-list Tab.-active", Tab)
        except NoMatches:
            return None

    def _on_mount(self, _: Mount) -> None:
        """Make the first tab active."""
        if self._first_active is not None:
            self.active = self._first_active
        if not self.active:
            try:
                tab = self.query("#tabs-list > Tab").first(Tab)
            except NoMatches:
                # Tabs are empty!
                return
            self.active = tab.id or ""

    def compose(self) -> ComposeResult:
        with Container(id="tabs-scroll"):
            with Vertical(id="tabs-list-bar"):
                with Horizontal(id="tabs-list"):
                    yield from self._tabs
                yield Underline()

    def watch_active(self, previously_active: str, active: str) -> None:
        """Handle a change to the active tab."""
        self.query("#tabs-list > Tab.-active").remove_class("-active")
        if active:
            try:
                active_tab = self.query_one(f"#tabs-list > #{active}", Tab)
            except NoMatches:
                return
            active_tab.add_class("-active")

            self._highlight_active(animate=previously_active != "")

            self._scroll_active_tab()
            self.post_message(self.TabActivated(self, active_tab))
        else:
            underline = self.query_one(Underline)
            underline.highlight_start = 0
            underline.highlight_end = 0
            self.post_message(self.Cleared(self))

    def _highlight_active(
        self,
        animate: bool = True,
    ) -> None:
        """Move the underline bar to under the active tab.

        Args:
            animate: Should the bar animate?
        """
        underline = self.query_one(Underline)
        try:
            _active_tab = self.query_one("#tabs-list > Tab.-active")
        except NoMatches:
            underline.show_highlight = False
            underline.highlight_start = 0
            underline.highlight_end = 0
        else:
            underline.show_highlight = True

            def move_underline(animate: bool) -> None:
                """Move the tab underline.

                Args:
                    animate: animate the underline to its new position.
                """
                try:
                    active_tab = self.query_one("#tabs-list > Tab.-active")
                except NoMatches:
                    pass
                else:
                    tab_region = active_tab.virtual_region.shrink(
                        active_tab.styles.gutter
                    )
                    start, end = tab_region.column_span
                    if animate:
                        underline.animate(
                            "highlight_start",
                            start,
                            duration=0.3,
                            level="basic",
                        )
                        underline.animate(
                            "highlight_end",
                            end,
                            duration=0.3,
                            level="basic",
                        )
                    else:
                        underline.highlight_start = start
                        underline.highlight_end = end

            if animate and self.app.animation_level != "none":
                self.set_timer(
                    0.02,
                    lambda: self.call_after_refresh(move_underline, True),
                )
            else:
                self.call_after_refresh(move_underline, False)

    async def _on_tab_clicked(self, event: Tab.Clicked) -> None:
        """Activate a tab that was clicked."""
        self.focus()
        event.stop()
        self._activate_tab(event.tab)

    def _activate_tab(self, tab: Tab) -> None:
        """Activate a tab.

        Args:
            tab: The Tab that was clicked.
        """
        self.query("#tabs-list Tab.-active").remove_class("-active")
        tab.add_class("-active")
        self.active = tab.id or ""

    def _on_underline_clicked(self, event: Underline.Clicked) -> None:
        """The underline was clicked.

        Activate the tab above to make a larger clickable area.

        Args:
            event: The Underline.Clicked event.
        """
        event.stop()
        offset = event.offset + (0, -1)
        self.focus()
        for tab in self.query(Tab):
            if offset in tab.region and not tab.disabled:
                self._activate_tab(tab)
                break

    def _scroll_active_tab(self) -> None:
        """Scroll the active tab into view."""
        if self.active_tab:
            try:
                self.query_one("#tabs-scroll").scroll_to_center(
                    self.active_tab, force=True
                )
            except NoMatches:
                pass

    def _on_resize(self):
        """Make the active tab visible on resize."""
        self._highlight_active(animate=False)
        self._scroll_active_tab()

    def action_next_tab(self) -> None:
        """Make the next tab active."""
        self._move_tab(+1)

    def action_previous_tab(self) -> None:
        """Make the previous tab active."""
        self._move_tab(-1)

    def _move_tab(self, direction: int) -> None:
        """Activate the next enabled tab in the given direction.

        Tab selection wraps around. If no tab is currently active, the "next"
        tab is set to be the first and the "previous" tab is the last one.

        Args:
            direction: +1 for the next tab, -1 for the previous.
        """
        active_tab = self.active_tab
        tabs = self._potentially_active_tabs
        if not tabs:
            return
        if not active_tab:
            self.active = tabs[0 if direction == 1 else -1].id or ""
            return
        tab_count = len(tabs)
        new_tab_index = (tabs.index(active_tab) + direction) % tab_count
        self.active = tabs[new_tab_index].id or ""

    def _on_tab_disabled(self, event: Tab.Disabled) -> None:
        """Re-post the disabled message."""
        event.stop()
        self.post_message(self.TabDisabled(self, event.tab))

    def _on_tab_enabled(self, event: Tab.Enabled) -> None:
        """Re-post the enabled message."""
        event.stop()
        self.post_message(self.TabEnabled(self, event.tab))

    def _on_tab_relabelled(self, event: Tab.Relabelled) -> None:
        """Redraw the highlight when tab is relabelled."""
        event.stop()
        self._highlight_active()

    def disable(self, tab_id: str) -> Tab:
        """Disable the indicated tab.

        Args:
            tab_id: The ID of the [`Tab`][textual.widgets.Tab] to disable.

        Returns:
            The [`Tab`][textual.widgets.Tab] that was targeted.

        Raises:
            TabError: If there are any issues with the request.
        """

        try:
            tab_to_disable = self.query_one(f"#tabs-list > Tab#{tab_id}", Tab)
        except NoMatches:
            raise self.TabError(
                f"There is no tab with ID {tab_id!r} to disable."
            ) from None

        tab_to_disable.disabled = True
        return tab_to_disable

    def enable(self, tab_id: str) -> Tab:
        """Enable the indicated tab.

        Args:
            tab_id: The ID of the [`Tab`][textual.widgets.Tab] to enable.

        Returns:
            The [`Tab`][textual.widgets.Tab] that was targeted.

        Raises:
            TabError: If there are any issues with the request.
        """

        try:
            tab_to_enable = self.query_one(f"#tabs-list > Tab#{tab_id}", Tab)
        except NoMatches:
            raise self.TabError(
                f"There is no tab with ID {tab_id!r} to enable."
            ) from None

        tab_to_enable.disabled = False
        return tab_to_enable

    def hide(self, tab_id: str) -> Tab:
        """Hide the indicated tab.

        Args:
            tab_id: The ID of the [`Tab`][textual.widgets.Tab] to hide.

        Returns:
            The [`Tab`][textual.widgets.Tab] that was targeted.

        Raises:
            TabError: If there are any issues with the request.
        """

        try:
            tab_to_hide = self.query_one(f"#tabs-list > Tab#{tab_id}", Tab)
        except NoMatches:
            raise self.TabError(f"There is no tab with ID {tab_id!r} to hide.")

        if tab_to_hide.has_class("-active"):
            next_tab = self._next_active
            self.active = next_tab.id or "" if next_tab else ""
        tab_to_hide.add_class("-hidden")
        self.post_message(self.TabHidden(self, tab_to_hide).set_sender(self))
        self.call_after_refresh(self._highlight_active)
        return tab_to_hide

    def show(self, tab_id: str) -> Tab:
        """Show the indicated tab.

        Args:
            tab_id: The ID of the [`Tab`][textual.widgets.Tab] to show.

        Returns:
            The [`Tab`][textual.widgets.Tab] that was targeted.

        Raises:
            TabError: If there are any issues with the request.
        """

        try:
            tab_to_show = self.query_one(f"#tabs-list > Tab#{tab_id}", Tab)
        except NoMatches:
            raise self.TabError(f"There is no tab with ID {tab_id!r} to show.")

        tab_to_show.remove_class("-hidden")
        self.post_message(self.TabShown(self, tab_to_show).set_sender(self))
        if not self.active:
            self._activate_tab(tab_to_show)
        self.call_after_refresh(self._highlight_active)
        return tab_to_show
