import curses
import re
from unicodedata import east_asian_width

from contact.ui.colors import get_color
from contact.utilities.control_utils import transform_menu_path
from typing import Any, Optional, List, Dict
from contact.utilities.singleton import interface_state, ui_state


def get_node_color(node_index: int, reverse: bool = False):
    node_num = ui_state.node_list[node_index]
    node = interface_state.interface.nodesByNum.get(node_num, {})
    if node.get("isFavorite"):
        return get_color("node_favorite", reverse=reverse)
    elif node.get("isIgnored"):
        return get_color("node_ignored", reverse=reverse)
    return get_color("settings_default", reverse=reverse)


# Aliases
Segment = tuple[str, str, bool, bool]
WrappedLine = List[Segment]

width = 80
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"]
save_option = "Save Changes"


def move_highlight(
    old_idx: int, options: List[str], menu_win: curses.window, menu_pad: curses.window, **kwargs: Any
) -> None:

    show_save_option = None
    start_index = [0]
    help_text = None
    max_help_lines = 0
    help_win = None

    if "help_win" in kwargs:
        help_win = kwargs["help_win"]

    if "menu_state" in kwargs:
        new_idx = kwargs["menu_state"].selected_index
        show_save_option = kwargs["menu_state"].show_save_option
        start_index = kwargs["menu_state"].start_index
        transformed_path = transform_menu_path(kwargs["menu_state"].menu_path)
    else:
        new_idx = kwargs["selected_index"]
        transformed_path = []

    if "help_text" in kwargs:
        help_text = kwargs["help_text"]

    if "max_help_lines" in kwargs:
        max_help_lines = kwargs["max_help_lines"]
    if old_idx == new_idx:  # No-op
        return

    max_index = len(options) + (1 if show_save_option else 0) - 1
    visible_height = menu_win.getmaxyx()[0] - 5 - (2 if show_save_option else 0)

    # Adjust menu_state.start_index only when moving out of visible range
    if new_idx == max_index and show_save_option:
        pass
    elif new_idx < start_index[-1]:  # Moving above the visible area
        start_index[-1] = new_idx
    elif new_idx >= start_index[-1] + visible_height:  # Moving below the visible area
        start_index[-1] = new_idx - visible_height

    # Ensure menu_state.start_index is within bounds
    start_index[-1] = max(0, min(start_index[-1], max_index - visible_height + 1))

    # Clear old selection
    if show_save_option and old_idx == max_index:
        menu_win.chgat(
            menu_win.getmaxyx()[0] - 2, (width - len(save_option)) // 2, len(save_option), get_color("settings_save")
        )
    else:
        menu_pad.chgat(
            old_idx,
            0,
            menu_pad.getmaxyx()[1],
            (
                get_color("settings_sensitive")
                if options[old_idx] in sensitive_settings
                else get_color("settings_default")
            ),
        )

    # Highlight new selection
    if show_save_option and new_idx == max_index:
        menu_win.chgat(
            menu_win.getmaxyx()[0] - 2,
            (width - len(save_option)) // 2,
            len(save_option),
            get_color("settings_save", reverse=True),
        )
    else:
        menu_pad.chgat(
            new_idx,
            0,
            menu_pad.getmaxyx()[1],
            (
                get_color("settings_sensitive", reverse=True)
                if options[new_idx] in sensitive_settings
                else get_color("settings_default", reverse=True)
            ),
        )

    menu_win.refresh()

    # Refresh pad only if scrolling is needed
    menu_pad.refresh(
        start_index[-1],
        0,
        menu_win.getbegyx()[0] + 3,
        menu_win.getbegyx()[1] + 4,
        menu_win.getbegyx()[0] + 3 + visible_height,
        menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4,
    )

    # Update help window only if help_text is populated
    selected_option = options[new_idx] if new_idx < len(options) else None
    help_y = menu_win.getbegyx()[0] + menu_win.getmaxyx()[0]
    if help_win:
        help_win = update_help_window(
            help_win,
            help_text,
            transformed_path,
            selected_option,
            max_help_lines,
            width,
            help_y,
            menu_win.getbegyx()[1],
        )

    draw_arrows(menu_win, visible_height, max_index, start_index, show_save_option)


def draw_arrows(
    win: object, visible_height: int, max_index: int, start_index: List[int], show_save_option: bool
) -> None:

    mi = max_index - (2 if show_save_option else 0)

    if visible_height < mi:
        if start_index[-1] > 0:
            win.addstr(3, 2, "▲", get_color("settings_default"))
        else:
            win.addstr(3, 2, " ", get_color("settings_default"))

        if mi - start_index[-1] >= visible_height + (0 if show_save_option else 1):
            win.addstr(visible_height + 3, 2, "▼", get_color("settings_default"))
        else:
            win.addstr(visible_height + 3, 2, " ", get_color("settings_default"))


