#!/usr/bin/env -S uv run --script
# /// script
# dependencies = [
#   "nicegui==3.16.0",
#   "pandas",
#   "pydantic>=2",
# ]
# ///

from collections.abc import Callable
from dataclasses import dataclass
from dataclasses import field

import pandas as pd
from nicegui import binding
from nicegui import events
from nicegui import ui
from pydantic import BaseModel
from pydantic import ValidationError
from pydantic import field_validator

STATUS_OPTIONS = ["draft", "active", "archived"]
EDITABLE_FIELDS = ("name", "quantity", "status")

type TableValue = str | int
type TableRow = dict[str, TableValue]


class RowEditDraft(BaseModel):
    name: str
    quantity: int
    status: str

    @field_validator("name")
    @classmethod
    def validate_name(cls, value: str) -> str:
        if not (name := value.strip()):
            raise ValueError("Name is required")
        return name

    @field_validator("quantity", mode="before")
    @classmethod
    def validate_quantity(cls, value: object) -> int:
        if isinstance(value, bool) or value is None:
            raise TypeError("Quantity must be an integer")
        if not isinstance(value, (int, float, str)):
            raise TypeError("Quantity must be an integer")
        if isinstance(value, float) and not value.is_integer():
            raise ValueError("Quantity must be an integer")
        try:
            quantity = int(value)
        except (TypeError, ValueError, OverflowError) as error:
            raise ValueError("Quantity must be an integer") from error
        if not 0 <= quantity <= 1_000:
            raise ValueError("Quantity must be between 0 and 1000")
        return quantity

    @field_validator("status")
    @classmethod
    def validate_status(cls, value: str) -> str:
        if value not in STATUS_OPTIONS:
            raise ValueError("Unknown status")
        return value


@dataclass(slots=True)
class RowEditorDialog:
    open_for_row_id: Callable[[int], None]


def _validation_message(error: ValidationError) -> str:
    return str(error.errors()[0]["msg"])


@binding.bindable_dataclass
class EditableRow:
    id: int
    name: str
    quantity: int
    status: str
    table_row: TableRow = field(init=False, repr=False)
    touched: bool = False

    def __post_init__(self) -> None:
        self.table_row = {
            "id": self.id,
            "name": self.name,
            "quantity": self.quantity,
            "status": self.status,
        }
        for field_name in EDITABLE_FIELDS:
            binding.bind_to(
                self,
                field_name,
                self.table_row,
                field_name,
                other_strict=True,
            )

    def to_draft(self) -> RowEditDraft:
        return RowEditDraft(name=self.name, quantity=self.quantity, status=self.status)

    def validate_update(self, updates: dict[str, object]) -> RowEditDraft:
        base_values = self.to_draft().model_dump()
        return RowEditDraft.model_validate({**base_values, **updates})

    def apply_draft(self, draft: RowEditDraft) -> None:
        self.name = draft.name
        self.quantity = draft.quantity
        self.status = draft.status


@dataclass(slots=True)
class EditableTableState:
    rows_by_id: dict[int, EditableRow]

    def row(self, row_id: int) -> EditableRow | None:
        return self.rows_by_id.get(row_id)

    def table_rows(self) -> list[TableRow]:
        return [row.table_row for row in self.rows_by_id.values()]

    def touched_rows(self) -> list[EditableRow]:
        return [row for row in self.rows_by_id.values() if row.touched]


def dataframe_to_state(dataframe: pd.DataFrame) -> EditableTableState:
    required_columns = {"id", *EDITABLE_FIELDS}
    missing_columns = required_columns.difference(dataframe.columns)
    if missing_columns:
        raise ValueError(f"Missing columns: {sorted(missing_columns)}")
    if not dataframe["id"].is_unique:
        raise ValueError("The id column must contain unique row keys")

    rows_by_id: dict[int, EditableRow] = {}
    for record in dataframe.to_dict(orient="records"):
        row = EditableRow(
            id=int(record["id"]),
            name=str(record["name"]),
            quantity=int(record["quantity"]),
            status=str(record["status"]),
        )
        if row.status not in STATUS_OPTIONS:
            raise ValueError(f"Unknown status {row.status!r}")
        if row.id in rows_by_id:
            raise ValueError("Row keys must remain unique after normalization")
        rows_by_id[row.id] = row

    return EditableTableState(rows_by_id)


def render_row_editor_dialog(
    state: EditableTableState,
    refresh_table: Callable[[], None],
) -> RowEditorDialog:
    selected_row_id: int | None = None

    with ui.dialog() as edit_dialog, ui.card().classes("w-96"):
        dialog_heading = ui.label("Edit row")
        draft_name = ui.input("Name")
        draft_quantity = ui.number("Quantity", min=0, max=1_000, precision=0)
        draft_status = ui.select(STATUS_OPTIONS, label="Status")
        with ui.row().classes("w-full justify-end"):
            ui.button("Cancel", on_click=edit_dialog.close).props("flat")

            def save_dialog_edit() -> None:
                nonlocal selected_row_id
                try:
                    if selected_row_id is None:
                        raise ValueError("Select a row before saving")

                    row_state = state.row(selected_row_id)
                    if row_state is None:
                        raise ValueError("This row no longer exists")

                    draft = row_state.validate_update(
                        {
                            "name": draft_name.value,
                            "quantity": draft_quantity.value,
                            "status": draft_status.value,
                        },
                    )
                    row_state.apply_draft(draft)
                    row_state.touched = True
                    edit_dialog.close()
                except ValidationError as error:
                    ui.notify(_validation_message(error), type="negative")
                except ValueError as error:
                    ui.notify(str(error), type="negative")
                finally:
                    refresh_table()

            ui.button("Save", icon="save", on_click=save_dialog_edit)

    def open_for_row_id(row_id: int) -> None:
        nonlocal selected_row_id
        row_state = state.row(row_id)
        if row_state is None:
            ui.notify("This row no longer exists", type="negative")
            return

        selected_row_id = row_id
        draft = row_state.to_draft()
        dialog_heading.set_text(f"Edit row {row_id}")
        draft_name.set_value(draft.name)
        draft_quantity.set_value(draft.quantity)
        draft_status.set_value(draft.status)
        edit_dialog.open()

    return RowEditorDialog(open_for_row_id=open_for_row_id)


