"""Textual Cookbook Recipe Runner
================================

# ~ Type Checking (Pyright and MyPy) - Strict Mode
# ~ Linting - Ruff
# ~ Formatting - Black - max 110 characters / line
"""

# python standard lib
from __future__ import annotations
from typing import TypedDict, Any
import subprocess
import sys
import re
from pathlib import Path
from importlib import resources
# from importlib.abc import Traversable
# import importlib
from enum import Enum

# Textual imports
from textual import on, work, events
from textual.app import App, ComposeResult
from textual.widget import Widget
from textual.widgets import Static, DataTable, Button, Markdown, TextArea
from textual.widgets.data_table import ColumnKey, Column, RowDoesNotExist
from textual.screen import Screen, ModalScreen
from textual.message import Message
from textual.containers import Horizontal, Vertical
from textual.binding import Binding
from textual.geometry import clamp
from rich.text import Text

# Textual library imports
from textual_pyfiglet import FigletWidget

class RecipeData(TypedDict):
    name: str
    category: str
    author: str
    description: str
    text_blob: str


class SortableText(Text):

    def __lt__(self, other: object) -> bool:
        if isinstance(other, str):
            return self.plain < other
        if isinstance(other, Text):
            return self.plain < other.plain
        return NotImplemented


class DescriptionScreen(ModalScreen[bool]):

    BINDINGS = [
        Binding("escape", "close_screen", description="Close the help window."),
        Binding("left", "app.focus_previous", description="Focus Previous"),
        Binding("right", "app.focus_next", description="Focus Next"),
    ]

    def __init__(self, description: str) -> None:
        super().__init__()
        self.description = description

    def compose(self) -> ComposeResult:

        with Vertical(classes="description_container"):
            yield Markdown(self.description)
            with Horizontal(classes="button_container modal"):
                yield Button("Run", id="run_button_modal", compact=True)
                yield Button("Back", id="back_button", compact=True)

    @on(Button.Pressed, "#run_button_modal")
    def run_button_pressed(self) -> None:
        self.dismiss(True)

    @on(Button.Pressed, "#back_button")
    def back_button_pressed(self) -> None:
        self.dismiss(False)

    def on_click(self, event: events.Click) -> None:
        
        if isinstance(event.control, DescriptionScreen):
            self.dismiss(False)

    def action_close_screen(self) -> None:
        self.dismiss()


class ErrorScreen(ModalScreen[bool]):

    BINDINGS = [
        Binding("escape", "close_screen", description="Close the help window."),
    ]

    def __init__(self, error: subprocess.CalledProcessError) -> None:
        super().__init__()
        self.error = error

    def compose(self) -> ComposeResult:

        with Vertical(classes="description_container"):
            yield Static(
                "[$accent]Recipe failed to run[/$accent] \n\n"
                "Close the cookbook to view the error output.\n",
                markup=True
            )
            with Horizontal(classes="button_container modal"):
                yield Button("Close", id="close_button", compact=True)

    @on(Button.Pressed, "#close_button")
    def close_button_pressed(self) -> None:
        self.dismiss(False)

    def on_click(self, event: events.Click) -> None:
        
        if isinstance(event.control, ErrorScreen):
            self.dismiss(False)

    def action_close_screen(self) -> None:
        self.dismiss()


