import sys
from abc import ABC, abstractmethod
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, replace
from enum import Enum, auto
from typing import Any, ClassVar, Literal, TypeVar, cast, override

from textual import work
from textual.app import App, ComposeResult
from textual.binding import Binding, BindingsMap
from textual.containers import Center, Horizontal, ScrollableContainer, Vertical
from textual.events import Key
from textual.geometry import Offset
from textual.screen import Screen
from textual.validation import Validator
from textual.widgets import Button, DataTable, Footer, Input, Label, LoadingIndicator, OptionList, Rule, SelectionList
from textual.widgets._data_table import RowKey
from textual.widgets.option_list import Option
from textual.widgets.selection_list import Selection
from textual.worker import WorkerCancelled

from archinstall.lib.output import debug
from archinstall.lib.translationhandler import tr
from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup
from archinstall.tui.ui.result import Result, ResultType

ValueT = TypeVar('ValueT')


def _translate_bindings(source: BindingsMap | None, target: BindingsMap) -> None:
	"""Translate binding descriptions from source to target.

	Uses source (original, immutable class-level cache) to avoid
	double-translation on repeated calls (e.g. language switch).
	"""
	if source is None:
		return
	for key, bindings in source.key_to_bindings.items():
		target.key_to_bindings[key] = [replace(b, description=tr(b.description)) if b.description else b for b in bindings]


class BaseScreen(Screen[Result[ValueT]]):
	BINDINGS: ClassVar = [
		Binding('escape', 'cancel_operation', 'Cancel', show=True),
		Binding('ctrl+c', 'reset_operation', 'Reset', show=True),
	]

	def __init__(self, allow_skip: bool = False, allow_reset: bool = False):
		super().__init__()
		self._allow_skip = allow_skip
		self._allow_reset = allow_reset

	def action_cancel_operation(self) -> None:
		if self._allow_skip:
			_ = self.dismiss(Result(ResultType.Skip))

	async def action_reset_operation(self) -> None:
		if self._allow_reset:
			_ = self.dismiss(Result(ResultType.Reset))


class LoadingScreen(BaseScreen[ValueT]):
	CSS = """
	LoadingScreen {
		align: center middle;
		background: transparent;
	}

	.content-container {
		width: 1fr;
		height: 1fr;
		max-height: 100%;

		margin-top: 2;
		margin-bottom: 2;

		background: transparent;
	}

	LoadingIndicator {
		align: center middle;
	}
	"""

	def __init__(
		self,
		timer: int = 3,
		data_callback: Callable[[], Any] | None = None,
		header: str | None = None,
	):
		super().__init__()
		self._timer = timer
		self._header = header
		self._data_callback = data_callback

	async def run(self) -> Result[ValueT]:
		assert TApp.app
		return await TApp.app.show(self)

	@override
	def compose(self) -> ComposeResult:
		with Vertical(classes='content-container'):
			if self._header:
				with Center():
					yield Label(self._header, classes='header', id='loading_header')

			yield Center(LoadingIndicator())

		yield Footer()

	def on_mount(self) -> None:
		_translate_bindings(self._merged_bindings, self._bindings)
		if self._data_callback:
			self._exec_callback()
		else:
			self.set_timer(self._timer, self.action_pop_screen)

		self._set_cursor()

	def _set_cursor(self) -> None:
		label = self.query_one(Label)
		self.app.cursor_position = Offset(label.region.x, label.region.y)
		self.app.refresh()

	@work(thread=True)
	def _exec_callback(self) -> None:
		assert self._data_callback
		result = self._data_callback()
		# cannot call self.dismiss directly from
		# background thread (thread=true) as there's no event loop
		self.app.call_from_thread(self.dismiss, Result(ResultType.Selection, _data=result))

	def action_pop_screen(self) -> None:
		_ = self.dismiss()


class _OptionList(OptionList):
	BINDINGS: ClassVar = [
		Binding('down', 'cursor_down', 'Down', show=True),
		Binding('up', 'cursor_up', 'Up', show=True),
		Binding('j', 'cursor_down', 'Down', show=False),
		Binding('k', 'cursor_up', 'Up', show=False),
	]

	@override
	def on_mount(self) -> None:
		_translate_bindings(self._merged_bindings, self._bindings)