def update_help_window(
    help_win: object,  # curses window or None
    help_text: Dict[str, str],
    transformed_path: List[str],
    selected_option: Optional[str],
    max_help_lines: int,
    width: int,
    help_y: int,
    help_x: int,
) -> object:  # returns a curses window
    """Handles rendering the help window consistently."""
    wrapped_help = get_wrapped_help_text(help_text, transformed_path, selected_option, width, max_help_lines)

    help_height = min(len(wrapped_help) + 2, max_help_lines + 2)  # +2 for border
    help_height = max(help_height, 3)  # Ensure at least 3 rows (1 text + border)

    # Ensure help window does not exceed screen size
    if help_y + help_height > curses.LINES:
        help_y = curses.LINES - help_height

    # Create or update the help window
    if help_win is None:
        help_win = curses.newwin(help_height, width, help_y, help_x)
    else:
        help_win.erase()
        help_win.refresh()
        help_win.resize(help_height, width)
        help_win.mvwin(help_y, help_x)

    help_win.bkgd(get_color("background"))
    help_win.attrset(get_color("window_frame"))
    help_win.border()

    for idx, line_segments in enumerate(wrapped_help):
        x_pos = 2  # Start after border
        for text, color, bold, underline in line_segments:
            try:
                attr = get_color(color, bold=bold, underline=underline)
                help_win.addstr(1 + idx, x_pos, text, attr)
                x_pos += len(text)
            except curses.error:
                pass  # Prevent crashes

    help_win.refresh()
    return help_win


def get_wrapped_help_text(
    help_text: Dict[str, str], transformed_path: List[str], selected_option: Optional[str], width: int, max_lines: int
) -> List[WrappedLine]:
    """Fetches and formats help text for display, ensuring it fits within the allowed lines."""

    full_help_key = ".".join(transformed_path + [selected_option]) if selected_option else None
    help_content = help_text.get(full_help_key, "No help available.")

    wrap_width = max(width - 6, 10)  # Ensure a valid wrapping width

    # Color replacements
    color_mappings = {
        r"\[warning\](.*?)\[/warning\]": ("settings_warning", True, False),  # Red for warnings
        r"\[note\](.*?)\[/note\]": ("settings_note", True, False),  # Green for notes
        r"\[underline\](.*?)\[/underline\]": ("settings_default", False, True),  # Underline
        r"\\033\[31m(.*?)\\033\[0m": ("settings_warning", True, False),  # Red text
        r"\\033\[32m(.*?)\\033\[0m": ("settings_note", True, False),  # Green text
        r"\\033\[4m(.*?)\\033\[0m": ("settings_default", False, True),  # Underline
    }

    def extract_ansi_segments(text: str) -> List[Segment]:
        """Extracts and replaces ANSI color codes, ensuring spaces are preserved."""
        matches = []
        last_pos = 0
        pattern_matches = []

        # Find all matches and store their positions
        for pattern, (color, bold, underline) in color_mappings.items():
            for match in re.finditer(pattern, text):
                pattern_matches.append((match.start(), match.end(), match.group(1), color, bold, underline))

        # Sort matches by start position to process sequentially
        pattern_matches.sort(key=lambda x: x[0])

        for start, end, content, color, bold, underline in pattern_matches:
            # Preserve non-matching text including spaces
            if last_pos < start:
                segment = text[last_pos:start]
                matches.append((segment, "settings_default", False, False))

            # Append the colored segment
            matches.append((content, color, bold, underline))
            last_pos = end

        # Preserve any trailing text
        if last_pos < len(text):
            matches.append((text[last_pos:], "settings_default", False, False))

        return matches

    def wrap_ansi_text(segments: List[Segment], wrap_width: int) -> List[WrappedLine]:
        """Wraps text while preserving ANSI formatting and spaces."""
        wrapped_lines = []
        line_buffer = []
        line_length = 0

        for text, color, bold, underline in segments:
            words = re.findall(r"\S+|\s+", text)  # Capture words and spaces separately

            for word in words:
                word_length = len(word)

                if line_length + word_length > wrap_width and word.strip():
                    # If the word (ignoring spaces) exceeds width, wrap the line
                    wrapped_lines.append(line_buffer)
                    line_buffer = []
                    line_length = 0

                line_buffer.append((word, color, bold, underline))
                line_length += word_length

        if line_buffer:
            wrapped_lines.append(line_buffer)

        return wrapped_lines

    raw_lines = help_content.split("\\n")  # Preserve new lines
    wrapped_help = []

    for raw_line in raw_lines:
        color_segments = extract_ansi_segments(raw_line)
        wrapped_segments = wrap_ansi_text(color_segments, wrap_width)
        wrapped_help.extend(wrapped_segments)
        pass

    # Trim and add ellipsis if needed
    if len(wrapped_help) > max_lines:
        wrapped_help = wrapped_help[:max_lines]
        wrapped_help[-1].append(("...", "settings_default", False, False))

    return wrapped_help