class ResizeBar(Static):

    DEFAULT_CSS = """
    ResizeBar {
        width: 1;
        height: 1fr;
        border-left: outer $panel;
        &:hover { border-left: outer $primary-darken-2; }
        &.pressed {   border-left: outer $primary-lighten-1; }
    }
    """

    def __init__(self, parent: Widget, **kwargs: Any) -> None:
        super().__init__(**kwargs)
        self.connected_container = parent
        self.min_width = 25    # adjust as needed
        self.max_width = 80
        self.tooltip = "<-- Drag to resize -->"

    def on_mouse_move(self, event: events.MouseMove) -> None:

        # App.mouse_captured refers to the widget that is currently capturing mouse events.
        if self.app.mouse_captured == self:

            total_delta = event.screen_offset - self.position_on_down
            new_size = self.size_on_down - total_delta
        
            self.connected_container.styles.width = clamp(new_size.width, self.min_width, self.max_width)

    def on_mouse_down(self, event: events.MouseDown) -> None:

        self.max_width = self.app.screen.size.width - 10
        self.position_on_down = event.screen_offset
        self.size_on_down = self.connected_container.size

        self.add_class("pressed")    # this requires a "pressed" class to exist
        self.capture_mouse()

    def on_mouse_up(self) -> None:

        self.remove_class("pressed")
        self.release_mouse()


class CodeContainer(Horizontal):

    def compose(self) -> ComposeResult:
        yield ResizeBar(self)
        with Vertical():
            with Horizontal():
                yield Static(id="current_recipe")
                yield Static(id="current_category")
                with Button("Run", id="run_button", compact=True) as butt:
                    butt.can_focus = False
            yield TextArea(
                "Select a recipe \n"
                "Hint: You can resize this area by dragging \n"
                "<-- this Resize bar with your mouse.",
                # language="markdown",
                read_only=True,
                show_line_numbers=True,
                soft_wrap=False,
                tab_behavior="indent"
            )

    def update(self, recipe_data: RecipeData):
        self.query_one("#current_recipe", Static).update(f"📄 {recipe_data['name']}.py  │")
        self.query_one("#current_category", Static).update(f"📁 {recipe_data['category']}")
        text_area = self.query_one(TextArea)
        text_area.language = "python"
        text_area.text = recipe_data['text_blob']


class CustomDataTable(DataTable[Any]):

    class TableInitialized(Message):
        pass

    class SortingStatus(Enum):
        UNSORTED = 0  #     [-] unsorted
        ASCENDING = 1  #    [↑] ascending (reverse = True)
        DESCENDING = 2  #   [↓] descending (reverse = False)


    def __init__(
        self,
        recipe_data_dict: dict[str, RecipeData], 
    ) -> None:
        super().__init__(zebra_stripes=True, show_cursor=True, cursor_type="row")
        self.recipe_data_dict = recipe_data_dict
        self.initialized = False

        self.sort_status: dict[str, CustomDataTable.SortingStatus] = {
            "name": CustomDataTable.SortingStatus.UNSORTED,
            "category": CustomDataTable.SortingStatus.UNSORTED,
            "author": CustomDataTable.SortingStatus.UNSORTED,
        }

        for key in self.sort_status:
            self.add_column(f"{key} [dark_orange]-[/]", key=key)

    def on_mount(self) -> None:

        for recipe_data in self.recipe_data_dict.values():

            self.add_row(
                SortableText(recipe_data["name"], overflow="ellipsis"),
                SortableText(recipe_data["category"], overflow="ellipsis"),
                SortableText(recipe_data["author"], overflow="ellipsis"),
                key= recipe_data["name"],
            )        

    @on(DataTable.HeaderSelected)
    def header_selected(self, event: DataTable.HeaderSelected) -> None:

        column = self.columns[event.column_key]
        self.sort_column(column, column.key)


    def sort_column(self, column: Column, column_key: ColumnKey) -> None:

        if column_key.value is None:
            raise ValueError("Tried to sort a column with no key.")
        if column_key.value not in self.sort_status:
            raise ValueError(
                f"Unknown column key: {column_key.value}. "
                "This should never happen, please report this issue."
            )
        value = column_key.value

        # if its currently unsorted, that means the user is switching columns
        # to sort. Reset all columns to unsorted.
        if self.sort_status[value] == self.SortingStatus.UNSORTED:
            for key in self.sort_status:
                self.sort_status[key] = self.SortingStatus.UNSORTED
                col_index = self.get_column_index(key)
                col = self.ordered_columns[col_index]
                col.label = Text.from_markup(f"{key} [dark_orange]-[/]")

            # Now set chosen column to ascending:
            self.sort_status[value] = self.SortingStatus.ASCENDING
            self.sort(column_key, reverse=True)
            column.label = Text.from_markup(f"{value} [dark_orange]↑[/]")

        # For the other two conditions, we just toggle ascending/descending
        elif self.sort_status[value] == self.SortingStatus.ASCENDING:
            self.sort_status[value] = self.SortingStatus.DESCENDING
            self.sort(value, reverse=False)
            column.label = Text.from_markup(f"{value} [dark_orange]↓[/]")
        elif self.sort_status[value] == self.SortingStatus.DESCENDING:
            self.sort_status[value] = self.SortingStatus.ASCENDING
            self.sort(column_key, reverse=True)
            column.label = Text.from_markup(f"{value} [dark_orange]↑[/]")
        else:
            raise ValueError(
                f"Sort status for {value} is '{self.sort_status[value]}', did not meet any expected values."
            )