class OptionListScreen(BaseScreen[ValueT]):
	"""
	Single selection menu list
	"""

	BINDINGS: ClassVar = [
		Binding('/', 'search', 'Search', show=True),
	]

	CSS = """
	OptionListScreen {
		align-horizontal: center;
		align-vertical: middle;
		background: transparent;
	}

	.content-container {
		width: 1fr;
		height: 1fr;
		max-height: 100%;

		margin-top: 2;
		margin-left: 2;

		background: transparent;
	}

	.list-container {
		width: auto;
		height: auto;
		max-height: 100%;

		padding-bottom: 3;

		background: transparent;
	}

	OptionList {
		width: auto;
		height: auto;
		min-width: 15%;
		max-height: 1fr;

		padding-bottom: 3;

		background: transparent;
	}

	OptionList > .option-list--option-highlighted {
		background: blue;
		color: white;
		text-style: bold;
	}
	"""

	def __init__(
		self,
		group: MenuItemGroup,
		header: str | None = None,
		title: str | None = None,
		allow_skip: bool = False,
		allow_reset: bool = False,
		preview_location: Literal['right', 'bottom'] | None = None,
		enable_filter: bool = False,
	):
		super().__init__(allow_skip, allow_reset)
		self._group = group
		self._header = header
		self._title = title
		self._preview_location = preview_location
		self._filter = enable_filter
		self._show_frame = False

		self._options = self._get_options()

	def action_search(self) -> None:
		if self.query_one(OptionList).has_focus:
			if self._filter:
				self._handle_search_action()

	@override
	def action_cancel_operation(self) -> None:
		if self._filter and self.query_one(Input).has_focus:
			self._handle_search_action()
		else:
			super().action_cancel_operation()

	def _handle_search_action(self) -> None:
		search_input = self.query_one(Input)

		if search_input.has_focus:
			self.query_one(OptionList).focus()
		else:
			search_input.focus()

	async def run(self) -> Result[ValueT]:
		assert TApp.app
		return await TApp.app.show(self)

	def _get_options(self) -> list[Option]:
		options = []

		for item in self._group.get_enabled_items():
			disabled = True if item.read_only else False
			options.append(Option(item.text, id=item.get_id(), disabled=disabled))

		return options

	@override
	def compose(self) -> ComposeResult:
		if self._title:
			yield Label(self._title, classes='app-header')

		with Vertical(classes='content-container'):
			if self._header:
				yield Label(self._header, classes='header-text', id='header_text')

			option_list = _OptionList(id='option_list_widget')

			if not self._show_frame:
				option_list.classes = 'no-border'

			if self._preview_location is None:
				with Center():
					with Vertical(classes='list-container'):
						yield option_list
			else:
				Container = Horizontal if self._preview_location == 'right' else Vertical
				rule_orientation: Literal['horizontal', 'vertical'] = 'vertical' if self._preview_location == 'right' else 'horizontal'

				with Container():
					yield option_list
					yield Rule(orientation=rule_orientation)
					yield ScrollableContainer(Label('', id='preview_content', markup=False))

		if self._filter:
			yield Input(placeholder='/filter', id='filter-input')

		yield Footer()

	def on_mount(self) -> None:
		_translate_bindings(self._merged_bindings, self._bindings)
		self._update_options(self._options)
		self.query_one(OptionList).focus()

	def on_input_changed(self, event: Input.Changed) -> None:
		search_term = event.value.lower()
		self._group.set_filter_pattern(search_term)
		filtered_options = self._get_options()
		self._update_options(filtered_options)

	def _update_options(self, options: list[Option]) -> None:
		option_list = self.query_one(OptionList)
		option_list.clear_options()
		option_list.add_options(options)

		option_list.highlighted = self._group.get_focused_index()

		if focus_item := self._group.focus_item:
			self._set_preview(focus_item.get_id())

	def on_input_submitted(self, event: Input.Submitted) -> None:
		if self.query_one(Input).has_focus:
			self._handle_search_action()

	def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
		selected_option = event.option
		if selected_option.id is not None:
			item = self._group.find_by_id(selected_option.id)
			_ = self.dismiss(Result(ResultType.Selection, _item=item))

	def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None:
		if event.option.id:
			self._set_preview(event.option.id)

		self._set_cursor()

	def _set_cursor(self) -> None:
		option_list = self.query_one(OptionList)
		index = option_list.highlighted

		if index is None:
			return

		target_y = sum(
			[
				1 if self._show_frame else 0,  # add top buffer for the frame
				option_list.region.y,  # padding/margin offset of the option list
				index,  # index of the highlighted option
				-option_list.scroll_offset.y,  # scroll offset
			]
		)

		# debug(f'Index: {index}')
		# debug(f'Region: {option_list.region}')
		# debug(f'Scroll offset: {option_list.scroll_offset}')
		# debug(f'Target_Y: {target_y}')

		self.app.cursor_position = Offset(option_list.region.x, target_y)
		self.app.refresh()

	def _set_preview(self, item_id: str) -> None:
		if self._preview_location is None:
			return None

		preview_widget = self.query_one('#preview_content', Label)
		item = self._group.find_by_id(item_id)

		if item.preview_action is not None:
			maybe_preview = item.preview_action(item)

			if maybe_preview is not None:
				preview_widget.update(maybe_preview)
				return

		preview_widget.update('')


