"""

The Remote driver uses the following packet structure.

1 byte for packet type. "D" for data, "M" for meta.
4 byte little endian integer for the size of the payload.
Arbitrary payload.


"""

from __future__ import annotations

import asyncio
import json
import os
import signal
import sys
from codecs import getincrementaldecoder
from functools import partial
from pathlib import Path
from threading import Event, Thread
from typing import Any, BinaryIO, Literal, TextIO, cast

from textual import events, log, messages
from textual._binary_encode import dump as binary_dump
from textual._xterm_parser import XTermParser
from textual.app import App
from textual.driver import Driver
from textual.drivers._byte_stream import ByteStream
from textual.drivers._input_reader import InputReader
from textual.geometry import Size

WINDOWS = sys.platform == "win32"


class _ExitInput(Exception):
    """Internal exception to force exit of input loop."""


class WebDriver(Driver):
    """A headless driver that may be run remotely."""

    def __init__(
        self,
        app: App[Any],
        *,
        debug: bool = False,
        mouse: bool = True,
        size: tuple[int, int] | None = None,
    ):
        if size is None:
            try:
                width = int(os.environ.get("COLUMNS", 80))
                height = int(os.environ.get("ROWS", 24))
            except ValueError:
                pass
            else:
                size = width, height
        super().__init__(app, debug=debug, mouse=mouse, size=size)
        self.stdout = sys.__stdout__
        self.fileno = sys.__stdout__.fileno()
        self._write = partial(os.write, self.fileno)
        self.exit_event = Event()
        self._key_thread: Thread = Thread(
            target=self.run_input_thread, name="textual-input"
        )
        self._input_reader = InputReader()

        self._deliveries: dict[str, BinaryIO | TextIO] = {}
        """Maps delivery keys to file-like objects, used
        for delivering files to the browser."""

    @property
    def is_web(self) -> bool:
        return True

    def write(self, data: str) -> None:
        """Write string data to the output device, which may be piped to
        the parent process (i.e. textual-web/textual-serve).

        Args:
            data: Raw data.
        """

        data_bytes = data.encode("utf-8")
        self._write(b"D%s%s" % (len(data_bytes).to_bytes(4, "big"), data_bytes))

    def write_meta(self, data: dict[str, object]) -> None:
        """Write a dictionary containing some metadata to stdout, which
        may be piped to the parent process (i.e. textual-web/textual-serve).

        Args:
            data: Meta dict.
        """
        meta_bytes = json.dumps(data).encode("utf-8", errors="ignore")
        self._write(b"M%s%s" % (len(meta_bytes).to_bytes(4, "big"), meta_bytes))

    def write_binary_encoded(self, data: tuple[str | bytes, ...]) -> None:
        """Binary encode a data-structure and write to stdout.

        Args:
            data: The data to binary encode and write.
        """
        packed_bytes = binary_dump(data)
        self._write(b"P%s%s" % (len(packed_bytes).to_bytes(4, "big"), packed_bytes))

    def flush(self) -> None:
        pass

    def _enable_mouse_support(self) -> None:
        """Enable reporting of mouse events."""
        write = self.write
        write("\x1b[?1000h")  # SET_VT200_MOUSE
        write("\x1b[?1003h")  # SET_ANY_EVENT_MOUSE
        write("\x1b[?1015h")  # SET_VT200_HIGHLIGHT_MOUSE
        write("\x1b[?1006h")  # SET_SGR_EXT_MODE_MOUSE

    def _enable_bracketed_paste(self) -> None:
        """Enable bracketed paste mode."""
        self.write("\x1b[?2004h")

    def _disable_bracketed_paste(self) -> None:
        """Disable bracketed paste mode."""
        self.write("\x1b[?2004l")

    def _disable_mouse_support(self) -> None:
        """Disable reporting of mouse events."""
        write = self.write
        write("\x1b[?1000l")  #
        write("\x1b[?1003l")  #
        write("\x1b[?1015l")
        write("\x1b[?1006l")

    def _request_terminal_sync_mode_support(self) -> None:
        """Writes an escape sequence to query the terminal support for the sync protocol."""
        self.write("\033[?2026$p")

    def start_application_mode(self) -> None:
        """Start application mode."""

        loop = asyncio.get_running_loop()

        def do_exit() -> None:
            """Callback to force exit."""
            asyncio.run_coroutine_threadsafe(
                self._app._post_message(messages.ExitApp()), loop=loop
            )

        if not WINDOWS:
            for _signal in (signal.SIGINT, signal.SIGTERM):
                loop.add_signal_handler(_signal, do_exit)

        self._write(b"__GANGLION__\n")

        self.write("\x1b[?1049h")  # Alt screen
        self._enable_mouse_support()

        self.write("\x1b[?25l")  # Hide cursor
        self.write("\033[?1003h")

        size = Size(80, 24) if self._size is None else Size(*self._size)
        event = events.Resize(size, size)
        asyncio.run_coroutine_threadsafe(
            self._app._post_message(event),
            loop=loop,
        )

        self._request_terminal_sync_mode_support()
        self._enable_bracketed_paste()
        self.flush()
        self._key_thread.start()
        self._app.call_later(self._app.post_message, events.AppBlur())

    def disable_input(self) -> None:
        """Disable further input."""

    def stop_application_mode(self) -> None:
        """Stop application mode, restore state."""
        self.exit_event.set()
        self._input_reader.close()
        self.write_meta({"type": "exit"})

    def run_input_thread(self) -> None:
        """Wait for input and dispatch events."""
        input_reader = self._input_reader
        parser = XTermParser(debug=self._debug)
        utf8_decoder = getincrementaldecoder("utf-8")().decode
        decode = utf8_decoder
        # The server sends us a stream of bytes, which contains the equivalent of stdin, plus
        # in band data packets.
        byte_stream = ByteStream()
        try:
            for data in input_reader:
                if data:
                    for packet_type, payload in byte_stream.feed(data):
                        if packet_type == "D":
                            # Treat as stdin
                            for event in parser.feed(decode(payload)):
                                self.process_message(event)
                        else:
                            # Process meta information separately
                            self._on_meta(packet_type, payload)
                for event in parser.tick():
                    self.process_message(event)
        except _ExitInput:
            pass
        except Exception:
            from traceback import format_exc

            log(format_exc())
        finally:
            input_reader.close()

    def _on_meta(self, packet_type: str, payload: bytes) -> None:
        """Private method to dispatch meta.

        Args:
            packet_type: Packet type (currently always "M")
            payload: Meta payload (JSON encoded as bytes).
        """
        payload_map: dict[str, object] = json.loads(payload)
        _type = payload_map.get("type", {})
        if isinstance(_type, str):
            self.on_meta(_type, payload_map)
        else:
            log.error(
                f"Protocol error: type field value is not a string. Value is {_type!r}"
            )

    def on_meta(self, packet_type: str, payload: dict[str, object]) -> None:
        """Process a dictionary containing information received from the controlling process.

        Args:
            packet_type: The type of the packet.
            payload: meta dict.
        """
        if packet_type == "resize":
            self._size = (payload["width"], payload["height"])
            requested_size = Size(*self._size)
            self._app.post_message(events.Resize(requested_size, requested_size))
        elif packet_type == "focus":
            self._app.post_message(events.AppFocus())
        elif packet_type == "blur":
            self._app.post_message(events.AppBlur())
        elif packet_type == "quit":
            self._app.post_message(messages.ExitApp())
        elif packet_type == "exit":
            raise _ExitInput()
        elif packet_type == "deliver_chunk_request":
            # A request from the server to deliver another chunk of a file
            log.debug(f"Deliver chunk request: {payload}")
            try:
                delivery_key = cast(str, payload["key"])
                requested_size = cast(int, payload["size"])
            except KeyError:
                log.error("Protocol error: deliver_chunk_request missing key or size")
                return

            deliveries = self._deliveries

            file_like: BinaryIO | TextIO | None = None
            try:
                file_like = deliveries[delivery_key]
            except KeyError:
                log.error(
                    f"Protocol error: deliver_chunk_request invalid key {delivery_key!r}"
                )
            else:
                # Read the requested amount of data from the file
                name: str | None = payload.get("name", None)
                try:
                    log.debug(f"Reading {requested_size} bytes from {delivery_key}")
                    chunk = file_like.read(requested_size)
                    log.debug(f"Delivering chunk {delivery_key!r} of len {len(chunk)}")
                    self.write_binary_encoded(("deliver_chunk", delivery_key, chunk))
                    # We've hit an empty chunk, so we're done
                    if not chunk:
                        log.info(f"Delivery complete for {delivery_key}")
                        file_like.close()
                        del deliveries[delivery_key]
                        self._delivery_complete(delivery_key, save_path=None, name=name)
                except Exception as error:
                    file_like.close()
                    del deliveries[delivery_key]

                    log.error(
                        f"Error delivering file chunk for key {delivery_key!r}. "
                        "Cancelling delivery."
                    )
                    import traceback

                    log.error(str(traceback.format_exc()))

                    self._delivery_failed(delivery_key, exception=error, name=name)

    def open_url(self, url: str, new_tab: bool = True) -> None:
        """Open a URL in the default web browser.

        Args:
            url: The URL to open.
            new_tab: Whether to open the URL in a new tab.
        """
        self.write_meta({"type": "open_url", "url": url, "new_tab": new_tab})

    def deliver_binary(
        self,
        binary: BinaryIO | TextIO,
        *,
        delivery_key: str,
        save_path: Path,
        open_method: Literal["browser", "download"] = "download",
        encoding: str | None = None,
        mime_type: str | None = None,
        name: str | None = None,
    ) -> None:
        self._deliver_file(
            binary,
            delivery_key=delivery_key,
            save_path=save_path,
            open_method=open_method,
            encoding=encoding,
            mime_type=mime_type,
            name=name,
        )

    def _deliver_file(
        self,
        binary: BinaryIO | TextIO,
        *,
        delivery_key: str,
        save_path: Path,
        open_method: Literal["browser", "download"],
        encoding: str | None = None,
        mime_type: str | None = None,
        name: str | None = None,
    ) -> None:
        """Deliver a file to the end-user of the application."""
        binary.seek(0)

        self._deliveries[delivery_key] = binary

        # Inform the server that we're starting a new file delivery
        meta: dict[str, object] = {
            "type": "deliver_file_start",
            "key": delivery_key,
            "path": str(save_path.resolve()),
            "open_method": open_method,
            "encoding": encoding or "",
            "mime_type": mime_type or "",
            "name": name,
        }
        self.write_meta(meta)
        log.info(f"Delivering file {meta['path']!r}: {meta!r}")