class TableScreen(Screen[None]):

    BINDINGS = [
        Binding("1", "sort_column(0)", "Sort Column 1"),
        Binding("2", "sort_column(1)", "Sort Column 2"),
        Binding("3", "sort_column(2)", "Sort Column 3"),
        Binding("d", "show_description", "Show Description"),
    ]

    def __init__(
        self, 
        recipe_data_dict: dict[str, RecipeData],
        starting_selection: str | None = None,
        run_immediately: bool = False,
    ) -> None:
        super().__init__()
        self.recipe_data_dict = recipe_data_dict
        self.selected_recipe = None
        self.starting_selection = starting_selection
        self.run_immediately = run_immediately

    def compose(self) -> ComposeResult:

        with Vertical(id="main_container"):
            with Vertical(id="header_container"):
                yield FigletWidget(
                    "TeXTuaL cOOkBOOk",
                    font="double_blocky",
                    colors=["red", "red", "magenta"],
                    horizontal=True,
                    animate=True,
                    fps=30,
                )
                yield Static(
                    "Select once to view code, select again to run",
                    id="header_text",
                )
            with Vertical(id="table_container"):
                self.table = CustomDataTable(self.recipe_data_dict)
                yield self.table
            with Vertical(id="bottom_container"):
                yield Static(
                    "[$accent]Up/Down[/] Navigate │ "
                    "[$accent]Enter[/]/[$accent]Click[/] Select │ "
                    "[$accent]1-3[/] Sort │ "
                    "[$accent]d[/] Description",
                    id="controls_bar",
                )
                with Horizontal(classes="button_container"):
                    yield Button("Quit", id="quit_button", compact=True)
        yield CodeContainer()
        
    async def on_mount(self):
        if self.starting_selection:
            try:
                row_index = self.table.get_row_index(self.starting_selection)
            except RowDoesNotExist:
                self.notify(
                    f"Recipe '{self.starting_selection}' not found in table."
                )
                return
            else:
                self.table.move_cursor(row=row_index)
                await self.table.run_action("select_cursor")
                if self.run_immediately:
                    # This will trigger the recipe_selected event
                    await self.table.run_action("select_cursor")

    def action_sort_column(self, column_index: int) -> None:

        try:
            column = self.table.ordered_columns[column_index]
        except IndexError:
            return
        else:
            self.table.sort_column(column, column.key)

    @on(Button.Pressed, "#run_button")
    def run_button_pressed(self) -> None:

        if self.selected_recipe is None:
            self.notify("No recipe selected", timeout=3)
        else:
            category = self.recipe_data_dict[self.selected_recipe]["category"]
            self.run_recipe(self.selected_recipe, category)        

    @on(DataTable.RowSelected)
    @work
    async def recipe_selected(self, event: DataTable.RowSelected) -> None:

        row_data: list[str] = self.table.get_row(event.row_key)
        recipe_name = str(row_data[0])
        recipe_data = self.recipe_data_dict[recipe_name]
        category = self.recipe_data_dict[recipe_name]["category"]
        code_container = self.query_one(CodeContainer)

        if recipe_name == self.selected_recipe:
            self.run_recipe(recipe_name, category)
        else:
            self.selected_recipe = recipe_name
            code_container.update(recipe_data)

    @work
    async def action_show_description(self) -> None:

        cursor_index = self.table.cursor_row
        row_data: list[str] = self.table.get_row_at(cursor_index)
        recipe_name = str(row_data[0])
        description = self.recipe_data_dict[recipe_name]["description"]
        result = await self.app.push_screen(DescriptionScreen(description), wait_for_dismiss=True)
        if result:
            category = self.recipe_data_dict[recipe_name]["category"]
            self.run_recipe(recipe_name, category)

    def run_recipe(self, recipe_name: str, category: str) -> None:

        with self.app.suspend():

            cookbook = resources.files("textual_cookbook")
            recipe_traversable = cookbook.joinpath(f"recipes/{category}/{recipe_name}.py")

            # as_file gives a real filesystem Path (extracts to a temp file if inside a zip/wheel)
            with resources.as_file(recipe_traversable) as recipe_path:
                try:
                    subprocess.run([sys.executable, str(recipe_path)], check=True)
                except subprocess.CalledProcessError as e:
                    self.app.push_screen(ErrorScreen(e))
                except Exception as e:
                    raise e


    @on(Button.Pressed, "#quit_button")
    def quit_button_pressed(self) -> None:
        self.set_timer(0.2, self.app.exit)