class _SelectionList(SelectionList[ValueT]):
	BINDINGS: ClassVar = [
		Binding('down', 'cursor_down', 'Down', show=True),
		Binding('up', 'cursor_up', 'Up', show=True),
		Binding('j', 'cursor_down', 'Down', show=False),
		Binding('k', 'cursor_up', 'Up', show=False),
		Binding('space', 'select', 'Toggle', show=True),
	]

	@override
	def on_mount(self) -> None:
		_translate_bindings(self._merged_bindings, self._bindings)


class SelectListScreen(BaseScreen[ValueT]):
	"""
	Multi selection menu
	"""

	BINDINGS: ClassVar = [
		Binding('/', 'search', 'Search', show=True),
		Binding('enter', '', 'Confirm', show=True),
	]

	CSS = """
	SelectListScreen {
		align-horizontal: center;
		align-vertical: middle;
		background: transparent;
	}

	.content-container {
		width: 1fr;
		height: 1fr;
		max-height: 100%;

		margin-top: 2;
		margin-left: 2;

		background: transparent;
	}

	.list-container {
		width: auto;
		height: auto;
		min-width: 15%;
		max-height: 1fr;

		padding-bottom: 3;

		background: transparent;
	}

	SelectionList {
		width: auto;
		height: auto;
		max-height: 1fr;

		padding-bottom: 3;

		background: transparent;
	}

	SelectionList > .option-list--option-highlighted {
		background: blue;
		color: white;
		text-style: bold;
	}
	"""

	def __init__(
		self,
		group: MenuItemGroup,
		header: str | None = None,
		allow_skip: bool = False,
		allow_reset: bool = False,
		preview_location: Literal['right', 'bottom'] | None = None,
		enable_filter: bool = False,
	):
		super().__init__(allow_skip, allow_reset)
		self._group = group
		self._header = header
		self._preview_location = preview_location
		self._show_frame = False
		self._filter = enable_filter

		self._selected_items: list[MenuItem] = self._group.selected_items
		self._options: list[Selection[MenuItem]] = self._get_selections()

	def action_search(self) -> None:
		if self.query_one(OptionList).has_focus:
			if self._filter:
				self._handle_search_action()

	@override
	def action_cancel_operation(self) -> None:
		if self._filter and self.query_one(Input).has_focus:
			self._handle_search_action()
		else:
			super().action_cancel_operation()

	def _handle_search_action(self) -> None:
		search_input = self.query_one(Input)

		if search_input.has_focus:
			self.query_one(SelectionList).focus()
		else:
			search_input.focus()

	async def run(self) -> Result[ValueT]:
		assert TApp.app
		return await TApp.app.show(self)

	def _get_selections(self) -> list[Selection[MenuItem]]:
		selections = []

		for item in self._group.get_enabled_items():
			is_selected = item in self._selected_items
			selection = Selection(item.text, item, is_selected)
			selections.append(selection)

		return selections

	@override
	def compose(self) -> ComposeResult:
		with Vertical(classes='content-container'):
			if self._header:
				yield Label(self._header, classes='header-text', id='header_text')

			selection_list = _SelectionList[MenuItem](id='select_list_widget')

			if not self._show_frame:
				selection_list.classes = 'no-border'

			if self._preview_location is None:
				with Center():
					with Vertical(classes='list-container'):
						yield selection_list
			else:
				Container = Horizontal if self._preview_location == 'right' else Vertical
				rule_orientation: Literal['horizontal', 'vertical'] = 'vertical' if self._preview_location == 'right' else 'horizontal'

				with Container():
					yield selection_list
					yield Rule(orientation=rule_orientation)
					yield ScrollableContainer(Label('', id='preview_content', markup=False))

		if self._filter:
			yield Input(placeholder='/filter', id='filter-input')

		yield Footer()

	def on_input_submitted(self, event: Input.Submitted) -> None:
		if self.query_one(Input).has_focus:
			self._handle_search_action()

	def on_mount(self) -> None:
		_translate_bindings(self._merged_bindings, self._bindings)
		self._update_options(self._options)
		self.query_one(SelectionList).focus()

	def on_key(self, event: Key) -> None:
		selection_list = self.query_one(SelectionList)

		if not selection_list.has_focus or event.key != 'enter':
			return None

		if len(self._selected_items) < 1:
			index = selection_list.highlighted
			if index is not None:
				selection = selection_list.get_option_at_index(index)
				self._selected_items.append(selection.value)

		_ = self.dismiss(Result(ResultType.Selection, _item=self._selected_items))

	def on_input_changed(self, event: Input.Changed) -> None:
		search_term = event.value.lower()
		self._group.set_filter_pattern(search_term)
		filtered_options = self._get_selections()
		self._update_options(filtered_options)

	def _update_options(self, options: list[Selection[MenuItem]]) -> None:
		selection_list = self.query_one(SelectionList)
		selection_list.clear_options()
		selection_list.add_options(options)

		selection_list.highlighted = self._group.get_focused_index()

		if focus_item := self._group.focus_item:
			self._set_preview(focus_item)

		self._set_cursor()

	def on_selection_list_selection_highlighted(self, event: SelectionList.SelectionHighlighted[MenuItem]) -> None:
		if self._preview_location is not None:
			item: MenuItem = event.selection.value
			self._set_preview(item)

		self._set_cursor()

	def _set_cursor(self) -> None:
		selection_list = self.query_one(SelectionList)
		index = selection_list.highlighted

		if index is None:
			return

		target_y = sum(
			[
				1 if self._show_frame else 0,  # add top buffer for the frame
				selection_list.region.y,  # padding/margin offset of the option list
				index,  # index of the highlighted option
				-selection_list.scroll_offset.y,  # scroll offset
			]
		)

		self.app.cursor_position = Offset(selection_list.region.x, target_y)
		self.app.refresh()

	def on_selection_list_selection_toggled(self, event: SelectionList.SelectionToggled[MenuItem]) -> None:
		item: MenuItem = event.selection.value

		if item not in self._selected_items:
			self._selected_items.append(item)
		else:
			self._selected_items.remove(item)

	def _set_preview(self, item: MenuItem) -> None:
		if self._preview_location is None:
			return

		preview_widget = self.query_one('#preview_content', Label)

		if item.preview_action is not None:
			maybe_preview = item.preview_action(item)
			if maybe_preview is not None:
				preview_widget.update(maybe_preview)
				return

		preview_widget.update('')


