import tkinter.ttk as ttk
from collections.abc import Iterable
from contextlib import suppress
from tkinter import END

from .base import Base
from .entries import CellEntry
from .tabulardata import TabularData


class TableView(Base):
    """A table widget wrapping tkinter Treeview with data binding and editing support."""

    class DelegateError(Exception):
        """Raised when a delegate callback raises an unexpected error."""

    class WidgetNotYetCreatedError(Exception):
        """Raised when accessing widget properties before the widget is created."""

    def __init__(self, columns_labels, is_treetable=False, create_data_source=True):
        Base.__init__(self)
        if not isinstance(columns_labels, dict):
            raise TypeError(
                "column_labels must be a dictionary with {'column_name':'column_label'}"
            )
        self._columns_labels = columns_labels  # keep until widget created
        self.is_treetable = is_treetable
        self.column_formats = {} # Dict with column_name: 'format_string', 'multiplier', 'type','anchor'

        self.delegate = None
        self.all_elements_are_editable = True

        if create_data_source:
            self.data_source = TabularData(
                tableview=self, required_fields=list(columns_labels.keys())
            )
        else:
            self.data_source = None

    @property
    def columns(self):
        """Return the list of column names."""
        if self.widget is not None:
            return list(self.widget["columns"])
        else:
            return list(self._columns_labels.keys())

    @columns.setter
    def columns(self, new_values):
        if self.widget is not None:
            # If displaycolumns has been set, it cannot contain columns
            # that are being deleted. We start by removing them from displaycolumns
            # We will modify the list, we need to make a copy
            # displayed_column_names = self.displaycolumns.copy()

            # for old_column_name in displayed_column_names:
            #     if old_column_name not in new_values:
            #         self.displaycolumns.remove(old_column_name)

            self.displaycolumns = ["#all"] # necessary to avoid TCLError when setting columns
            self.widget["columns"] = new_values
            self.displaycolumns = new_values # We refer to displaycolumns to get displayed order
        else:
            raise ValueError("Set columns-labels directly if the widget is not created yet.")


    def column_info(self, cid):
        """Return column configuration info for the given column identifier."""
        if self.widget is not None:
            return self.widget.column(cid)
        else:
            raise TableView.WidgetNotYetCreatedError()

    @property
    def displaycolumns(self):
        """Return the list of currently displayed column names."""
        if self.widget is not None:
            return list(self.widget["displaycolumns"])
        else:
            return list(self._columns_labels.keys())

    @displaycolumns.setter
    def displaycolumns(self, values):
        if isinstance(values, Iterable) and len(values) == 0:
            print('Warning: empty displaycolumns will display nothing')

        self.widget.configure(displaycolumns=values)

    @property
    def headings(self):
        """Return the list of column heading labels."""
        headings = []
        if self.widget is not None:
            for column_name in self.columns:
                treeview_heading = self.widget.heading(column_name)
                headings.append(treeview_heading["text"])
        else:
            headings = list(self._columns_labels.values())

        return headings

    @headings.setter
    def headings(self, new_values):
        assert len(new_values) == len(self.columns)
        new_columns_labels = dict(zip(self.columns, new_values, strict=False))

        if self.widget is not None:
            for column_name, column_heading in new_columns_labels.items():
                self.widget.heading(column_name,text=column_heading)
        else:
            self._columns_labels = dict(zip(self.columns, new_values, strict=False))

    @property
    def columns_labels(self):
        """Return a dict mapping column names to their heading labels."""
        if self.widget is not None:
            return dict(zip(self.columns, self.headings, strict=False))
        else:
            return self._columns_labels

    @columns_labels.setter
    def columns_labels(self, new_values):
        if self.widget is not None:
            self.columns = list(new_values.keys())
            self.headings = list(new_values.values())
        else:
            return self._columns_labels

    def heading_info(self, cid):
        """Return heading configuration info for the given column identifier."""
        if self.widget is not None:
            return self.widget.heading(cid)
        else:
            raise TableView.WidgetNotYetCreatedError()

    def item_info(self, iid):
        """Return item configuration info for the given item identifier."""
        if self.widget is not None:
            return self.widget.item(iid)
        else:
            raise TableView.WidgetNotYetCreatedError()

    def create_widget(self, master):
        """Create the underlying Treeview widget within the given master container."""
        self.parent = master

        if self.is_treetable:
            self.widget = ttk.Treeview(
                master,
                selectmode="browse",
                takefocus=True,
            )
        else:
            self.widget = ttk.Treeview(
                master,
                show="headings",
                selectmode="browse",
                takefocus=True,
            )

        self.widget.configure(columns=sorted(list(self._columns_labels.keys())))
        self.widget.configure(displaycolumns=sorted(list(self._columns_labels.keys())))
        for key, value in self._columns_labels.items():
            self.widget.heading(key, text=value)

        self.widget.bind("<Button>", self.click)
        self.widget.bind("<Double-Button>", self.doubleclick)
        self.widget.bind("<<TreeviewSelect>>", self.selection_changed)

    def source_data_changed(self, records):
        """Update the widget to reflect changes in the data source records."""
        self.source_data_added_or_updated(records)
        self.source_data_deleted(records)

        if self.delegate is not None:
            with suppress(AttributeError):
                self.delegate.source_data_changed(self)

    def source_data_added_or_updated(self, records):
        """Insert new records or update existing ones in the widget."""
        for record in records:
            formatted_values = self.record_to_formatted_widget_values(record)
            item_id = record["__uuid"]
            if self.widget.exists(item_id):  # updated
                for i, value in enumerate(formatted_values):
                    self.widget.set(item_id, column=i, value=value)
            else:  # added
                parentid = ""
                if record["__puuid"] is not None:
                    parentid = record["__puuid"]
                self.widget.insert(parentid, END, iid=item_id, values=formatted_values)

    def source_data_deleted(self, records):
        """Remove widget items that are no longer present in the data source."""
        uuids = [str(record["__uuid"]) for record in records]
        items_ids = self.items_ids()

        for item_id in items_ids:
            if item_id not in uuids:
                self.widget.delete(item_id)

    def record_to_formatted_widget_values(self, record):
        """Convert a data record to a list of formatted strings for display."""
        ordered_values = [record[column] for column in self.columns]
        formatted_values = []
        for i, value in enumerate(ordered_values):
            padding = ""
            column_name = self.columns[i]
            if len(self.displaycolumns) > 0 and column_name == self.displaycolumns[0]:
                level = record.get("depth_level", 0)
                padding = "   " * level

            column_format = self.column_formats.get(column_name, None)

            try:
                if value is None:
                    value = ""

                if column_format is not None:
                    format_string = column_format['format_string']
                    multiplier = column_format['multiplier']
                    if multiplier is not None:
                        formatted_values.append(
                            padding + format_string.format(value/multiplier)
                        )
                    else:
                        formatted_values.append(
                            padding + format_string.format(value)
                        )
                else:
                    formatted_values.append(
                                padding + str(value)
                            )

            except Exception:
                formatted_values.append(
                            padding + str(value)
                        )
        return formatted_values

    def extract_record_from_formatted_widget_values(self):
        """Extract a record from the formatted widget values (not implemented)."""
        return None

    def widget_data_changed(self):
        """Handle notification that the widget data has changed."""
        pass

    def items_ids(self):
        """Return all item identifiers in the widget, including nested children."""
        all_item_ids = []
        parent_items_ids = [None]
        while len(parent_items_ids) > 0:
            all_children_item_ids = []
            for item_id in parent_items_ids:
                children_items_ids = self.widget.get_children(item_id)

                all_item_ids.extend(children_items_ids)
                all_children_item_ids.extend(children_items_ids)

            parent_items_ids = all_children_item_ids

        return all_item_ids

    def item_modified(self, item_id, modified_record):
        """Update a widget item and its backing data source record."""
        ordered_values = self.record_to_formatted_widget_values(modified_record)

        self.widget.item(item_id, values=ordered_values)
        self.data_source.update_record(item_id, values=modified_record)

    def clear_widget_content(self):
        """Delete all items from the widget and return their identifiers."""
        items_ids = self.widget.get_children()
        self.widget.delete(*items_ids)
        return items_ids

    def empty(self):
        """Remove all items from the table."""
        self.clear_widget_content()

    def selection_changed(self, event):
        """Notify the delegate when the table selection changes."""
        if self.delegate is not None:
            with suppress(AttributeError):
                self.delegate.selection_changed(event, self)

    def identify_column_name(self, event_x):
        """Return the column name at the given x pixel coordinate."""
        column_string_id = self.widget.identify_column(event_x)
        if column_string_id == '#0':
            return column_string_id

        display_column_number = int(column_string_id.strip("#"))
        column_name = self.displaycolumns[display_column_number-1]

        return column_name

    def get_column_name(self, column_id=None, display_column_number=None):
        """Return the column name for a given column id or display column number."""
        if column_id is not None:
            # column_id is 1-based, our list is 0-based
            if column_id == 0:
                return "#0"
            return self.columns[column_id-1]
        elif display_column_number is not None:
            # display_column_number 0 is the icon column
            if display_column_number == 0:
                return "#0"
            return self.displaycolumns[display_column_number-1]

    def get_column_id(self, column_name):
        """Return the 1-based column id for the given column name."""
        # column_id is 1-based, our list is 0-based
        # The items "values" are accessible by column_id-1
        if column_name == "#0":
            return 0
        return self.columns.index(column_name)+1

    def get_logical_column_id(self, column_name):
        """Return the 0-based logical column index used to access item values."""
        # logical_column_id is 0-based and used to access item['values']
        if column_name == "#0":
            return None # We do not store the icon
        return self.columns.index(column_name)

    def click(self, event) -> bool:  # pragma: no cover
        """Handle a single click event on the table."""
        keep_running = True
        try:
            with suppress(AttributeError):
                keep_running = self.delegate.click(event, self)
        except Exception as err:
            raise TableView.DelegateError(err) from err

        if keep_running:
            region = self.widget.identify_region(event.x, event.y)

            if region == "heading":
                column_name = self.identify_column_name(event.x)
                self.click_header(column_name=column_name)
            elif region == "cell":
                column_name = self.identify_column_name(event.x)
                item_id = self.widget.identify_row(event.y)
                self.click_cell(item_id=item_id, column_name=column_name)

        return True

    def click_cell(self, item_id, column_name):  # pragma: no cover
        """Handle a single click on a specific cell, opening URLs if applicable."""
        assert isinstance(column_name, str)
        item_dict = self.widget.item(item_id)

        keep_running = True
        try:
            with suppress(AttributeError):
                keep_running = self.delegate.click_cell(
                    item_id, column_name, self
                )
        except Exception as err:
            raise TableView.DelegateError(err) from err

        if keep_running:
            logical_column_id = self.get_logical_column_id(column_name)
            value = item_dict["values"][logical_column_id]
            if isinstance(value, str) and value.startswith("http"):
                import webbrowser

                webbrowser.open(value)

        return True

    def is_column_sorted(self, column_name):
        """Return '<' if sorted ascending, '>' if descending, or None if unsorted."""
        assert isinstance(column_name, str)

        original_items_ids = list(self.widget.get_children())
        sorted_items_ids = list(self.sorted_column(column_name=column_name, reverse=False))
        sorted_items_ids_reverse = list(self.sorted_column(column_name=column_name, reverse=True))

        if sorted_items_ids == original_items_ids:
            return "<"
        elif sorted_items_ids_reverse == original_items_ids:
            return ">"
        else:
            return None

    def sorted_column(self, column_name=None, reverse=False):
        """Return item identifiers sorted by the given column."""
        assert isinstance(column_name, str)

        if column_name == "#0":
            return self.widget.get_children()

        # HACK We sort only what is actually in the widget (may be filtered)
        widget_items_ids = self.items_ids()
        items_ids_sorted = self.data_source.sorted_records_uuids(
            only_uuids=widget_items_ids, field=column_name, reverse=reverse
        )
        return items_ids_sorted

    def sort_column(self, column_name=None, reverse=False):
        """Sort the widget items in place by the given column."""
        assert isinstance(column_name, str)

        items_ids_sorted = self.sorted_column(column_name=column_name, reverse=reverse)

        for _, item_id in enumerate(items_ids_sorted):
            record = self.data_source.record(item_id)
            parent_id = record['__puuid']
            if parent_id is None:
                parent_id = ""

            self.widget.move(record['__uuid'], parent_id , END)

        return items_ids_sorted

    def click_header(self, column_name=None):
        """Handle a click on a column header, toggling sort order."""
        assert isinstance(column_name, str)

        keep_running = True

        if column_name not in self.columns:
            raise ValueError(f"click_header: '{column_name}'' is not the name of a column")

        try:
            with suppress(AttributeError):
                keep_running = self.delegate.click_header(column_name, self)
        except Exception as err:
            raise TableView.DelegateError(err) from err

        if keep_running:
            with suppress(IndexError):  # if empty, not an error
                if self.is_column_sorted(column_name) == "<":
                    self.sort_column(column_name=column_name, reverse=True)
                else:
                    self.sort_column(column_name=column_name, reverse=False)

        return True

    def doubleclick(self, event) -> bool:  # pragma: no cover
        """Handle a double-click event on the table."""
        keep_running = True
        try:
            with suppress(AttributeError):
                keep_running = self.delegate.doubleclick(event, self)
        except Exception as err:
            raise TableView.DelegateError(err) from err

        if keep_running:
            region = self.widget.identify_region(event.x, event.y)
            column_name = self.identify_column_name(event.x)
            if region == "heading":
                self.doubleclick_header(column_name=column_name)
            elif region == "cell":
                item_id = self.widget.identify_row(event.y)
                self.doubleclick_cell(item_id=item_id, column_name=column_name)

        # return True

    def is_editable(self, item_id, column_name):
        """Return whether the given cell is editable."""
        return self.all_elements_are_editable

    def doubleclick_cell(self, item_id, column_name):
        """Handle a double-click on a cell, entering edit mode if editable."""
        assert isinstance(column_name, str)

        _item_dict = self.widget.item(item_id)

        if self.is_editable(item_id, column_name=column_name):
            self.focus_edit_cell(item_id=item_id, column_name=column_name)
        else:
            _keep_running = True

        try:
            with suppress(AttributeError):
                _keep_running = self.delegate.doubleclick_cell(
                    item_id, column_name, self
                )
        except Exception as err:
            raise TableView.DelegateError(err) from err

    def focus_edit_cell(self, item_id, column_name):
        """Place an entry widget over the cell for inline editing."""
        assert isinstance(column_name, str)

        bbox = self.widget.bbox(item_id, column=column_name)
        entry_box = CellEntry(tableview=self, item_id=item_id, column_name=column_name)
        entry_box.place_into(
            parent=self,
            x=bbox[0] - 2,
            y=bbox[1] - 2,
            width=bbox[2] + 4,
            height=bbox[3] + 4,
        )
        entry_box.widget.focus()

    def doubleclick_header(self, column_name):  # pragma: no cover
        """Handle a double-click on a column header."""
        assert isinstance(column_name, str)

        _keep_running = True
        try:
            with suppress(AttributeError):
                _keep_running = self.delegate.doubleclick_header(column_name, self)
        except Exception as err:
            raise TableView.DelegateError(err) from err