def render_table(dataframe: pd.DataFrame) -> EditableTableState:
    state = dataframe_to_state(dataframe)
    columns = [
        {"name": "name", "label": "Name", "field": "name", "align": "left"},
        {"name": "quantity", "label": "Quantity", "field": "quantity", "align": "right"},
        {"name": "status", "label": "Status", "field": "status", "align": "left"},
        {"name": "actions", "label": "Actions", "field": "id", "align": "center"},
    ]
    table = ui.table(
        columns=columns,
        rows=state.table_rows(),
        row_key="id",
        selection="multiple",
        pagination=10,
    ).classes("w-120")

    def refresh_table() -> None:
        table.update_rows(state.table_rows(), clear_selection=False)

    def apply_inline_edit(event: events.GenericEventArguments) -> None:
        try:
            raw_row_id, raw_field, raw_value = event.args
            row_id = int(raw_row_id)
            field_name = str(raw_field)
            if field_name not in EDITABLE_FIELDS:
                raise ValueError(f"Field {field_name!r} is not editable")

            row_state = state.row(row_id)
            if row_state is None:
                raise ValueError("This row no longer exists")

            draft = row_state.validate_update({field_name: raw_value})
            row_state.apply_draft(draft)
            row_state.touched = True
        except ValidationError as error:
            ui.notify(_validation_message(error), type="negative")
        except (TypeError, ValueError) as error:
            ui.notify(str(error), type="negative")
        finally:
            refresh_table()

    def show_changes() -> None:
        changed_rows = state.touched_rows()
        if not changed_rows:
            ui.notify("No rows changed")
            return
        for row in changed_rows:
            ui.notify(f"Changed row: {row.id}: {row.name}, quantity {row.quantity}, status {row.status}")

    row_editor = render_row_editor_dialog(state, refresh_table)

    _add_slots(
        table,
        apply_inline_edit,
        row_editor.open_for_row_id,
    )

    with ui.row().classes("w-120 justify-end"):
        ui.button("Show changes", icon="edit_note", on_click=show_changes)

    return state


def open_dialog_for_row(open_editor: Callable[[int], None], event: events.GenericEventArguments) -> None:
    try:
        row_id = int(event.args)
        open_editor(row_id)
    except (TypeError, ValueError):
        ui.notify("Invalid row key", type="negative")


def _add_slots(
    table: ui.table,
    apply_inline_edit: Callable[[events.GenericEventArguments], None],
    open_editor: Callable[[int], None],
):
    with table.add_slot("body-cell-name"), table.cell("name"):
        name_input = ui.input().props(remove="value")
        name_input.props(':value="props.value" dense borderless debounce=400').on(
            "update:value",
            handler=apply_inline_edit,
            js_handler="(value) => emit(props.row.id, props.col.name, value)",
        )

    with table.add_slot("body-cell-quantity"), table.cell("quantity"):
        ui.number(min=0, max=1_000).props(':model-value="props.value" dense borderless debounce=400').on(
            "update:model-value",
            handler=apply_inline_edit,
            js_handler="(value) => emit(props.row.id, props.col.name, value)",
        )

    with table.add_slot("body-cell-status"), table.cell("status"):
        ui.select(STATUS_OPTIONS).props(':model-value="props.value" dense borderless options-dense').on(
            "update:model-value",
            handler=apply_inline_edit,
            js_handler="(option) => emit(props.row.id, props.col.name, option.label)",
        )

    with table.add_slot("body-cell-actions"), table.cell("actions"):
        edit_button = ui.button(icon="edit")
        edit_button.props('flat round dense color=primary aria-label="Edit row"')
        edit_button.tooltip("Edit this row").on(
            "click",
            handler=lambda event: open_dialog_for_row(open_editor, event),
            js_handler="() => emit(props.row.id)",
        )


if __name__ in {"__main__", "__mp_main__"}:
    items = pd.DataFrame(
        [
            {"id": 101, "name": "Desk", "quantity": 4, "status": "active"},
            {"id": 102, "name": "Lamp", "quantity": 12, "status": "draft"},
            {"id": 103, "name": "Chair", "quantity": 8, "status": "active"},
            {"id": 104, "name": "Shelf", "quantity": 3, "status": "draft"},
            {"id": 105, "name": "Monitor", "quantity": 15, "status": "active"},
            {"id": 106, "name": "Keyboard", "quantity": 20, "status": "active"},
            {"id": 107, "name": "Mouse", "quantity": 24, "status": "active"},
            {"id": 108, "name": "Dock", "quantity": 6, "status": "archived"},
            {"id": 109, "name": "Cable", "quantity": 40, "status": "draft"},
            {"id": 110, "name": "Stand", "quantity": 10, "status": "active"},
        ]
    )
    table_state = render_table(items)

    ui.run(port=8888, reload=True)