# DEPRECATED: Removed when switching to async
class ConfirmationScreen(BaseScreen[ValueT]):
	BINDINGS: ClassVar = [
		Binding('l', 'focus_right', 'Focus right', show=False),
		Binding('h', 'focus_left', 'Focus left', show=False),
		Binding('right', 'focus_right', 'Focus right', show=True),
		Binding('left', 'focus_left', 'Focus left', show=True),
	]

	CSS = """
	ConfirmationScreen {
		align: center top;
	}

	.content-container {
		width: 1fr;
		height: 1fr;
		max-height: 100%;

		border: none;
		background: transparent;
	}

	.buttons-container {
		align: center top;
		height: 3;
		background: transparent;
	}

	Button {
		width: 4;
		height: 3;
		background: transparent;
		margin: 0 1;
	}

	Button.-active {
		background: blue;
		color: white;
		border: none;
		text-style: none;
	}
	"""

	def __init__(
		self,
		group: MenuItemGroup,
		header: str,
		allow_skip: bool = False,
		allow_reset: bool = False,
		preview_location: Literal['bottom'] | None = None,
		preview_header: str | None = None,
	):
		super().__init__(allow_skip, allow_reset)
		self._group = group
		self._header = header
		self._preview_location = preview_location
		self._preview_header = preview_header

	async def run(self) -> Result[ValueT]:
		assert TApp.app
		return await TApp.app.show(self)

	@override
	def compose(self) -> ComposeResult:
		yield Label(self._header, classes='header-text', id='header_text')

		if self._preview_location is None:
			with Vertical(classes='content-container'):
				with Horizontal(classes='buttons-container'):
					for item in self._group.items:
						yield Button(item.text, id=item.key)
		else:
			with Vertical():
				with Horizontal(classes='buttons-container'):
					for item in self._group.items:
						yield Button(item.text, id=item.key)

				yield Rule(orientation='horizontal')
				if self._preview_header is not None:
					yield Label(self._preview_header, classes='preview-header', id='preview_header')
				yield ScrollableContainer(Label('', id='preview_content', markup=False))

		yield Footer()

	def on_mount(self) -> None:
		_translate_bindings(self._merged_bindings, self._bindings)
		self._update_selection()

	def action_focus_right(self) -> None:
		if self._is_btn_focus():
			self._group.focus_next()
			self._update_selection()

	def action_focus_left(self) -> None:
		if self._is_btn_focus():
			self._group.focus_prev()
			self._update_selection()

	def _update_selection(self) -> None:
		focused = self._group.focus_item
		buttons = self.query(Button)

		if not focused:
			return

		for button in buttons:
			if button.id == focused.key:
				button.add_class('-active')
				button.focus()

				if self._preview_header is not None:
					preview = self.query_one('#preview_content', Label)

					if focused.preview_action is None:
						preview.update('')
					else:
						text = focused.preview_action(focused)
						if text is not None:
							preview.update(text)
			else:
				button.remove_class('-active')

	def _is_btn_focus(self) -> bool:
		buttons = self.query(Button)
		for button in buttons:
			if button.has_focus:
				return True

		return False

	def on_key(self, event: Key) -> None:
		if event.key == 'enter':
			if self._is_btn_focus():
				item = self._group.focus_item
				if not item:
					return None
				_ = self.dismiss(Result(ResultType.Selection, _item=item))


