#!/usr/bin/env python3
# -*- coding: utf-8 -*-

# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.

import difflib
import os
import re
import time

from fenrirscreenreader.core import debug
from fenrirscreenreader.utils import screen_utils


class ScreenManager:
    def __init__(self):
        self.differ = difflib.Differ()
        self.currScreenIgnored = False
        self.prevScreenIgnored = False
        self.prevScreenText = ""
        self.currScreenText = ""
        self.colums = None
        self.rows = None
        # Compile regex once for better performance
        self._space_normalize_regex = re.compile(" +")

    def get_rows(self):
        return self.rows

    def get_columns(self):
        return self.colums

    def initialize(self, environment):
        self.env = environment
        self.env["runtime"]["SettingsManager"].load_driver(
            self.env["runtime"]["SettingsManager"].get_setting(
                "screen", "driver"
            ),
            "ScreenDriver",
        )
        self.get_curr_screen()
        self.get_curr_screen()
        self.get_session_information()
        self.update_screen_ignored()
        self.update_screen_ignored()

    def reset_screen_text(self, screen_text):
        self.prevScreenText = ""
        self.currScreenText = screen_text

    def set_screen_text(self, screen_text):
        self.prevScreenText = self.currScreenText
        self.currScreenText = screen_text

    def get_screen_text(self):
        return self.currScreenText
    
    def get_line_text(self, line_number):
        """Get text from a specific line (0-based index)"""
        if not self.env["screen"]["new_content_text"]:
            return ""
        
        lines = self.env["screen"]["new_content_text"].split("\n")
        if line_number < 0 or line_number >= len(lines):
            return ""
        
        return lines[line_number]

    def get_curr_screen(self):
        try:
            self.env["runtime"]["ScreenDriver"].get_curr_screen()
        except Exception as e:
            self.env["runtime"]["DebugManager"].write_debug_out(
                "ScreenManager get_curr_screen: Error getting current screen: "
                + str(e),
                debug.DebugLevel.ERROR,
            )

    def get_session_information(self):
        try:
            self.env["runtime"]["ScreenDriver"].get_session_information()
        except Exception as e:
            self.env["runtime"]["DebugManager"].write_debug_out(
                "ScreenManager get_session_information: Error getting session info: "
                + str(e),
                debug.DebugLevel.ERROR,
            )

    def shutdown(self):
        self.env["runtime"]["SettingsManager"].shutdown_driver("ScreenDriver")

    def is_curr_screen_ignored_changed(self):
        return self.get_curr_screen_ignored() != self.get_prev_screen_ignored()

    def handle_screen_change(self, event_data):
        self.get_curr_screen()
        self.get_session_information()
        self.update_screen_ignored()
        if self.is_curr_screen_ignored_changed():
            self.env["runtime"]["InputManager"].set_execute_device_grab()
        self.env["runtime"]["InputManager"].handle_device_grab()
        
        # Update current application detection on screen change
        try:
            self.env["runtime"]["ScreenDriver"].get_curr_application()
        except Exception as e:
            self.env["runtime"]["DebugManager"].write_debug_out(
                f"Application detection failed: {str(e)}", debug.DebugLevel.ERROR
            )
            
        if not self.is_ignored_screen(self.env["screen"]["newTTY"]):
            self.update(event_data, "onScreenChange")
            self.env["screen"]["lastScreenUpdate"] = time.time()
        else:
            self.env["runtime"]["OutputManager"].interrupt_output()

    def handle_screen_update(self, event_data):
        self.env["screen"]["oldApplication"] = self.env["screen"][
            "new_application"
        ]
        self.update_screen_ignored()
        if self.is_curr_screen_ignored_changed():
            self.env["runtime"]["InputManager"].set_execute_device_grab()
        self.env["runtime"]["InputManager"].handle_device_grab()
        if not self.get_curr_screen_ignored():
            self.update(event_data, "onScreenUpdate")
            self.env["screen"]["lastScreenUpdate"] = time.time()
        elif self.is_curr_screen_ignored_changed():
            self.env["runtime"]["OutputManager"].interrupt_output()

    def get_curr_screen_ignored(self):
        return self.currScreenIgnored

    def get_prev_screen_ignored(self):
        return self.prevScreenIgnored

    def update_screen_ignored(self):
        self.prevScreenIgnored = self.currScreenIgnored
        self.currScreenIgnored = self.is_ignored_screen(
            self.env["screen"]["newTTY"]
        )

    def update(self, event_data, trigger="onUpdate"):
        # set new "old" values
        self.env["screen"]["oldContentBytes"] = self.env["screen"][
            "newContentBytes"
        ]
        self.env["screen"]["old_content_text"] = self.env["screen"][
            "new_content_text"
        ]
        self.env["screen"]["old_cursor"] = self.env["screen"][
            "new_cursor"
        ].copy()
        self.env["screen"]["oldDelta"] = self.env["screen"]["new_delta"]
        self.env["screen"]["oldNegativeDelta"] = self.env["screen"][
            "newNegativeDelta"
        ]
        self.env["screen"]["newContentBytes"] = event_data["bytes"]

        # get metadata like cursor or screensize
        self.env["screen"]["lines"] = int(event_data["lines"])
        self.env["screen"]["columns"] = int(event_data["columns"])
        self.colums = int(event_data["columns"])
        self.rows = int(event_data["lines"])
        self.env["screen"]["new_cursor"]["x"] = int(
            event_data["textCursor"]["x"]
        )
        self.env["screen"]["new_cursor"]["y"] = int(
            event_data["textCursor"]["y"]
        )
        self.env["screen"]["newTTY"] = event_data["screen"]
        self.env["screen"]["new_content_text"] = event_data["text"]


        # screen change
        if self.is_screen_change():
            self.env["screen"]["oldContentBytes"] = b""
            self.reset_screen_text(event_data["text"])
            self.env["runtime"]["AttributeManager"].reset_attributes(
                event_data["attributes"]
            )
            self.env["runtime"]["AttributeManager"].reset_attribute_cursor()
            self.env["screen"]["old_content_text"] = ""
            self.env["screen"]["old_cursor"]["x"] = 0
            self.env["screen"]["old_cursor"]["y"] = 0
            self.env["screen"]["oldDelta"] = ""
            self.env["screen"]["oldNegativeDelta"] = ""
        else:
            self.set_screen_text(event_data["text"])
            self.env["runtime"]["AttributeManager"].set_attributes(
                event_data["attributes"]
            )
        # initialize current deltas
        self.env["screen"]["newNegativeDelta"] = ""
        self.env["screen"]["new_delta"] = ""
        self.env["screen"]["new_delta_is_typing"] = False
        self.env["runtime"]["AttributeManager"].reset_attribute_delta()

        # Diff generation - critical for screen reader functionality
        # This code detects and categorizes screen content changes to provide appropriate
        # speech feedback (typing echo vs incoming text vs screen updates)

        # Track whether this appears to be typing (user input) vs other screen
        # changes
        typing = False
        diff_list = []

        if (
            self.env["screen"]["old_content_text"]
            != self.env["screen"]["new_content_text"]
        ):
            # Special case: Initial screen content (going from empty to populated)
            # This handles first screen load or TTY switch scenarios
            if (
                self.env["screen"]["new_content_text"] != ""
                and self.env["screen"]["old_content_text"] == ""
            ):
                # Pre-process screen text for comparison - collapse multiple spaces to single space
                # This normalization prevents spurious diffs from spacing
                # inconsistencies
                old_screen_text = self._space_normalize_regex.sub(
                    " ",
                    self.env["runtime"][
                        "ScreenManager"
                    ].get_window_area_in_text(
                        self.env["screen"]["old_content_text"]
                    ),
                )
                new_screen_text = self._space_normalize_regex.sub(
                    " ",
                    self.env["runtime"][
                        "ScreenManager"
                    ].get_window_area_in_text(
                        self.env["screen"]["new_content_text"]
                    ),
                )
                if old_screen_text == "" and new_screen_text != "":
                    self.env["screen"]["new_delta"] = new_screen_text
            else:
                # Calculate byte positions for the current cursor's line in the flat text buffer
                # Formula: (line_number * columns) + line_number accounts for newlines
                # Each line contributes 'columns' chars + 1 newline char
                cursor_line_start = (
                    self.env["screen"]["new_cursor"]["y"]
                    * self.env["screen"]["columns"]
                    + self.env["screen"]["new_cursor"]["y"]
                )
                cursor_line_end = (
                    cursor_line_start + self.env["screen"]["columns"]
                )
                cursor_moved_horizontally = (
                    abs(
                        self.env["screen"]["old_cursor"]["x"]
                        - self.env["screen"]["new_cursor"]["x"]
                    )
                    >= 1
                    and self.env["screen"]["old_cursor"]["y"]
                    == self.env["screen"]["new_cursor"]["y"]
                )
                cursor_line_typing_delta = (
                    self._get_cursor_line_typing_delta()
                )
                cursor_line_is_typing = (
                    cursor_line_typing_delta is not None
                    and (
                        cursor_moved_horizontally
                        or self._is_recent_input()
                    )
                )

                # TYPING DETECTION ALGORITHM
                # Determines if this screen change is likely user typing vs other content changes
                # All conditions must be met for typing detection:
                if (
                    cursor_line_is_typing
                    and cursor_line_typing_delta["changed_lines"]
                    == [cursor_line_typing_delta["cursor_y"]]
                ):
                    diff_list = [
                        "+ " + cursor_line_typing_delta["text"]
                    ]
                    typing = True
                elif (
                    cursor_moved_horizontally
                    and self.env["screen"]["new_content_text"][
                        :cursor_line_start
                    ]
                    == self.env["screen"]["old_content_text"][
                        :cursor_line_start
                    ]
                    and self.env["screen"]["new_content_text"][
                        cursor_line_end:
                    ]
                    == self.env["screen"]["old_content_text"][cursor_line_end:]
                ):
                    # Condition 1: Cursor moved horizontally by at least 1 position (typical of typing)
                    # Condition 2: Cursor stayed on same line (typing doesn't usually change lines)
                    # Condition 3: Content BEFORE cursor line is unchanged (text above typing area)
                    # Condition 4: Content AFTER cursor line is unchanged (text below typing area)
                    # Together: only the current line changed, cursor moved
                    # horizontally = likely typing

                    # Optimize diff calculation for typing by focusing on a
                    # small window around cursor
                    cursor_line_start_offset = cursor_line_start
                    cursor_line_end_offset = cursor_line_end

                    # Limit analysis window to avoid processing entire long lines
                    # +3 provides safety buffer beyond cursor position to catch edge cases
                    if (
                        cursor_line_end
                        > cursor_line_start
                        + self.env["screen"]["new_cursor"]["x"]
                        + 3
                    ):
                        cursor_line_end_offset = (
                            cursor_line_start
                            + self.env["screen"]["new_cursor"]["x"]
                            + 3
                        )

                    # Extract just the relevant text sections for
                    # character-level diff
                    old_screen_text = self.env["screen"]["old_content_text"][
                        cursor_line_start_offset:cursor_line_end_offset
                    ]
                    new_screen_text = self.env["screen"]["new_content_text"][
                        cursor_line_start_offset:cursor_line_end_offset
                    ]

                    # Character-level diff for typing detection (not
                    # line-level)
                    diff = self.differ.compare(
                        old_screen_text, new_screen_text
                    )
                    diff_list = list(diff)
                    typing = True

                    # Validate typing assumption by checking if detected
                    # changes match cursor movement
                    temp_new_delta = "".join(
                        x[2:] for x in diff_list if x[0] == "+"
                    )
                    if temp_new_delta.strip() != "":
                        # Compare diff result against expected typing pattern
                        # Expected: characters between old and new cursor
                        # positions
                        expected_typing = "".join(
                            new_screen_text[
                                self.env["screen"]["old_cursor"][
                                    "x"
                                ] : self.env["screen"]["new_cursor"]["x"]
                            ].rstrip()
                        )

                        # Prompt/status repaints can add extra diff text while
                        # the user's typed characters are still at the cursor.
                        # Keep those as typing so incoming speech does not
                        # announce the repainted prompt line.
                        if temp_new_delta != expected_typing:
                            if expected_typing.strip() != "":
                                diff_list = ["+ " + expected_typing]
                            else:
                                # Fallback: treat entire current line as new
                                # content
                                current_line = self.env["screen"][
                                    "new_content_text"
                                ].split("\n")[
                                    self.env["screen"]["new_cursor"]["y"]
                                ]
                                diff_list = [
                                    "+ " + current_line + "\n"
                                ]
                                typing = False
                else:
                    # GENERAL SCREEN CHANGE DETECTION
                    # Not typing - handle as line-by-line content change
                    # This catches: incoming messages, screen updates,
                    # application output, etc.
                    typing_delta = (
                        cursor_line_typing_delta
                        if cursor_line_is_typing
                        else None
                    )
                    if (
                        typing_delta is not None
                        and typing_delta["changed_lines"]
                        == [typing_delta["cursor_y"]]
                    ):
                        diff_list = ["+ " + typing_delta["text"]]
                        typing = True
                    else:
                        old_content_text = self.env["screen"][
                            "old_content_text"
                        ]
                        new_content_text = self.env["screen"][
                            "new_content_text"
                        ]
                        if typing_delta is not None:
                            old_lines = old_content_text.split("\n")
                            new_lines = new_content_text.split("\n")
                            new_lines[typing_delta["cursor_y"]] = old_lines[
                                typing_delta["cursor_y"]
                            ]
                            old_content_text = "\n".join(old_lines)
                            new_content_text = "\n".join(new_lines)

                        # Pre-process screen text for comparison - collapse multiple spaces to single space
                        # This normalization prevents spurious diffs from spacing
                        # inconsistencies
                        old_screen_text = self._space_normalize_regex.sub(
                            " ",
                            self.env["runtime"][
                                "ScreenManager"
                            ].get_window_area_in_text(old_content_text),
                        )
                        new_screen_text = self._space_normalize_regex.sub(
                            " ",
                            self.env["runtime"][
                                "ScreenManager"
                            ].get_window_area_in_text(new_content_text),
                        )

                        diff = self.differ.compare(
                            old_screen_text.split("\n"),
                            new_screen_text.split("\n"),
                        )
                        diff_list = list(diff)

                # Extract added and removed content from diff results
                # Output format depends on whether this was detected as typing
                # or general change
                if not typing:
                    # Line-based changes: join with newlines for proper speech
                    # cadence
                    self.env["screen"]["new_delta"] = "\n".join(
                        x[2:] for x in diff_list if x[0] == "+"
                    )
                else:
                    # Character-based changes: no newlines for smooth typing
                    # echo
                    self.env["screen"]["new_delta"] = "".join(
                        x[2:] for x in diff_list if x[0] == "+"
                    )
                self.env["screen"]["new_delta_is_typing"] = typing

                # Negative delta (removed content) - used for backspace/delete
                # detection
                self.env["screen"]["newNegativeDelta"] = "".join(
                    x[2:] for x in diff_list if x[0] == "-"
                )

        # track highlighted
        try:
            if self.env["runtime"]["AttributeManager"].is_attribute_change():
                if self.env["runtime"]["SettingsManager"].get_setting_as_bool(
                    "focus", "highlight"
                ):
                    attribute_delta, attributeCursor = self.env["runtime"][
                        "AttributeManager"
                    ].track_highlights()
                    if attributeCursor:
                        self.env["runtime"][
                            "AttributeManager"
                        ].set_attribute_cursor(attributeCursor)
                        self.env["runtime"][
                            "AttributeManager"
                        ].set_attribute_delta(attribute_delta)
        except Exception as e:
            self.env["runtime"]["DebugManager"].write_debug_out(
                "ScreenManager:update:highlight: " + str(e),
                debug.DebugLevel.ERROR,
            )

    def is_ignored_screen(self, screen=None):
        if screen is None:
            screen = self.env["screen"]["newTTY"]
        # Check if force all screens flag is set
        if self.env["runtime"].get("force_all_screens", False):
            return False
        ignore_screens = []
        fix_ignore_screens = self.env["runtime"][
            "SettingsManager"
        ].get_setting("screen", "ignore_screen")
        if fix_ignore_screens != "":
            ignore_screens.extend(fix_ignore_screens.split(","))
        if self.env["runtime"]["SettingsManager"].get_setting_as_bool(
            "screen", "autodetect_ignore_screen"
        ):
            ignore_screens.extend(self.env["screen"]["autoIgnoreScreens"])
        self.env["runtime"]["DebugManager"].write_debug_out(
            "ScreenManager:is_ignored_screen ignore:"
            + str(ignore_screens)
            + " current:"
            + str(screen),
            debug.DebugLevel.INFO,
        )
        return screen in ignore_screens

    def _is_recent_input(self):
        try:
            return (
                time.time()
                - self.env["runtime"][
                    "InputManager"
                ].get_last_input_time()
                <= 0.3
            )
        except Exception:
            return False

    def _get_cursor_line_typing_delta(self):
        try:
            old_lines = self.env["screen"]["old_content_text"].split("\n")
            new_lines = self.env["screen"]["new_content_text"].split("\n")
            cursor_y = self.env["screen"]["new_cursor"]["y"]
            if (
                len(old_lines) != len(new_lines)
                or cursor_y < 0
                or cursor_y >= len(new_lines)
            ):
                return None

            changed_lines = [
                index
                for index, old_line in enumerate(old_lines)
                if index >= len(new_lines) or old_line != new_lines[index]
            ]
            if cursor_y not in changed_lines:
                return None

            old_line = old_lines[cursor_y].rstrip()
            new_line = new_lines[cursor_y].rstrip()
            if old_line == new_line:
                return None

            matcher = difflib.SequenceMatcher(
                None,
                old_line,
                new_line,
                autojunk=False,
            )
            inserted_parts = []
            for tag, i1, i2, j1, j2 in matcher.get_opcodes():
                if tag == "equal":
                    continue
                if tag == "insert":
                    inserted_parts.append(new_line[j1:j2])
                    continue
                if tag == "replace" and old_line[i1:i2].strip() == "":
                    inserted_parts.append(new_line[j1:j2])
                    continue
                return None

            typed_text = "".join(inserted_parts).strip()
            if typed_text == "" or len(typed_text) > 4:
                return None
            return {
                "text": typed_text,
                "cursor_y": cursor_y,
                "changed_lines": changed_lines,
            }
        except Exception:
            return None

    def is_screen_change(self):
        if not self.env["screen"]["oldTTY"]:
            return False
        return self.env["screen"]["newTTY"] != self.env["screen"]["oldTTY"]

    def is_delta(self, ignoreSpace=False):
        new_delta = self.env["screen"]["new_delta"]
        if ignoreSpace:
            new_delta = new_delta.strip()
        return new_delta != ""

    def is_negative_delta(self):
        return self.env["screen"]["newNegativeDelta"] != ""

    def get_window_area_in_text(self, text):
        if not self.env["runtime"][
            "CursorManager"
        ].is_application_window_set():
            return text
        window_text = ""
        window_list = text.split("\n")
        curr_app = self.env["runtime"][
            "ApplicationManager"
        ].get_current_application()
        window_list = window_list[
            self.env["commandBuffer"]["windowArea"][curr_app]["1"][
                "y"
            ] : self.env["commandBuffer"]["windowArea"][curr_app]["2"]["y"]
            + 1
        ]
        for line in window_list:
            window_text += (
                line[
                    self.env["commandBuffer"]["windowArea"][curr_app]["1"][
                        "x"
                    ] : self.env["commandBuffer"]["windowArea"][curr_app]["2"][
                        "x"
                    ]
                    + 1
                ]
                + "\n"
            )
        return window_text

    def inject_text_to_screen(self, text, screen=None):
        try:
            self.env["runtime"]["ScreenDriver"].inject_text_to_screen(
                text, screen
            )
        except Exception as e:
            self.env["runtime"]["DebugManager"].write_debug_out(
                "ScreenManager:inject_text_to_screen " + str(e),
                debug.DebugLevel.ERROR,
            )
