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

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

import fcntl
import getpass
import os
import pty
import shlex
from queue import Full
import signal
import struct
import sys
import termios
import threading
import time
import tty
from select import select

import pyte

from fenrirscreenreader.core import debug
from fenrirscreenreader.core.eventData import FenrirEventType
from fenrirscreenreader.core.screenDriver import ScreenDriver as screenDriver
from fenrirscreenreader.utils import screen_utils

# PTY Driver Constants
class PTYConstants:
    # Timeouts (in seconds)
    DEFAULT_READ_TIMEOUT = 0.3
    INPUT_READ_TIMEOUT = 0.01
    OUTPUT_READ_TIMEOUT = 0.05  # Faster than default but allows for network lag
    SELECT_TIMEOUT = 0.05
    PROCESS_TERMINATION_TIMEOUT = 3.0
    PROCESS_KILL_DELAY = 0.5
    
    # Polling intervals (in seconds)  
    MIN_POLL_INTERVAL = 0.001
    
    # Limits
    MAX_TERMINAL_LINES = 10000
    DEFAULT_READ_BUFFER_SIZE = 65536
    INPUT_BUFFER_SIZE = 4096
    
    # Error codes
    IO_ERROR_ERRNO = 5


class FenrirScreen(pyte.Screen):
    def set_margins(self, *args, **kwargs):
        kwargs.pop("private", None)
        super(FenrirScreen, self).set_margins(*args, **kwargs)

    def select_graphic_rendition(self, *args, **kwargs):
        kwargs.pop("private", None)
        super(FenrirScreen, self).select_graphic_rendition(*args, **kwargs)


class Terminal:
    def __init__(self, columns, lines, p_in, env=None):
        self.text = ""
        self.attributes = None
        self.screen = FenrirScreen(columns, lines)
        self.env = env  # Environment for proper logging
        
        # Pre-create default attribute template to avoid repeated allocation
        self._default_attribute = [
            "default", "default", False, False, False, False, False, False,
            "default", "default"
        ]
        self.screen.write_process_input = lambda data: p_in.write(
            data.encode()
        )
        self.stream = pyte.ByteStream()
        self.stream.attach(self.screen)

    def _log_error(self, message, level=None):
        """Log error message using proper debug manager if available."""
        if self.env and "runtime" in self.env and "DebugManager" in self.env["runtime"]:
            try:
                log_level = level if level else debug.DebugLevel.ERROR
                self.env["runtime"]["DebugManager"].write_debug_out(
                    f"PTY Terminal: {message}",
                    log_level
                )
                return
            except Exception:
                pass  # Fallback to print if debug manager fails
        
        # Fallback logging when debug manager unavailable
        print(f"PTY Terminal: {message}")

    def feed(self, data):
        self.stream.feed(data)

    def update_attributes(self, initialize=False):
        buffer = self.screen.buffer
        lines = None
        if not initialize:
            lines = self.screen.dirty
        else:
            lines = range(self.screen.lines)
            try:
                self.attributes = [
                    [
                        list(attribute[1:]) + [False, "default", "default"]
                        if len(attribute) > 1 else [False, "default", "default"]
                        for attribute in line.values()
                    ]
                    for line in buffer.values()
                ]
            except Exception as e:
                self._log_error(f"Error initializing attributes: {e}")
                # Fallback to empty attributes
                self.attributes = [[] for _ in range(self.screen.lines)]
        for y in lines:
            # Validate y is within reasonable bounds (prevent memory exhaustion)
            if y >= PTYConstants.MAX_TERMINAL_LINES:
                self._log_error(
                    f"Line index {y} exceeds maximum {PTYConstants.MAX_TERMINAL_LINES}, "
                    f"skipping attribute update",
                    debug.DebugLevel.WARNING
                )
                continue
            
            # Check if line y exists in buffer before accessing it
            if y not in buffer:
                # Only log this occasionally to prevent spam
                if y % 10 == 0:  # Log every 10th missing line
                    # Pre-format string to avoid repeated f-string operations
                    line_range = f"{y}-{y+9}"
                    self._log_error(
                        f"Lines {line_range} not found in buffer, skipping attribute updates",
                        debug.DebugLevel.WARNING
                    )
                continue
                
            # Ensure attributes array is large enough for line y
            while len(self.attributes) <= y:
                self.attributes.append([])
                
            try:
                self.attributes[y] = [
                    list(attribute[1:]) + [False, "default", "default"]
                    for attribute in (buffer[y].values())
                ]
            except Exception as e:
                self._log_error(f"Error updating attributes for line {y}: {e}")
                # Initialize with empty attributes if update fails
                self.attributes[y] = []
            if len(self.attributes[y]) < self.screen.columns:
                diff = self.screen.columns - len(self.attributes[y])
                # Use pre-created template for efficiency
                self.attributes[y] += [self._default_attribute[:] for _ in range(diff)]

    def resize(self, lines, columns):
        self.screen.resize(lines, columns)
        self.set_cursor()
        self.update_attributes(True)

    def set_cursor(self, x=-1, y=-1):
        # Determine target cursor position
        x_pos = x if x != -1 else self.screen.cursor.x
        y_pos = y if y != -1 else self.screen.cursor.y
        
        # Validate and clamp cursor position to screen bounds
        max_x = max(0, self.screen.columns - 1)
        max_y = max(0, self.screen.lines - 1)
        
        self.screen.cursor.x = max(0, min(x_pos, max_x))
        self.screen.cursor.y = max(0, min(y_pos, max_y))

    def get_screen_content(self):
        cursor = self.screen.cursor
        # Only regenerate text if screen is dirty or text doesn't exist
        if not hasattr(self, 'text') or self.screen.dirty:
            self.text = "\n".join(self.screen.display)
        self.update_attributes(self.attributes is None)
        self.screen.dirty.clear()
        
        # Return screen content without unnecessary copying
        # Only copy attributes if they exist and need protection
        screen_data = {
            "cursor": (cursor.x, cursor.y),
            "lines": self.screen.lines,
            "columns": self.screen.columns,
            "text": self.text,
            "attributes": self.attributes[:] if self.attributes else [],  # Shallow copy only if needed
            "screen": "pty",
            "screenUpdateTime": time.time(),
        }
        return screen_data