class NotifyScreen(ConfirmationScreen[ValueT]):
	def __init__(self, header: str):
		group = MenuItemGroup([MenuItem(tr('Ok'))])
		super().__init__(group, header)


class InputInfoType(Enum):
	MsgInfo = auto()
	MsgWarning = auto()
	MsgError = auto()


@dataclass
class InputInfo:
	message: str
	info_type: InputInfoType


class InputScreen(BaseScreen[str]):
	CSS = """
	InputScreen {
		align: center middle;
	}

	.container-wrapper {
		align: center top;
		width: 100%;
		height: 1fr;
	}

	.input-content {
		width: 60;
		height: 10;
	}

	.input-failure {
		color: red;
		text-align: center;
	}

	#input-info {
		text-align: center;
	}

	.input-hint-msg-error {
		color: red;
	}

	.input-hint-msg-warning {
		color: yellow;
	}

	.input-hint-msg-info {
		color: green;
	}
	"""

	def __init__(
		self,
		header: str | None = None,
		placeholder: str | None = None,
		password: bool = False,
		default_value: str | None = None,
		allow_reset: bool = False,
		allow_skip: bool = False,
		validator: Validator | None = None,
		info_callback: Callable[[str], InputInfo | None] | None = None,
	):
		super().__init__(allow_skip, allow_reset)
		self._header = header or ''
		self._placeholder = placeholder or ''
		self._password = password
		self._default_value = default_value or ''
		self._allow_reset = allow_reset
		self._allow_skip = allow_skip
		self._validator = validator
		self._info_callback = info_callback

	async def run(self) -> Result[str]:
		assert TApp.app
		return await TApp.app.show(self)

	@override
	def compose(self) -> ComposeResult:
		yield Label(self._header, classes='header-text', id='header_text')

		with Center(classes='container-wrapper'):
			with Vertical(classes='input-content'):
				yield Input(
					placeholder=self._placeholder,
					password=self._password,
					value=self._default_value,
					id='main_input',
					validators=self._validator,
					validate_on=['submitted'],
				)
				yield Label('', classes='input-failure', id='input-failure')
				yield Label('', id='input-info')

		yield Footer()

	def on_mount(self) -> None:
		_translate_bindings(self._merged_bindings, self._bindings)
		input_field = self.query_one('#main_input', Input)
		input_field.focus()

	def on_input_submitted(self, event: Input.Submitted) -> None:
		if event.validation_result and not event.validation_result.is_valid:
			failures = [failure.description for failure in event.validation_result.failures if failure.description]
			failure_out = ', '.join(failures)

			self.query_one('#input-failure', Label).update(failure_out)
		else:
			input_value = event.value

			if not input_value and not self._allow_skip:
				self.query_one('#input-failure', Label).update(tr('Input cannot be empty'))
				return

			_ = self.dismiss(Result(ResultType.Selection, _data=event.value))

	def on_input_changed(self, event: Input.Changed) -> None:
		info_label = self.query_one('#input-info', Label)
		if self._info_callback:
			result = self._info_callback(event.value)
			if result:
				css_class = ''
				if result.info_type == InputInfoType.MsgError:
					css_class = 'input-hint-msg-error'
				elif result.info_type == InputInfoType.MsgWarning:
					css_class = 'input-hint-msg-warning'
				elif result.info_type == InputInfoType.MsgInfo:
					css_class = 'input-hint-msg-info'
				info_label.update(result.message)
				info_label.set_classes(css_class)
			else:
				info_label.update('')
				info_label.set_classes('')