class CookBookApp(App[None]):

    CSS_PATH = "styles.tcss"
    
    def __init__(
        self,
        starting_recipe: str | None = None,
        run: bool = False,
    ) -> None:
        super().__init__()
        self.starting_recipe = starting_recipe
        self.run_immediately = run

    class WorkerFinished(Message):
        def __init__(self, recipe_data_dict: dict[str, RecipeData]) -> None:
            super().__init__()
            self.recipe_data_dict = recipe_data_dict

    def on_mount(self) -> None:
        self.run_worker(self.scan_for_recipes)

    async def scan_for_recipes(self) -> None:

        recipe_data_dict: dict[str, RecipeData] = {}
        
        # NEW: Start with Traversable then convert to Path for easier handling
        recipes_traversable = resources.files("textual_cookbook").joinpath("recipes")
        recipes_dir = Path(str(recipes_traversable))

        # Simple one-level scan - categories are subdirectories, no files in root
        for recipe_file in recipes_dir.rglob("*.py"):
            with open(recipe_file, "r", encoding="utf-8") as f:
                text_blob = f.read()
                author_match = re.search(r"Recipe by (.+)", text_blob)
                if author_match:
                    author = author_match.group(1).strip()
                    author = author.replace('"', "")
                else:
                    author = "Unknown"

            description_match = re.match(r'"""(.*?)"""', text_blob, re.DOTALL)
            if description_match:
                description = description_match.group(1).strip()
            else:
                description = "No description available."

            recipe_data: RecipeData = {
                "name": recipe_file.stem,
                "category": recipe_file.parent.name,
                "author": author,
                "description": description,
                "text_blob": text_blob
            }
            recipe_data_dict[recipe_file.stem] = recipe_data

        self.post_message(CookBookApp.WorkerFinished(recipe_data_dict))


    @on(WorkerFinished)
    async def worker_finished(self, message: WorkerFinished) -> None:

        await self.push_screen(
            TableScreen(
                message.recipe_data_dict, 
                starting_selection=self.starting_recipe,
                run_immediately=self.run_immediately,
            )
        )

def run_main() -> None:
    CookBookApp().run()
    

if __name__ == "__main__":
    run_main()