def text_width(text: str) -> int:
    return sum(2 if east_asian_width(c) in "FW" else 1 for c in text)

def wrap_text(text: str, wrap_width: int) -> List[str]:
    """Wraps text while preserving spaces and breaking long words."""

    whitespace = '\t\n\x0b\x0c\r '
    whitespace_trans = dict.fromkeys(map(ord, whitespace), ord(' '))
    text = text.translate(whitespace_trans)

    words = re.findall(r"\S+|\s+", text)  # Capture words and spaces separately
    wrapped_lines = []
    line_buffer = ""
    line_length = 0
    margin = 2  # Left and right margin
    wrap_width -= margin

    for word in words:
        word_length = text_width(word)

        if word_length > wrap_width:  # Break long words
            if line_buffer:
                wrapped_lines.append(line_buffer.strip())
                line_buffer = ""
                line_length = 0
            for i in range(0, word_length, wrap_width):
                wrapped_lines.append(word[i : i + wrap_width])
            continue

        if line_length + word_length > wrap_width and word.strip():
            wrapped_lines.append(line_buffer.strip())
            line_buffer = ""
            line_length = 0

        line_buffer += word
        line_length += word_length

    if line_buffer:
        wrapped_lines.append(line_buffer.strip())

    return wrapped_lines


def move_main_highlight(
    old_idx: int, new_idx, options: List[str], menu_win: curses.window, menu_pad: curses.window, ui_state: object
) -> None:

    if old_idx == new_idx:  # No-op
        return

    max_index = len(options) - 1
    visible_height = menu_win.getmaxyx()[0] - 2

    if new_idx < ui_state.start_index[ui_state.current_window]:  # Moving above the visible area
        ui_state.start_index[ui_state.current_window] = new_idx
    elif new_idx >= ui_state.start_index[ui_state.current_window] + visible_height:  # Moving below the visible area
        ui_state.start_index[ui_state.current_window] = new_idx - visible_height + 1

    # Ensure start_index is within bounds
    ui_state.start_index[ui_state.current_window] = max(
        0, min(ui_state.start_index[ui_state.current_window], max_index - visible_height + 1)
    )

    highlight_line(menu_win, menu_pad, old_idx, new_idx, visible_height)

    if ui_state.current_window == 0:  # hack to fix max_index
        max_index += 1

    draw_main_arrows(menu_win, max_index, window=ui_state.current_window)
    menu_win.refresh()


def highlight_line(
    menu_win: curses.window, menu_pad: curses.window, old_idx: int, new_idx: int, visible_height: int
) -> None:

    if ui_state.current_window == 0:
        color_old = (
            get_color("channel_selected") if old_idx == ui_state.selected_channel else get_color("channel_list")
        )
        color_new = get_color("channel_list", reverse=True) if True else get_color("channel_list", reverse=True)
        menu_pad.chgat(old_idx, 1, menu_pad.getmaxyx()[1] - 4, color_old)
        menu_pad.chgat(new_idx, 1, menu_pad.getmaxyx()[1] - 4, color_new)

    elif ui_state.current_window == 2:
        menu_pad.chgat(old_idx, 1, menu_pad.getmaxyx()[1] - 4, get_node_color(old_idx))
        menu_pad.chgat(new_idx, 1, menu_pad.getmaxyx()[1] - 4, get_node_color(new_idx, reverse=True))

    menu_win.refresh()

    # Refresh pad only if scrolling is needed
    menu_pad.refresh(
        ui_state.start_index[ui_state.current_window],
        0,
        menu_win.getbegyx()[0] + 1,
        menu_win.getbegyx()[1] + 1,
        menu_win.getbegyx()[0] + visible_height,
        menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 3,
    )


def draw_main_arrows(win: object, max_index: int, window: int, **kwargs) -> None:

    height, width = win.getmaxyx()
    usable_height = height - 2
    usable_width = width - 2

    if window == 1 and ui_state.display_log:
        if log_height := kwargs.get("log_height"):
            usable_height -= log_height - 1

    if usable_height < max_index:
        if ui_state.start_index[window] > 0:
            win.addstr(1, usable_width, "▲", get_color("settings_default"))
        else:
            win.addstr(1, usable_width, " ", get_color("settings_default"))

        if max_index - ui_state.start_index[window] - 1 >= usable_height:
            win.addstr(usable_height, usable_width, "▼", get_color("settings_default"))
        else:
            win.addstr(usable_height, usable_width, " ", get_color("settings_default"))
    else:
        win.addstr(1, usable_width, " ", get_color("settings_default"))
        win.addstr(usable_height, usable_width, " ", get_color("settings_default"))


def get_msg_window_lines(messages_win, packetlog_win) -> None:
    packetlog_height = packetlog_win.getmaxyx()[0] - 1 if ui_state.display_log else 0
    return messages_win.getmaxyx()[0] - 2 - packetlog_height