class _DataTable(DataTable[ValueT]):
	BINDINGS: ClassVar = [
		Binding('down', 'cursor_down', 'Down', show=True),
		Binding('up', 'cursor_up', 'Up', show=True),
		Binding('j', 'cursor_down', 'Down', show=False),
		Binding('k', 'cursor_up', 'Up', show=False),
		Binding('space', 'select', 'Toggle', show=True),
		Binding('enter', 'select_cursor', 'Confirm', show=True),
	]

	@override
	def on_mount(self) -> None:
		_translate_bindings(self._merged_bindings, self._bindings)


class TableSelectionScreen(BaseScreen[ValueT]):
	BINDINGS: ClassVar = [
		Binding('space', 'toggle_selection', 'Toggle', show=True),  # expclit handling of space in multi-selection mode
	]

	CSS = """
	TableSelectionScreen {
		align: center top;
		background: transparent;
	}

	.content-container {
		width: 1fr;
		height: 1fr;
		max-height: 100%;

		margin-top: 2;
		margin-bottom: 2;

		background: transparent;
	}

	.table-container {
		align: center top;
		width: 1fr;
		height: 1fr;

		background: transparent;
	}

	.table-container ScrollableContainer {
		align: center top;
		height: auto;

		background: transparent;
	}

	DataTable {
		width: auto;
		height: auto;

		padding-bottom: 2;

		border: none;
		background: transparent;
	}

	DataTable .datatable--header {
		background: transparent;
		border: solid;
	}

	LoadingIndicator {
		height: auto;
		padding-top: 2;

		background: transparent;
	}
	"""

	def __init__(
		self,
		header: str | None = None,
		group: MenuItemGroup | None = None,
		group_callback: Callable[[], Awaitable[MenuItemGroup]] | None = None,
		allow_reset: bool = False,
		allow_skip: bool = False,
		loading_header: str | None = None,
		multi: bool = False,
		preview_location: Literal['bottom'] | None = None,
		preview_header: str | None = None,
	):
		super().__init__(allow_skip, allow_reset)
		self._header = header
		self._group = group
		self._group_callback = group_callback
		self._loading_header = loading_header
		self._multi = multi
		self._preview_location = preview_location
		self._preview_header = preview_header

		self._selected_keys: set[RowKey] = set()
		self._current_row_key: RowKey | None = None

		if self._group is None and self._group_callback is None:
			raise ValueError('Either data or data_callback must be provided')

	async def run(self) -> Result[ValueT]:
		assert TApp.app
		return await TApp.app.show(self)

	@override
	def compose(self) -> ComposeResult:
		if self._header:
			yield Label(self._header, classes='header-text', id='header_text')

		with Vertical(classes='content-container'):
			if self._loading_header:
				with Center():
					yield Label(self._loading_header, classes='header', id='loading_header')

			yield LoadingIndicator(id='loader')

			if self._preview_location is None:
				with Center():
					with Vertical(classes='table-container'):
						yield ScrollableContainer(_DataTable(id='data_table'))

			else:
				with Vertical(classes='table-container'):
					yield ScrollableContainer(_DataTable(id='data_table'))
					yield Rule(orientation='horizontal')
					if self._preview_header is not None:
						yield Label(self._preview_header, classes='preview-header', id='preview-header')
					yield ScrollableContainer(Label('', id='preview_content', markup=False))

		yield Footer()

	def on_mount(self) -> None:
		_translate_bindings(self._merged_bindings, self._bindings)
		self._display_header(True)
		data_table = self.query_one(DataTable)
		data_table.cell_padding = 2

		if self._group:
			self._put_data_to_table(data_table, self._group)
		else:
			self._load_data(data_table)

	@work
	async def _load_data(self, table: DataTable[ValueT]) -> None:
		assert self._group_callback is not None
		group = await self._group_callback()
		self._put_data_to_table(table, group)

	def _display_header(self, is_loading: bool) -> None:
		if self._loading_header:
			loading_header = self.query_one('#loading_header', Label)
			loading_header.display = is_loading

		if self._header:
			header = self.query_one('#header_text', Label)
			header.display = not is_loading

	def _get_column_keys(self, items: list[MenuItem]) -> list[str]:
		all_keys: list[str] = []
		for item in items:
			if item.value:
				all_keys.extend(item.value.table_data().keys())

		# Create unique list while preserving order
		unique_keys: list[str] = list(dict.fromkeys(all_keys))

		if self._multi:
			unique_keys.insert(0, '   ')

		return unique_keys

	def _put_data_to_table(self, table: DataTable[ValueT], group: MenuItemGroup) -> None:
		items = group.items
		selected = group.selected_items

		if not items:
			_ = self.dismiss(Result(ResultType.Selection))
			return

		cols = self._get_column_keys(items)

		table.add_columns(*cols)

		for item in items:
			if not item.value:
				continue

			row_values = list(item.value.table_data().values())

			if self._multi:
				if item in selected:
					row_values.insert(0, '[X]')
				else:
					row_values.insert(0, '[ ]')

			row_key = table.add_row(*row_values, key=item)  # type: ignore[arg-type]
			if item in selected:
				self._selected_keys.add(row_key)

		table.cursor_type = 'row'
		table.display = True

		loader = self.query_one('#loader')
		loader.display = False
		self._display_header(False)
		table.focus()

	def action_toggle_selection(self) -> None:
		if not self._multi:
			return

		if not self._current_row_key:
			return

		table = self.query_one(DataTable)
		cell_key = table.coordinate_to_cell_key(table.cursor_coordinate)

		if self._current_row_key in self._selected_keys:
			self._selected_keys.remove(self._current_row_key)
			table.update_cell(self._current_row_key, cell_key.column_key, '[ ]')
		else:
			self._selected_keys.add(self._current_row_key)
			table.update_cell(self._current_row_key, cell_key.column_key, '[X]')

	def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
		self._set_cursor(event.cursor_row)

		self._current_row_key = event.row_key
		item: MenuItem = event.row_key.value  # type: ignore[assignment]

		if not item.preview_action:
			return

		preview_widget = self.query_one('#preview_content', Label)

		maybe_preview = item.preview_action(item)
		if maybe_preview is not None:
			preview_widget.update(maybe_preview)
			return

		preview_widget.update('')

	def _set_cursor(self, row_index: int) -> None:
		data_table = self.query_one(DataTable)

		target_y = sum(
			[
				data_table.region.y,  # padding/margin offset of the option list
				1,  # table header
				row_index,  # index of the highlighted row
				-data_table.scroll_offset.y,  # scroll offset
			]
		)

		debug(f'Setting cursor to target_y: {target_y}')

		self.app.cursor_position = Offset(data_table.region.x, target_y)
		self.app.refresh()

	def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
		if self._multi:
			if len(self._selected_keys) == 0:
				selection = [event.row_key.value]
			else:
				selection = [row_key.value for row_key in self._selected_keys]
		else:
			selection = event.row_key.value  # type: ignore[assignment]

		_ = self.dismiss(
			Result[ValueT](
				ResultType.Selection,
				_item=selection,  # type: ignore[arg-type]
			)
		)