class driver(screenDriver):
    def __init__(self):
        screenDriver.__init__(self)
        self.signalPipe = os.pipe()
        self.p_out = None
        self.terminal = None
        self.p_pid = -1
        self.terminal_lock = threading.Lock()  # Synchronize terminal operations
        self.stdin_interrupt_lock = threading.Lock()
        self.stdin_interrupt_running = False
        self.stdin_interrupt_thread = None
        signal.signal(signal.SIGWINCH, self.handle_sigwinch)
        
        # Runtime configuration storage
        self.pty_config = {}

    def _load_pty_settings(self):
        """Load PTY-specific settings from configuration with fallbacks to defaults."""
        try:
            settings_manager = self.env["runtime"]["SettingsManager"]
            
            self.pty_config = {
                'input_timeout': self._get_optional_float_setting(
                    settings_manager, 'screen', 'ptyInputTimeout',
                    PTYConstants.INPUT_READ_TIMEOUT
                ),
                'output_timeout': self._get_optional_float_setting(
                    settings_manager, 'screen', 'ptyOutputTimeout',
                    PTYConstants.OUTPUT_READ_TIMEOUT
                ),
                'select_timeout': self._get_optional_float_setting(
                    settings_manager, 'screen', 'ptySelectTimeout',
                    PTYConstants.SELECT_TIMEOUT
                ),
                'process_termination_timeout': self._get_optional_float_setting(
                    settings_manager, 'screen', 'ptyProcessTimeout',
                    PTYConstants.PROCESS_TERMINATION_TIMEOUT
                ),
                'poll_interval': self._get_optional_float_setting(
                    settings_manager, 'screen', 'ptyPollInterval',
                    PTYConstants.MIN_POLL_INTERVAL
                )
            }
            
            self.env["runtime"]["DebugManager"].write_debug_out(
                f"PTY screenDriver: Loaded configuration: {self.pty_config}",
                debug.DebugLevel.INFO
            )
            
        except Exception as e:
            # Fallback to constants if settings fail
            self.env["runtime"]["DebugManager"].write_debug_out(
                f"PTY screenDriver: Failed to load settings, using defaults: {e}",
                debug.DebugLevel.WARNING
            )
            self.pty_config = {
                'input_timeout': PTYConstants.INPUT_READ_TIMEOUT,
                'output_timeout': PTYConstants.OUTPUT_READ_TIMEOUT,
                'select_timeout': PTYConstants.SELECT_TIMEOUT,
                'process_termination_timeout': PTYConstants.PROCESS_TERMINATION_TIMEOUT,
                'poll_interval': PTYConstants.MIN_POLL_INTERVAL
            }

    def _get_optional_float_setting(
        self, settings_manager, section, setting, default
    ):
        try:
            value = settings_manager.get_setting(section, setting)
            if value == "":
                return default
            return float(value)
        except Exception:
            return default

    def initialize(self, environment):
        self.env = environment
        self.command = self.env["runtime"]["SettingsManager"].get_setting(
            "general", "shell"
        )
        
        # Load configurable timeouts from settings
        self._load_pty_settings()
        
        self.env["runtime"]["ProcessManager"].add_custom_event_thread(
            self.terminal_emulation
        )

    def get_curr_screen(self):
        self.env["screen"]["oldTTY"] = "pty"
        self.env["screen"]["newTTY"] = "pty"

    def inject_text_to_screen(self, msg_bytes, screen=None):
        if not screen:
            screen = self.p_out.fileno()
        if isinstance(msg_bytes, str):
            msg_bytes = bytes(msg_bytes, "UTF-8")
        os.write(screen, msg_bytes)

    def interrupt_output_on_stdin_input(self, msg_bytes):
        if not msg_bytes:
            return
        settings_manager = self.env["runtime"]["SettingsManager"]
        if not settings_manager.get_setting_as_bool(
            "keyboard", "interrupt_on_key_press"
        ):
            return
        if settings_manager.get_setting(
            "keyboard", "interrupt_on_key_press_filter"
        ).strip():
            return
        self.start_stdin_interrupt_thread()

    def start_stdin_interrupt_thread(self):
        with self.stdin_interrupt_lock:
            if self.stdin_interrupt_running:
                return
            self.stdin_interrupt_running = True
        self.stdin_interrupt_thread = threading.Thread(
            target=self.run_stdin_interrupt,
            daemon=True,
        )
        self.stdin_interrupt_thread.start()

    def run_stdin_interrupt(self):
        try:
            self.env["runtime"]["OutputManager"].interrupt_output()
        except Exception as e:
            self.env["runtime"]["DebugManager"].write_debug_out(
                "ptyDriver interrupt_output_on_stdin_input: "
                + str(e),
                debug.DebugLevel.ERROR,
            )
        finally:
            with self.stdin_interrupt_lock:
                self.stdin_interrupt_running = False

    def handle_stdin_input(self, msg_bytes, event_queue):
        if self.synthesize_backspace_shortcut(msg_bytes, event_queue):
            return
        self.record_stdin_keypress(msg_bytes)
        self.interrupt_output_on_stdin_input(msg_bytes)
        self.inject_text_to_screen(msg_bytes)

    def record_stdin_keypress(self, msg_bytes):
        if msg_bytes != b"\t":
            return
        try:
            self.env["runtime"]["InputManager"].record_unmanaged_keypress(
                "KEY_TAB"
            )
        except Exception as e:
            self.env["runtime"]["DebugManager"].write_debug_out(
                "ptyDriver record_stdin_keypress: " + str(e),
                debug.DebugLevel.ERROR,
            )

    def synthesize_backspace_shortcut(self, msg_bytes, event_queue):
        if msg_bytes not in [b"\x7f", b"\x08"]:
            return False
        if "KEY_FENRIR" not in self.env["input"]["curr_input"]:
            return False

        event_time = time.time()
        for event_state in [1, 0]:
            try:
                event_queue.put(
                    {
                        "Type": FenrirEventType.keyboard_input,
                        "data": {
                            "event_name": "KEY_BACKSPACE",
                            "event_value": 0,
                            "event_sec": int(event_time),
                            "event_usec": int((event_time % 1) * 1000000),
                            "event_state": event_state,
                            "event_type": 0,
                        },
                    },
                    block=False,
                )
            except Full:
                self.env["runtime"]["DebugManager"].write_debug_out(
                    "ptyDriver synthesize_backspace_shortcut: Event queue full, dropping backspace events",
                    debug.DebugLevel.WARNING,
                )
        return True

    def get_session_information(self):
        self.env["screen"]["autoIgnoreScreens"] = []
        self.env["general"]["prev_user"] = getpass.getuser()
        self.env["general"]["curr_user"] = getpass.getuser()

    def read_all(self, fd, timeout=PTYConstants.DEFAULT_READ_TIMEOUT, interruptFd=None, len=PTYConstants.DEFAULT_READ_BUFFER_SIZE):
        """Read all available data from file descriptor with efficient polling.
        
        Uses progressively longer wait times to balance responsiveness with CPU usage.
        """
        msg_bytes = b""
        fd_list = [fd]
        if interruptFd:
            fd_list.append(interruptFd)
        
        starttime = time.time()
        poll_timeout = self.pty_config.get('poll_interval', PTYConstants.MIN_POLL_INTERVAL)  # Use configured interval
        
        while True:
            # Use consistent short polling for responsiveness
            r = screen_utils.has_more_what(fd_list, poll_timeout)
            
            # Nothing more to read
            if fd not in r:
                # Check overall timeout
                if (time.time() - starttime) >= timeout:
                    break
                continue
            
            try:
                data = os.read(fd, len)
                if data == b"":
                    raise EOFError
                msg_bytes += data
            except OSError as e:
                self.env["runtime"]["DebugManager"].write_debug_out(
                    f"PTY screenDriver read_all: OS error reading from fd {fd}: {e}",
                    debug.DebugLevel.ERROR
                )
                # For I/O errors, exit immediately to prevent endless retry loops
                if e.errno == PTYConstants.IO_ERROR_ERRNO:  # Input/output error
                    self.env["runtime"]["DebugManager"].write_debug_out(
                        "PTY screenDriver: Terminal connection lost, stopping read loop",
                        debug.DebugLevel.ERROR
                    )
                    raise EOFError("Terminal connection lost")
                break
                
            # Exit on interrupt available
            if interruptFd and interruptFd in r:
                break
                
            # Check overall timeout
            if (time.time() - starttime) >= timeout:
                break
                
        return msg_bytes

    def open_terminal(self, columns, lines, command):
        p_pid, master_fd = pty.fork()
        if p_pid == 0:  # Child.
            argv = shlex.split(command)
            env = os.environ.copy()
            # values are VT100,xterm-256color,linux
            try:
                if env["TERM"] == "":
                    env["TERM"] = "linux"
            except Exception as e:
                # Child process doesn't have access to debug manager
                # Use fallback logging with more context
                print(
                    f"ptyDriver open_terminal (child): TERM environment error: {e}"
                )
                env["TERM"] = "linux"
            os.execvpe(argv[0], argv, env)
        # File-like object for I/O with the child process aka command.
        p_out = os.fdopen(master_fd, "w+b", 0)
        return Terminal(columns, lines, p_out, self.env), p_pid, p_out

    def resize_terminal(self, fd):
        s = struct.pack("HHHH", 0, 0, 0, 0)
        s = fcntl.ioctl(0, termios.TIOCGWINSZ, s)
        fcntl.ioctl(fd, termios.TIOCSWINSZ, s)
        lines, columns, _, _ = struct.unpack("hhhh", s)
        return lines, columns

    def get_terminal_size(self, fd):
        s = struct.pack("HHHH", 0, 0, 0, 0)
        lines, columns, _, _ = struct.unpack(
            "HHHH", fcntl.ioctl(fd, termios.TIOCGWINSZ, s)
        )
        return lines, columns

    def handle_sigwinch(self, *args):
        os.write(self.signalPipe[1], b"w")

    def terminal_emulation(self, active, event_queue):
        try:
            old_attr = termios.tcgetattr(sys.stdin)
            tty.setraw(0)
            lines, columns = self.get_terminal_size(0)
            if self.command == "":
                self.command = screen_utils.get_shell()
            self.terminal, self.p_pid, self.p_out = self.open_terminal(
                columns, lines, self.command
            )
            lines, columns = self.resize_terminal(self.p_out)
            self.terminal.resize(lines, columns)
            fd_list = [sys.stdin, self.p_out, self.signalPipe[0]]
            while active.value:
                r, _, _ = select(fd_list, [], [], self.pty_config.get('select_timeout', PTYConstants.SELECT_TIMEOUT))  # Configurable timeout
                # none
                if r == []:
                    continue
                # signals
                if self.signalPipe[0] in r:
                    os.read(self.signalPipe[0], 1)
                    lines, columns = self.resize_terminal(self.p_out)
                    self.terminal.resize(lines, columns)
                # input
                if sys.stdin in r:
                    try:
                        msg_bytes = self.read_all(sys.stdin.fileno(), timeout=self.pty_config.get('input_timeout', PTYConstants.INPUT_READ_TIMEOUT), len=PTYConstants.INPUT_BUFFER_SIZE)
                    except (EOFError, OSError):
                        event_queue.put(
                            {
                                "Type": FenrirEventType.stop_main_loop,
                                "data": None,
                            }
                        )
                        break
                    try:
                        self.handle_stdin_input(msg_bytes, event_queue)
                    except Exception as e:
                        self.env["runtime"][
                            "DebugManager"
                        ].write_debug_out(
                            "ptyDriver getInputData: Error injecting text to screen: "
                            + str(e),
                            debug.DebugLevel.ERROR,
                        )
                        event_queue.put(
                            {
                                "Type": FenrirEventType.stop_main_loop,
                                "data": None,
                            }
                        )
                        break
                # output
                if self.p_out in r:
                    try:
                        msg_bytes = self.read_all(
                            self.p_out.fileno(), 
                            timeout=self.pty_config.get('output_timeout', PTYConstants.OUTPUT_READ_TIMEOUT),
                            interruptFd=sys.stdin.fileno()
                        )
                    except (EOFError, OSError):
                        event_queue.put(
                            {
                                "Type": FenrirEventType.stop_main_loop,
                                "data": None,
                            }
                        )
                        break
                    # Synchronize terminal operations to prevent race conditions
                    with self.terminal_lock:
                        # Feed data to terminal and get consistent screen state
                        self.terminal.feed(msg_bytes)
                        screen_content = self.terminal.get_screen_content()
                        
                        # Send screen update event with consistent state
                        event_queue.put(
                            {
                                "Type": FenrirEventType.screen_update,
                                "data": screen_utils.create_screen_event_data(
                                    screen_content
                                ),
                            }
                        )
                    
                    # Inject to actual screen (outside lock to avoid blocking)
                    self.inject_text_to_screen(
                        msg_bytes, screen=sys.stdout.fileno()
                    )
        except Exception as e:  # Process died?
            self.env["runtime"]["DebugManager"].write_debug_out(
                f"PTY screenDriver terminal_emulation: Exception occurred: {e}",
                debug.DebugLevel.ERROR
            )
            event_queue.put(
                {"Type": FenrirEventType.stop_main_loop, "data": None}
            )
        finally:
            self._safe_cleanup_process()
            self._safe_cleanup_resources(old_attr)
            event_queue.put(
                {"Type": FenrirEventType.stop_main_loop, "data": None}
            )

    def _safe_cleanup_process(self):
        """Safely terminate the child process with timeout and fallback to SIGKILL."""
        if not hasattr(self, 'p_pid') or self.p_pid is None:
            return
        
        try:
            # Check if process is still alive
            os.kill(self.p_pid, 0)  # Signal 0 checks if process exists
        except OSError:
            # Process already dead
            self.p_pid = None
            return
        
        try:
            # Try graceful termination first
            self.env["runtime"]["DebugManager"].write_debug_out(
                f"PTY screenDriver: Terminating process {self.p_pid} gracefully",
                debug.DebugLevel.INFO
            )
            os.kill(self.p_pid, signal.SIGTERM)
            
            # Wait for graceful termination
            timeout = self.pty_config.get('process_termination_timeout', PTYConstants.PROCESS_TERMINATION_TIMEOUT)
            start_time = time.time()
            while time.time() - start_time < timeout:
                try:
                    os.kill(self.p_pid, 0)  # Check if still alive
                    time.sleep(0.1)
                except OSError:
                    # Process terminated gracefully
                    self.p_pid = None
                    return
            
            # Process didn't terminate gracefully, use SIGKILL
            self.env["runtime"]["DebugManager"].write_debug_out(
                f"PTY screenDriver: Process {self.p_pid} didn't terminate gracefully, using SIGKILL",
                debug.DebugLevel.WARNING
            )
            os.kill(self.p_pid, signal.SIGKILL)
            time.sleep(PTYConstants.PROCESS_KILL_DELAY)  # Give it a moment
            
        except OSError as e:
            self.env["runtime"]["DebugManager"].write_debug_out(
                f"PTY screenDriver: Error terminating process {self.p_pid}: {e}",
                debug.DebugLevel.ERROR
            )
        finally:
            self.p_pid = None

    def _safe_cleanup_resources(self, old_attr=None):
        """Safely clean up file descriptors and terminal attributes."""
        # Close output pipe safely
        if hasattr(self, 'p_out') and self.p_out is not None:
            try:
                self.p_out.close()
                self.env["runtime"]["DebugManager"].write_debug_out(
                    "PTY screenDriver: Closed output pipe",
                    debug.DebugLevel.INFO
                )
            except Exception as e:
                self.env["runtime"]["DebugManager"].write_debug_out(
                    f"PTY screenDriver: Error closing output pipe: {e}",
                    debug.DebugLevel.ERROR
                )
            finally:
                self.p_out = None
        
        # Restore terminal attributes safely
        if old_attr is not None:
            try:
                termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_attr)
                self.env["runtime"]["DebugManager"].write_debug_out(
                    "PTY screenDriver: Restored terminal attributes",
                    debug.DebugLevel.INFO
                )
            except Exception as e:
                self.env["runtime"]["DebugManager"].write_debug_out(
                    f"PTY screenDriver: Error restoring terminal attributes: {e}",
                    debug.DebugLevel.ERROR
                )

    def get_curr_application(self):
        pass