class InstanceRunnable[ValueT](ABC):
	@abstractmethod
	async def run(self) -> ValueT | None:
		pass


class _AppInstance(App[ValueT]):
	ENABLE_COMMAND_PALETTE = False

	BINDINGS: ClassVar = [
		Binding('f1', 'trigger_help', 'Show/Hide help', show=True),
		Binding('ctrl+q', 'quit', 'Quit', show=True, priority=True),
	]

	CSS = """
	Screen {
		color: white;
	}

	* {
		scrollbar-size: 1 1;

		/* Use high contrast colors */
		scrollbar-color: white;
		scrollbar-background: black;
	}

	.app-header {
		dock: top;
		height: auto;
		width: 100%;
		content-align: center middle;
		background: blue;
		color: white;
		text-style: bold;
	}

	.header-text {
		text-align: center;
		width: 100%;
		height: auto;

		padding-top: 2;
		padding-bottom: 2;

		background: transparent;
	}

	.preview-header {
		text-align: center;
		color: white;
		text-style: bold;
		width: 100%;

		padding-bottom: 1;

		background: transparent;
	}

	.no-border {
		border: none;
	}

	Input {
		border: solid gray 50%;
		background: transparent;
		height: 3;
		color: white;
	}

	Input .input--cursor {
		color: white;
	}

	Input:focus {
		border: solid blue;
	}

	Footer {
		dock: bottom;
		width: 100%;
		background: transparent;
		color: white;
		height: 1;
	}

	.footer-key--key {
		background: black;
		color: white;
	}

	.footer-key--description {
		background: black;
		color: white;
		padding-right: 2;
	}

	FooterKey.-command-palette {
		background: black;
		border-left: vkey white 20%;
	}
	"""

	def __init__(self, main: InstanceRunnable[ValueT] | Callable[[], Awaitable[ValueT]]) -> None:
		super().__init__(ansi_color=True)
		self._main = main

	def action_trigger_help(self) -> None:
		from textual.widgets import HelpPanel

		if self.screen.query('HelpPanel'):
			_ = self.screen.query('HelpPanel').remove()
		else:
			_ = self.screen.mount(HelpPanel())

	def on_mount(self) -> None:
		_translate_bindings(self._merged_bindings, self._bindings)
		self._run_worker()

	@work
	async def _run_worker(self) -> None:
		try:
			if isinstance(self._main, InstanceRunnable):
				result: ValueT | None = await self._main.run()
			else:
				result = await self._main()

			tui.exit(result)
		except WorkerCancelled:
			debug('Worker was cancelled')
		except Exception as err:
			debug(f'Error while running main app: {err}')
			# this will terminate the textual app and return the exception
			self.exit(cast(ValueT, err))

	@work
	async def _show_async(self, screen: Screen[Result[ValueT]]) -> Result[ValueT]:
		return await self.push_screen_wait(screen)

	async def show(self, screen: Screen[Result[ValueT]]) -> Result[ValueT]:
		return await self._show_async(screen).wait()


class TApp:
	app: _AppInstance[Any] | None = None

	def run(self, main: InstanceRunnable[ValueT] | Callable[[], Awaitable[ValueT]]) -> ValueT:
		TApp.app = _AppInstance(main)
		result: ValueT | Exception | None = TApp.app.run()

		if isinstance(result, Exception):
			raise result

		if result is None:
			debug('App returned no result, assuming exit')
			sys.exit(0)

		return result

	def exit(self, result: Any) -> None:
		assert TApp.app
		TApp.app.exit(result)

	def translate_bindings(self) -> None:
		"""Re-translate app-level binding descriptions after language change."""
		if TApp.app is not None:
			_translate_bindings(TApp.app._merged_bindings, TApp.app._bindings)


tui = TApp()
