import msgpack as mp
import socket
import os
import signal
from collections import namedtuple
from typing import Callable
from enum import IntEnum
import re
import threading
import time
from importlib.metadata import version

__version__ = version('IsaREPL')

REPLFail = type('REPLFail', (Exception,), {})


def _load_symbols(path, symbols={}, reverse_symbols={}):
    """
    Load Isabelle symbol file
    Return: (A dictionary from ASCII symbol to unicode symbol, and the reverse dictionary)
    """
    if not isinstance(path, str):
        raise ValueError("the argument path must be a string")
    if not os.path.exists(path):
        return symbols, reverse_symbols
    with open(path, 'r', encoding='utf-8') as file:
        for line in file:
            # Every line has a form like `\<odiv>            code: 0x002A38   font: PhiSymbols   group: operator   abbrev: (-:)`
            # Here we extract the `\<odiv>` part as a string and the `0x002A38` part as a character
            # Skip comments and empty lines
            line = line.strip()
            if not line or line.startswith('#'):
                continue
            
            # Parse the line to extract symbol and code point
            parts = line.split()
            
            # Extract the symbol name (like \<odiv>)
            symbol = parts[0]
            
            # Find the code point (format can be either "code: 0x002A38" or "code:0x002A38")
            code_point = None
            for i, part in enumerate(parts[1:], 1):  # Start index at 1 since we're iterating from parts[1:]
                if part.startswith('code:'):
                    # Handle the case where there's no space after "code:"
                    if ':' in part and len(part) > 5:  # "code:" is 5 chars
                        code_point = part.split(':', 1)[1].strip()
                    # Otherwise, the hex value should be in the next part
                    elif i < len(parts) - 1:  # Check if there's a next element
                        code_point = parts[i + 1].strip()
                    break
            
            if symbol and code_point:
                try:
                    # Convert hex code point to unicode character
                    unicode_char = chr(int(code_point, 16))
                    # Add to dictionaries
                    symbols[symbol] = unicode_char
                    reverse_symbols[unicode_char] = symbol
                except ValueError:
                    # Skip if code point is invalid
                    continue
    return symbols, reverse_symbols

SYMBOLS_CACHE = None

def get_SYMBOLS_AND_REVERSED():
    global SYMBOLS_CACHE
    if SYMBOLS_CACHE is not None:
        return SYMBOLS_CACHE
    isabelle_home = os.popen("isabelle getenv -b ISABELLE_HOME").read().strip()
    isabelle_home_user = os.popen("isabelle getenv -b ISABELLE_HOME_USER").read().strip()
    SYMBOLS, REVERSE_SYMBOLS = {}, {}
    for file in [f"{isabelle_home}/etc/symbols", f"{isabelle_home_user}/etc/symbols"]:
        SYMBOLS, REVERSE_SYMBOLS = _load_symbols(file, SYMBOLS, REVERSE_SYMBOLS)
    SYMBOLS_CACHE = (SYMBOLS, REVERSE_SYMBOLS, str.maketrans(REVERSE_SYMBOLS))
    return SYMBOLS_CACHE

def get_SYMBOLS():
    return get_SYMBOLS_AND_REVERSED()[0]

def get_REVERSE_SYMBOLS():
    return get_SYMBOLS_AND_REVERSED()[1]

SUBSUP_TRANS_TABLE = {
    "⇩0": "₀", "⇩1": "₁", "⇩2": "₂", "⇩3": "₃", "⇩4": "₄",
    "⇩5": "₅", "⇩6": "₆", "⇩7": "₇", "⇩8": "₈", "⇩9": "₉",
    #ₐₑₕᵢⱼₖₗₘₙₒₚᵣₛₜᵤᵥₓ
    "⇩a": "ₐ", "⇩e": "ₑ", "⇩h": "ₕ", "⇩i": "ᵢ", "⇩j": "ⱼ", "⇩k": "ₖ", "⇩l": "ₗ",
    "⇩m": "ₘ", "⇩n": "ₙ", "⇩o": "ₒ", "⇩p": "ₚ", "⇩r": "ᵣ", "⇩s": "ₛ", "⇩t": "ₜ",
    "⇩u": "ᵤ", "⇩v": "ᵥ", "⇩x": "ₓ",
    "⇧0": "⁰", "⇧1": "¹", "⇧2": "²", "⇧3": "³", "⇧4": "⁴",
    "⇧5": "⁵", "⇧6": "⁶", "⇧7": "⁷", "⇧8": "⁸", "⇧9": "⁹",
    "⇧A": "ᴬ", "⇧B": "ᴮ", "⇧D": "ᴰ", "⇧E": "ᴱ",
    "⇧G": "ᴳ", "⇧H": "ᴴ", "⇧I": "ᴵ", "⇧J": "ᴶ", "⇧K": "ᴷ", "⇧L": "ᴸ",
    "⇧M": "ᴹ", "⇧N": "ᴺ", "⇧O": "ᴼ", "⇧P": "ᴾ", "⇧R": "ᴿ", "⇧T": "ᵀ",
    "⇧U": "ᵁ", "⇧V": "ⱽ", "⇧W": "ᵂ",
    #ᵃᵇᶜᵈᵉᶠᵍʰⁱʲᵏˡᵐⁿᵒᵖˢᵗᵘᵛʷˣʸᶻ
    "⇧a": "ᵃ", "⇧b": "ᵇ", "⇧c": "ᶜ", "⇧d": "ᵈ", "⇧e": "ᵉ", "⇧f": "ᶠ",
    "⇧g": "ᵍ", "⇧h": "ʰ", "⇧i": "ⁱ", "⇧j": "ʲ", "⇧k": "ᵏ", "⇧l": "ˡ",
    "⇧m": "ᵐ", "⇧n": "ⁿ", "⇧o": "ᵒ", "⇧p": "ᵖ", "⇧s": "ˢ", "⇧t": "ᵗ",
    "⇧u": "ᵘ", "⇧v": "ᵛ", "⇧w": "ʷ", "⇧x": "ˣ", "⇧y": "ʸ", "⇧z": "ᶻ",
    "⇩-": "₋", "⇧-": "⁻", "⇩+": "₊", "⇧+": "⁺", "⇩=": "₌", "⇧=": "⁼",
    "⇩(": "₍", "⇧(": "⁽", "⇩)": "₎", "⇧)": "⁾",
    "❙a": "𝐚", "❙b": "𝐛", "❙c": "𝐜", "❙d": "𝐝", "❙e": "𝐞", "❙f": "𝐟",
    "❙g": "𝐠", "❙h": "𝐡", "❙i": "𝐢", "❙j": "𝐣", "❙k": "𝐤", "❙l": "𝐥",
    "❙m": "𝐦", "❙n": "𝐧", "❙o": "𝐨", "❙p": "𝐩", "❙q": "𝐪", "❙r": "𝐫",
    "❙s": "𝐬", "❙t": "𝐭", "❙u": "𝐮", "❙v": "𝐯", "❙w": "𝐰", "❙x": "𝐱",
    "❙y": "𝐲", "❙z": "𝐳",
    "❙A": "𝐀", "❙B": "𝐁", "❙C": "𝐂", "❙D": "𝐃", "❙E": "𝐄", "❙F": "𝐅",
    "❙G": "𝐆", "❙H": "𝐇", "❙I": "𝐈", "❙J": "𝐉", "❙K": "𝐊", "❙L": "𝐋",
    "❙M": "𝐌", "❙N": "𝐍", "❙O": "𝐎", "❙P": "𝐏", "❙Q": "𝐐", "❙R": "𝐑",
    "❙S": "𝐒", "❙T": "𝐓", "❙U": "𝐔", "❙V": "𝐕", "❙W": "𝐖", "❙X": "𝐗",
    "❙Y": "𝐘", "❙Z": "𝐙",
}

SUBSUP_RESTORE_TABLE = {
    "₀": "⇩0", "₁": "⇩1", "₂": "⇩2", "₃": "⇩3", "₄": "⇩4",
    "₅": "⇩5", "₆": "⇩6", "₇": "⇩7", "₈": "⇩8", "₉": "⇩9",
    "ₐ": "⇩a", "ₑ": "⇩e", "ₕ": "⇩h", "ᵢ": "⇩i", "ⱼ": "⇩j", "ₖ": "⇩k", "ₗ": "⇩l",
    "ₘ": "⇩m", "ₙ": "⇩n", "ₒ": "⇩o", "ₚ": "⇩p", "ᵣ": "⇩r", "ₛ": "⇩s", "ₜ": "⇩t",
    "ᵤ": "⇩u", "ᵥ": "⇩v", "ₓ": "⇩x",
    "⁰": "⇧0", "¹": "⇧1", "²": "⇧2", "³": "⇧3", "⁴": "⇧4",
    "⁵": "⇧5", "⁶": "⇧6", "⁷": "⇧7", "⁸": "⇧8", "⁹": "⇧9",
    "ᴬ": "⇧A", "ᴮ": "⇧B", "ᴰ": "⇧D", "ᴱ": "⇧E", "ᴳ": "⇧G", "ᴴ": "⇧H", "ᴵ": "⇧I",
    "ᴶ": "⇧J", "ᴷ": "⇧K", "ᴸ": "⇧L", "ᴹ": "⇧M", "ᴺ": "⇧N", "ᴼ": "⇧O", "ᴾ": "⇧P",
    "ᴿ": "⇧R", "ᵀ": "⇧T", "ᵁ": "⇧U", "ⱽ": "⇧V", "ᵂ": "⇧W",
    "ᵃ": "⇧a", "ᵇ": "⇧b", "ᶜ": "⇧c", "ᵈ": "⇧d", "ᵉ": "⇧e", "ᶠ": "⇧f",
    "ᵍ": "⇧g", "ʰ": "⇧h", "ⁱ": "⇧i", "ʲ": "⇧j", "ᵏ": "⇧k", "ˡ": "⇧l",
    "ᵐ": "⇧m", "ⁿ": "⇧n", "ᵒ": "⇧o", "ᵖ": "⇧p", "ˢ": "⇧s", "ᵗ": "⇧t",
    "ᵘ": "⇧u", "ᵛ": "⇧v", "ʷ": "⇧w", "ˣ": "⇧x", "ʸ": "⇧y", "ᶻ": "⇧z",
    "₋": "⇩-", "⁻": "⇧-", "₊": "⇩+", "⁺": "⇧+", "₌": "⇩=", "⁼": "⇧=",
    "₍": "⇩(", "⁽": "⇧(", "₎": "⇩)", "⁾": "⇧)",
    "𝐚": "❙a", "𝐛": "❙b", "𝐜": "❙c", "𝐝": "❙d", "𝐞": "❙e", "𝐟": "❙f",
    "𝐠": "❙g", "𝐡": "❙h", "𝐢": "❙i", "𝐣": "❙j", "𝐤": "❙k", "𝐥": "❙l",
    "𝐦": "❙m", "𝐧": "❙n", "𝐨": "❙o", "𝐩": "❙p", "𝐪": "❙q", "𝐫": "❙r",
    "𝐬": "❙s", "𝐭": "❙t", "𝐮": "❙u", "𝐯": "❙v", "𝐰": "❙w", "𝐱": "❙x",
    "𝐲": "❙y", "𝐳": "❙z",
    "𝐀": "❙A", "𝐁": "❙B", "𝐂": "❙C", "𝐃": "❙D", "𝐄": "❙E", "𝐅": "❙F",
    "𝐆": "❙G", "𝐇": "❙H", "𝐈": "❙I", "𝐉": "❙J", "𝐊": "❙K", "𝐋": "❙L",
    "𝐌": "❙M", "𝐍": "❙N", "𝐎": "❙O", "𝐏": "❙P", "𝐐": "❙Q", "𝐑": "❙R",
    "𝐒": "❙S", "𝐓": "❙T", "𝐔": "❙U", "𝐕": "❙V", "𝐖": "❙W", "𝐗": "❙X",
    "𝐘": "❙Y", "𝐙": "❙Z",
}

SUBSUP_RESTORE_TABLE_trans = str.maketrans(SUBSUP_RESTORE_TABLE)

class IsabellePosition:
    def __init__(self, line : int, raw_offset : int, file : str):
        self.line = line
        self.raw_offset = raw_offset
        self.file = file

    def __str__(self):
        return f"{self.file}:{self.line}:{self.raw_offset}"

    def __repr__(self):
        return self.__str__()

    def __eq__(self, other):
        if not isinstance(other, IsabellePosition):
            return False
        return (self.line == other.line and 
                self.raw_offset == other.raw_offset and 
                self.file == other.file)

    def __hash__(self):
        return hash((self.line, self.raw_offset, self.file))

    def __lt__(self, other):
        if not isinstance(other, IsabellePosition):
            return NotImplemented
        return (self.file, self.line, self.raw_offset) < (other.file, other.line, other.raw_offset)

    def __le__(self, other):
        if not isinstance(other, IsabellePosition):
            return NotImplemented
        return (self.file, self.line, self.raw_offset) <= (other.file, other.line, other.raw_offset)

    def __gt__(self, other):
        if not isinstance(other, IsabellePosition):
            return NotImplemented
        return (self.file, self.line, self.raw_offset) > (other.file, other.line, other.raw_offset)

    def __ge__(self, other):
        if not isinstance(other, IsabellePosition):
            return NotImplemented
        return (self.file, self.line, self.raw_offset) >= (other.file, other.line, other.raw_offset)

    @staticmethod
    def from_s(position_str):
        parts = position_str.split(':')
        match parts:
            case [file, line, raw_offset, _]:
                return IsabellePosition(int(line), int(raw_offset), file)
            case [file, line, raw_offset]:
                return IsabellePosition(int(line), int(raw_offset), file)
            case [file, line]:
                return IsabellePosition(int(line), 0, file)
            case [file]:
                return IsabellePosition(0, 0, file)
            case _:
                raise ValueError("The string must be in the format: file:line:raw_offset")

class Position:
    def __init__(self, line : int, column : int, file : str):
        self.line = line
        self.column = column
        self.file = file
    
    def to_s(self, with_column=True):
        if with_column:
            return self.__str__()
        else:
            return f"{self.file}:{self.line}"

    def __str__(self):
        if self.column == 0:
            return f"{self.file}:{self.line}"
        else:
            return f"{self.file}:{self.line}:{self.column}"

    def __repr__(self):
        return self.__str__()

    def __eq__(self, other):
        if not isinstance(other, Position):
            return False
        return (self.line == other.line and 
                self.column == other.column and 
                self.file == other.file)

    def __hash__(self):
        return hash((self.line, self.column, self.file))

    def __lt__(self, other):
        if not isinstance(other, Position):
            return NotImplemented
        return (self.file, self.line, self.column) < (other.file, other.line, other.column)

    def __le__(self, other):
        if not isinstance(other, Position):
            return NotImplemented
        return (self.file, self.line, self.column) <= (other.file, other.line, other.column)

    def __gt__(self, other):
        if not isinstance(other, Position):
            return NotImplemented
        return (self.file, self.line, self.column) > (other.file, other.line, other.column)

    def __ge__(self, other):
        if not isinstance(other, Position):
            return NotImplemented
        return (self.file, self.line, self.column) >= (other.file, other.line, other.column)

    @staticmethod
    def from_s(position_str):
        parts = position_str.split(':')
        match parts:
            case [file, line, column, _]:
                return Position(int(line), int(column), file)
            case [file, line, column]:
                return Position(int(line), int(column), file)
            case [file, line]:
                return Position(int(line), 0, file)
            case [file]:
                return Position(0, 0, file)
            case _:
                raise ValueError("The string must be in the format: file:line:column")
    
    def offset_of(self, lines=None):
        if lines is None:
            with open(self.file, 'r', encoding="latin-1") as f:
                lines = f.readlines()
        base_ofs = sum(len(line) for line in lines[:self.line-1])
        return base_ofs + self.column - 1

def __unpack_position__(data):  
    line, offset, end_offset, tup3 = data
    label, file, id = tup3
    return IsabellePosition(line, offset, file)

def __unpack_translated_position__(data):  
    line, column, end_offset, tup3 = data
    label, file, id = tup3
    return Position(line, column, file)

def is_list_of_strings(lst):
    if lst and isinstance(lst, list):
        return all(isinstance(elem, str) for elem in lst)
    else:
        return False


class MessageType(IntEnum):
    """
    Message type enumeration for Isabelle output messages.
    
    Values:
        NORMAL: 0 - Normal outputs printed by Isabelle/ML `writeln`
        TRACING: 1 - Tracing information printed by Isabelle/ML `tracing`
        WARNING: 2 - Warning printed by Isabelle/ML `warning`
    """
    NORMAL = 0
    TRACING = 1
    WARNING = 2


class CommandFlags:
    """
    Boolean flags indicating the state of the Isabelle session.
    
    Attributes:
        is_toplevel: Whether the Isabelle state is outside any theory block
        is_theory: Whether the state is within a theory block and at the toplevel of this block
        is_proof: Whether the state is working on proving some goal
        has_goal: Whether the state has some goal to prove, or all goals are proven
    """
    def __init__(self, is_toplevel: bool, is_theory: bool, is_proof: bool, has_goal: bool):
        self.is_toplevel = is_toplevel
        self.is_theory = is_theory
        self.is_proof = is_proof
        self.has_goal = has_goal
    
    def __repr__(self):
        return f"CommandFlags(is_toplevel={self.is_toplevel}, is_theory={self.is_theory}, is_proof={self.is_proof}, has_goal={self.has_goal})"


class CommandOutput:
    """
    Represents the output from evaluating a single Isabelle command.
    
    Attributes:
        command: The name of the command
        range: A tuple of (begin_pos, end_pos) indicating the range of the command,
               where each position is an IsabellePosition
        output: A list of (MessageType, str) tuples (the same output in Isabelle's output panel)
        latex: LaTeX output
        flags: Flags about the Isabelle state after executing the command
        level: The level of nesting context (an integer)
        state: The proof state as a string (the same content in the `State` panel)
        plugin_output: The output of plugins
        errors: A list of strings containing any errors raised during evaluating this command
    """
    def __init__(self, command: str, range: tuple, output: list, latex, flags: CommandFlags,
                 level: int, state: str, plugin_output, errors: list):
        self.command = command
        self.range = range
        self.output = output
        self.latex = latex
        self.flags = flags
        self.level = level
        self.state = state
        self.plugin_output = plugin_output
        self.errors = errors
    
    @classmethod
    def parse(cls, output):
        """
        Parse raw output data from Isabelle REPL into a CommandOutput instance.
        
        Args:
            output: Raw output data from Isabelle REPL (a list/tuple with specific structure)
        
        Returns:
            CommandOutput: A parsed CommandOutput instance
        """
        begin_pos, end_pos = output[8]
        # Parse output messages as (MessageType, str) tuples
        output_messages = [(MessageType(msg[0]), msg[1]) for msg in output[1]]
        # Parse flags
        flags = CommandFlags(
            is_toplevel=output[3][0],
            is_theory=output[3][1],
            is_proof=output[3][2],
            has_goal=output[3][3]
        )
        # Create and return CommandOutput instance
        return cls(
            command=output[0],
            range=(__unpack_position__(begin_pos), __unpack_position__(end_pos)),
            output=output_messages,
            latex=output[2],
            flags=flags,
            level=output[4],
            state=output[5],
            plugin_output=output[6],
            errors=output[7]
        )
    
    def __repr__(self):
        return (f"CommandOutput(command={repr(self.command)}, "
                f"range={self.range}, output={self.output}, latex={repr(self.latex)}, "
                f"flags={self.flags}, level={self.level}, state={repr(self.state)}, "
                f"plugin_output={repr(self.plugin_output)}, errors={self.errors})")


class Client:
    """
    A client for connecting Isabelle REPL
    """

    clients = {} # from client_id to Client instance

    def __init__(self, addr, thy_qualifier, timeout=3600):
        """
        Create a client and connect it to `addr`.

        Arghument `thy_qualifier` is the session name used to parse short theory names,
        which will be qualified by this qualifier.
        Besides, any created theories during the evaluation will be qualified under
        `thy_qualifier`.

        For example, if you want to evaluate `$ISABELLE_HOME/src/HOL/List.thy`
        which imports `Lifting_Set` which is a short name while its full name
        is `HOL.Lifting_Set`.
        In this case, you must indicate `thy_qualifier = "HOL"`. Otherwise,
        the REPL cannot determine which import target do you mean.

        As another example, to evaluate
        `$AFP/thys/WebAssembly/Wasm_Printing/Wasm_Interpreter_Printing_Pure.thy`
        you should indicate `thy_qualifier = "WebAssembly"`.

        Basically, whenever you evaluate some existing file, the `thy_qualifier`
        should be the session name of that file.

        If you are unaware of the session name of a file, you could call
        `Client.session_name_of` to enquiry.

        You could also call `Client.set_thy_qualifier` to change this `thy_qualifier`
        after initialization.
        """
        if not isinstance(thy_qualifier, str):
            raise ValueError("the argument thy_qualifier must be a string")

        def parse_address(address):
            host, port = address.split(':')
            return (host, int(port))

        self.addr = addr
        host, port = parse_address(addr)
        self.sock = socket.create_connection((host, port), timeout=timeout)
        self.cout = self.sock.makefile('wb')
        self.cin = self.sock.makefile('rb', buffering=0)
        self.unpack = mp.Unpacker(self.cin)

        mp.pack(__version__, self.cout)
        mp.pack(thy_qualifier, self.cout)
        self.cout.flush()
        (self.pid, self.client_id) = Client._parse_control_(self.unpack.unpack())
        Client.clients[self.client_id] = self

    def _send_call1(self, cmd, data):
        if self.cout.closed:
            raise REPLFail(f"Client {self.client_id} is dead or closed")
        mp.pack(cmd, self.cout)
        mp.pack(data, self.cout)
        self.cout.flush()
    def _read(self):
        return Client._parse_control_(self.unpack.unpack())

    def _chk_live(self):
        if self.cout.closed or self.cin.closed:
            raise REPLFail(f"Client {self.client_id} is dead or closed")

    @staticmethod
    def test_server(addr, timeout=60):
        host, port = addr.split(':')
        with socket.create_connection((host, port), timeout=timeout) as sock:
            with sock.makefile('wb') as cout:
                with sock.makefile('rb', buffering=0) as cin:
                    unpack = mp.Unpacker(cin)
                    mp.pack("heartbeat", cout)
                    cout.flush()
                    Client._parse_control_(unpack.unpack())


    def __enter__(self):
        return self
    def __exit__(self, exc_type, exc_value, traceback):
        self.close()

    @staticmethod
    def kill_client(addr, client_id, timeout=60) -> bool:
        host, port = addr.split(':')
        with socket.create_connection((host, port), timeout=timeout) as sock:
            with sock.makefile('wb') as cout:
                with sock.makefile('rb', buffering=0) as cin:
                    unpack = mp.Unpacker(cin)
                    mp.pack("kill " + str(client_id), cout)
                    cout.flush()
                    return Client._parse_control_(unpack.unpack())


    def close(self):
        self.cout.close()
        self.cin.close()
        self.sock.close()
        try:
            del Client.clients[self.client_id]
        except:
            pass
        try:
            Client.kill_client(self.addr, self.client_id)
        except:
            pass

    @staticmethod
    def _parse_control_(ret):
        if ret[1] is None:
            return ret[0]
        else:
            raise REPLFail(ret[1])

    def eval(self, source, timeout=None, cmd_timeout=None, import_dir=None, base_dir=None, configs=None):
        """
        The `eval` method ONLY accepts **complete** commands ---
        It is strictly forbiddened to split a command into multiple fragments and
        individually send them to `eval` by multiple calls.

        Given this restriction, you may want to split a script into a command
        sequence (a sequence of code pieces, each of which corresponds to exactly
        one command). The `lex` method provides this funciton.

        The return of this method is a tuple
            (outputs, err_message)
        where `err_message` is either None or a string indicating any error that
        interrtupts the evaluation process (so the later commands are not executed).

        According to Isabelle's process behavior, the given `source` are split into
        a sequence of commands (this split is given by the `lex` method). Each
        command in the sequence is executed in order. Their outputs are stored in
        the `outputs` field, also in order.

        Method `silly_eval` interprets the meaning of each field in this output.

        Note, `outputs` can be None if you call `set_trace(false)` which disables
        the output printing.

        timeout: the milliseconds to wait for the evaluation to finish.
        cmd_timeout: the milliseconds to wait for every single command other than sledgehammer and auto_sledgehammer.
        """
        self._chk_live()
        if not isinstance(source, str):
            raise ValueError("the argument source must be a string")
        if timeout is not None and not isinstance(timeout, int):
            raise ValueError("the argument timeout must be an integer")
        if cmd_timeout is not None and not isinstance(cmd_timeout, int):
            raise ValueError("the argument cmd_timeout must be an integer")
        if configs is not None and not isinstance(configs, dict):
            raise ValueError("the argument configs must be a dictionary of strings")
        if configs and not all(isinstance(k, str) and isinstance(v, str) for k, v in configs.items()):
            configs = {k: str(v) for k, v in configs.items()}
        if import_dir is not None and not isinstance(import_dir, str):
            raise ValueError("the argument import_dir must be a string")
        if base_dir is not None and not isinstance(base_dir, str):
            raise ValueError("the argument base_dir must be a string")
        if import_dir is not None:
            import_dir = os.path.abspath(import_dir)
        if base_dir is not None:
            base_dir = os.path.abspath(base_dir)
        if timeout is None and import_dir is None and timeout is None and cmd_timeout is None and configs is None:
            mp.pack(source, self.cout)
        else:
            mp.pack("\x05eval", self.cout)
            mp.pack((source, timeout, cmd_timeout, import_dir, base_dir, configs), self.cout)
        self.cout.flush()
        ret = Client._parse_control_(self.unpack.unpack())
        if ret is None:
            return None
        else:
            return [CommandOutput.parse(output) for output in ret]

    def set_trace(self, trace):
        """
        By default, Isabelle REPL will collect all the output of every command,
        which causes the evaluation very slow.
        You can set the `trace` to false to disable the collection of the outputs,
        which speeds up the REPL a lot.
        """
        self._chk_live()
        if not isinstance(trace, bool):
            raise ValueError("the argument trace must be a string")
        mp.pack("\x05trace" if trace else "\x05notrace", self.cout)
        self.cout.flush()
        Client._parse_control_(self.unpack.unpack())

    def set_register_thy(self, value):
        self._chk_live()
        if not isinstance(value, bool):
            raise ValueError("the argument value must be a string")
        mp.pack("\x05register_thy" if value else "\x05no_register_thy", self.cout)
        self.cout.flush()
        Client._parse_control_(self.unpack.unpack())

    def lex(self, source):
        """
        This method splits the given `source` into a sequence of code pieces.
        Each piece is a string led by the keyword of a command, and no symbol
        occurs before this keyword).
        A piece contains exactly one command.
        Comments and blank spaces are usualy appended to the command before them.
        However, leading comments and spaces that occur before any command are discarded.
        """
        self._chk_live()
        if not isinstance(source, str):
            raise ValueError("the argument source must be a string")
        mp.pack("\x05lex", self.cout)
        mp.pack(source, self.cout)
        self.cout.flush()
        ret = Client._parse_control_(self.unpack.unpack())
        ret = [(__unpack_translated_position__(pos), src) for pos, src in ret]
        #__repair_positions__(ret)
        return ret

    def lex_file(self, file):
        self._chk_live()
        if not isinstance(file, str):
            raise ValueError("the argument file must be a string")
        mp.pack("\x05lex_file", self.cout)
        mp.pack(os.path.abspath(file), self.cout)
        self.cout.flush()
        ret = Client._parse_control_(self.unpack.unpack())
        ret = [(__unpack_position__(pos), src) for pos, src in ret]
        return ret

    def fast_lex(self, source):
        """
        A faster but inaccurate version of `lex`.
        `lex` has to load all imports of the target source in order to use the correct
        set of Isar keywords (since libraries can define their own keywords).
        This faster version just use the predefined system keywords of Isar, so it
        doesn't need to load any imports but can fail to parse some user keywords.
        """
        self._chk_live()
        if not isinstance(source, str):
            raise ValueError("the argument source must be a string")
        mp.pack("\x05lex'", self.cout)
        mp.pack(source, self.cout)
        self.cout.flush()
        ret = Client._parse_control_(self.unpack.unpack())
        ret = [(__unpack_translated_position__(pos), src) for pos, src in ret]
        #__repair_positions__(ret)
        return ret

    def plugin(self, name, ML, thy='Isa_REPL.Isa_REPL'):
        """
        Isa-REPL allows clients to insert user-specific plugins to collect data
        directly from Isabelle's internal representations about proof states, lemma
        context, lemma storage, and any other stuff of Isabelle.

        Argument `name` should uniquely identify the plugin. (We highly recommend the
            length of the `name` to be short. The name will be printed in the output
            of every command application, so a long name can consume too much bandwidth.)
        Arugment `ML` is the source code of the plugin.

        A plugin must be written in Isabelle/ML, a dialect of Standard Meta
        Language (cf., Isabelle's <implementation> manual, chapter 0 <Isabelle/ML>).
        Sadly, writing a plugin requires Isabelle development knowledge which is
        not well documented. Basically, you need to read Isabelle's source code, or
        pursue helps from some experts like me <xqyww123@gmail.com> :)
        You could find [some examples](https://github.com/xqyww123/Isa-REPL/tree/main/examples/example_plugin.py).

        A plugin must be a ML value having ML type
            Toplevel.state -> MessagePackBinIO.Pack.raw_packer option *
                              Toplevel.state option

        Type `Toplevel.state` represents the entire state of an Isabelle evaluation
        context. This type is defined in `$ISABELLE_HOME/src/Pure/Isar/toplevel.ML`.
        The same file also provides useful interfaces to allow you to for example,
        access the proof state by `Toplevel.proof_of`.
        ($ISABELLE_HOME is the base directory of your Isabelle installation,
         you could run `isabelle getenv -b ISABELLE_HOME` to obtain this location).

        Type `MessagePackBinIO.Pack.raw_packer` is defined in [our library](https://github.com/xqyww123/Isa-REPL/blob/main/contrib/mlmsgpack/mlmsgpack.sml?plain=1#L10).
        It is basically the result of applying a packer to a value that you want to export,
        e.g., `MessagePackBinIO.Pack.packBool True`.

        So, a plugin is a function accepting the evaluation state and returning optionally
        two values `(raw_packer, new_state)`.

        `raw_packer` embeds any data you want to export and the way how to encode it into
        messagepack format. The encoded data will be sent to this Python client, and unpacked
        automatically using the `msgpack` Python package.
        Set `raw_packer` to NONE if you have nothing to send to the Python client.

        `new_state` allows you to alter the evaluation state. Set `new_state` to NONE if you
        do not want to change the state.
        """
        self._chk_live()
        if not isinstance(thy, str):
            raise ValueError("the argument thy must be a string")
        if not isinstance(name, str):
            raise ValueError("the argument name must be a string")
        if not isinstance(ML, str):
            raise ValueError("the argument ML must be a string")
        mp.pack("\x05plugin", self.cout)
        mp.pack(thy, self.cout)
        mp.pack(name, self.cout)
        mp.pack(ML, self.cout)
        self.cout.flush()
        return Client._parse_control_(self.unpack.unpack())

    def unplugin(self, name):
        """
        Remove an installed plugin.
        Argument `name` must be the name passed to the `plugin` method.
        This interface sliently does nothing if no plugin named `name` is installed.
        """
        self._chk_live()
        if not isinstance(name, str):
            raise ValueError("the argument name must be a string")
        mp.pack("\x05unplugin", self.cout)
        mp.pack(name, self.cout)
        self.cout.flush()
        Client._parse_control_(self.unpack.unpack())

    def record_state(self, name):
        """
        Record the current evaluation state so that later you could rollback to
        this state using name `name`.
        """
        self._chk_live()
        if not isinstance(name, str):
            raise ValueError("the argument name must be a string")
        mp.pack("\x05record", self.cout)
        mp.pack(name, self.cout)
        self.cout.flush()
        Client._parse_control_(self.unpack.unpack())

    def clean_history(self):
        """
        Remove all recorded states.
        """
        self._chk_live()
        mp.pack("\x05clean_history", self.cout)
        self.cout.flush()
        Client._parse_control_(self.unpack.unpack())

    def rollback(self, name):
        """
        Rollback to a recorded evaluation state named `name`.
        This method returns a description about the state just restored.
        However, the `command_name` fielld will be always an empty string,
        `output` be an empty list, and `latex` be NONE, because no command is executed.
        """
        self._chk_live()
        if not isinstance(name, str):
            raise ValueError("the argument name must be a string")
        mp.pack("\x05rollback", self.cout)
        mp.pack(name, self.cout)
        self.cout.flush()
        ret = Client._parse_control_(self.unpack.unpack())
        return CommandOutput.parse(ret)

    def history(self) -> dict[str, CommandOutput]:
        """
        Returns the names of all recorded states
        This method returns descriptions about all the recorded states.
        However, the `command_name` fielld will be always an empty string,
        `output` be an empty list, and `latex` be NONE, because no command is executed.
        """
        self._chk_live()
        mp.pack("\x05history", self.cout)
        self.cout.flush()
        ret = Client._parse_control_(self.unpack.unpack())
        return {k: CommandOutput.parse(v) for k, v in ret.items()}


    def hammer (self, timeout):
        """
        Invoke Isabelle Sledgehammer within an indicated timeout (in seconds, and 0 means no timeout).
        Returns obtained tactic scripts if succeeds; or raises REPLFail on failure.
        The returned tactic `tac` is a string ready to be invoked by `apply (tac)`.
        Regardless if the hammer success, the REPL state will not be changed.
        You must manually evaluate `apply (tac)` to apply the obtained tactics.

        You could evaluate `declare [[REPL_sledgehammer_params = "ANY SLEDGEHAMMER PARAMETERS HERE"]]`
        to configure any Sledgehammer settings to be used in this interface.
        The configure takes the same syntax as indicated in the Sledgehammer reference (as attached
        to the Isabelle software package).
        Example: `declare [[REPL_sledgehammer_params = "provers = \\"cvc4 e spass vampire\\", minimize = false, max_proofs = 10"]]`
        Note: use *SINGLE* backslash in the code to be evaluated.

        This sledgehammer process is smart and will only return the first encoutered successful proofs.
        It pre-play every reported proof to test if it could be finish within a time limit. If not,
        it will not considered as a successful proof and be discarded; otherwise, the proof is returned
        immediately killing all other parallel attempts.
        This pre-play time limit is configurable by evaluating
        `delcare [[REPL_sledgehammer_preplay_timeout = <ANY SECONDS>]]`
        e.g, `delcare [[REPL_sledgehammer_preplay_timeout = 6]]`
        """
        self._chk_live()
        if not isinstance(timeout, int):
            raise ValueError("the argument name must be an integer")
        mp.pack("\x05hammer", self.cout)
        mp.pack(timeout, self.cout)
        self.cout.flush()
        return Client._parse_control_(self.unpack.unpack())

    def context(self, pp='pretty'):
        """
        @return:
        A tuple of (
            local_facts: dict[str, thm],
            assumptions: [thm],
            binding: dict[str, (typ, term)], where the key is the name of the binding,
                note, a binding is something that appears like `?x` in Isabelle, e.g., let ?binding = 123.
            (fixed term variabls, fixed type variables): (dict[str, typ], dict[str, sort]),
            goals: [term]
        )
        where thm := str, in the encoding indicated by the pretty printer `pp`
            typ := str, in the encoding indicated by the pretty printer `pp`
            sort := [str]

        This retrival doesn't change the state of REPL.

        The formatter of S expression is given in ../library/REPL_serializer.ML:s_expr.
        """
        self._chk_live()
        if not isinstance(pp, str):
            raise ValueError("the argument pp must be a string")
        mp.pack("\x05context", self.cout)
        mp.pack(pp, self.cout)
        self.cout.flush()
        return Client._parse_control_(self.unpack.unpack())

    @staticmethod
    def parse_ctxt(raw):
        return {
            'local_facts': raw[0],
            'assumptions': raw[1],
            'bindings': raw[2],  # {name => (typ, term)}
            'fixed_terms': raw[3][0],
            'fixed_types': raw[3][1],
            'goals': raw[4]
        }

    def silly_context(self, s_expr):
        self._chk_live()
        return Client.parse_ctxt(self.context(s_expr))

    def sexpr_term(self, term):
        """
        Parse a term and translate it into S-expression that reveals the full names
        of all overloaded notations.
        This interface can be called only under certain theory context, meaning you
        must have evaluated certain code like `theory THY imports Main begin` using
        the `eval` interface.
        """
        self._chk_live()
        if not isinstance(term, str):
            raise ValueError("the argument term must be a string")
        mp.pack("\x05sexpr_term", self.cout)
        mp.pack(term, self.cout)
        self.cout.flush()
        return Client._parse_control_(self.unpack.unpack())

    def fact(self, names):
        """
        Retreive a fact like a lemma, a theorem, or a corollary.
        The argument `names` has the same syntax with the argument of Isabelle command `thm`.
        Attributes are allowed, e.g., `HOL.simp_thms(1)[symmetric]`
        Names must be separated by space, e.g., `HOL.simp_thms conj_cong[symmetric] conjI`
        A list of pretty-printed string of the facts will be returned in the same order of the names.
        """
        self._chk_live()
        if not isinstance(names, str):
            raise ValueError("the argument `names` must be a string")
        mp.pack("\x05fact", self.cout)
        mp.pack(names, self.cout)
        self.cout.flush()
        return Client._parse_control_(self.unpack.unpack())

    def sexpr_fact(self, names):
        """
        Similar with `fact` but returns the S-expressions of the terms of the facts.
        """
        self._chk_live()
        if not isinstance(names, str):
            raise ValueError("the argument `names` must be a string")
        mp.pack("\x05sexpr_fact", self.cout)
        mp.pack(names, self.cout)
        self.cout.flush()
        return Client._parse_control_(self.unpack.unpack())

    def set_thy_qualifier(self, thy_qualifier):
        """
        Change `thy_qualifier`.
        See `Client.__init__` for the explaination of `thy_qualifier`
        Returns None if success.
        """
        self._chk_live()
        if not isinstance(thy_qualifier, str):
            raise ValueError("the argument `thy_qualifier` must be a string")
        mp.pack("\x05qualifier", self.cout)
        mp.pack(thy_qualifier, self.cout)
        self.cout.flush()
        return Client._parse_control_(self.unpack.unpack())

    def session_name_of(self, path):
        """
        Given a `path` to an Isabelle theory file, `session_name_of` returns
        the name of the session containing the theory file, or None if fails
        to figure this out.
        """
        self._chk_live()
        if not isinstance(path, str):
            raise ValueError("the argument `path` must be a string")
        mp.pack("\x05session-of", self.cout)
        mp.pack(path, self.cout)
        self.cout.flush()
        return Client._parse_control_(self.unpack.unpack())

    def run_app(self, name):
        """
        Run user-defined applications.
        An application is an ML program registered through `REPL_Server.register_app`.
        It takes over the control of the in- and the out-socket stream, permitting the user
        to do anything he wants.
        """
        self._chk_live()
        if not isinstance(name, str):
            raise ValueError("the argument `name` must be a string")
        mp.pack("\x05app", self.cout)
        mp.pack(name, self.cout)
        self.cout.flush()
        found = Client._parse_control_(self.unpack.unpack())
        if not found:
            raise KeyError
        return None

    def run_ML(self, thy, src):
        """
        Execute ML code in the global state of the Isabelle runtime.
        """
        self._chk_live()
        if thy is not None and not isinstance(thy, str):
            raise ValueError("the argument `thy` must be a string")
        if not isinstance(src, str):
            raise ValueError("the argument `src` must be a string")
        mp.pack("\x05ML", self.cout)
        mp.pack((thy, src), self.cout)
        self.cout.flush()
        Client._parse_control_(self.unpack.unpack())
        return None

    def load_theory(self, targets, thy_qualifier=""):
        """
        Load theories. Short names can be used if the thy_qualifier is indicated.
        Otherwise, full names must be used.
        The target theories to be loaded must be registered to the Isabelle system
        through the `isabelle component -u` commands.
        The method returns the full names of the loaded theories.
        """
        self._chk_live()
        if not isinstance(thy_qualifier, str):
            raise ValueError("the argument `thy_qualifier` must be a string")
        if not is_list_of_strings(targets):
            raise ValueError("the argument `targets` must be a list of strings")
        mp.pack("\x05load", self.cout)
        mp.pack((thy_qualifier, targets), self.cout)
        self.cout.flush()
        return Client._parse_control_(self.unpack.unpack())

    def file(self, path : str, line : int = ~1, column : int = 0,
             timeout : int | None = None, attrs : list[str] = [],
             cache_position : bool = False, use_cache : bool = False):
        """
        Evaluate the file at the given path.
        This method only returns erros encountered during the evaluation.
        The evaluation may continue from a previously cached position if `use_cache` is True.
        The state at the position can be cached to be reused by later `file` calls if `cache_position` is True.

        Argument line and column indicate the REPL to evaluate all code
        until the first `column` characters at the `line`, meaning the REPL
        will stop at the position `line:column`.

        Timeout: the milliseconds to wait for the evaluation to finish.
        """
        self._chk_live()
        if not isinstance(path, str):
            raise ValueError("the argument `path` must be a string")
        if not isinstance(line, int):
            raise ValueError("the argument `line` must be an int")
        if not isinstance(column, int):
            raise ValueError("the argument `column` must be an int")
        if not isinstance(timeout, int | None):
            raise ValueError("the argument `timeout` must be an int or None")
        if not isinstance(cache_position, bool):
            raise ValueError("the argument `cache_position` must be a bool")
        if not isinstance(use_cache, bool):
            raise ValueError("the argument `use_cache` must be a bool")
        if not isinstance(attrs, list):
            raise ValueError("the argument `attrs` must be a list")
        pos = None
        if line >= 0:
            pos = (line, column)
        mp.pack("\x05file", self.cout)
        mp.pack((path, pos, timeout, cache_position, use_cache, attrs), self.cout)
        self.cout.flush()
        errs = Client._parse_control_(self.unpack.unpack())
        if errs:
            raise REPLFail('\n'.join(errs))
        return None

    def clean_cache(self):
        """
        Clean the evaluation cache recorded by the `file` method.
        """
        self._chk_live()
        mp.pack("\x05clean_cache", self.cout)
        self.cout.flush()
        return Client._parse_control_(self.unpack.unpack())

    def add_lib(self, libs):
        """
        Add additional `libs` that will be loaded whenever evaluating a theory.
        :param libs:
        All names must be fully qualified, e.g. "HOL-Library.Sublist" instead of "Sublist"
        :return:
        None
        """
        self._chk_live()
        if not is_list_of_strings(libs):
            raise ValueError("the argument `libs` must be a list of strings")
        mp.pack("\x05addlibs", self.cout)
        mp.pack(libs, self.cout)
        self.cout.flush()
        return Client._parse_control_(self.unpack.unpack())

    def num_processor (self):
        """
        :return: the number of processors available
        """
        self._chk_live()
        mp.pack("\x05numcpu", self.cout)
        self.cout.flush()
        ret = Client._parse_control_(self.unpack.unpack())
        if ret <= 0:
            ret = 1
        return ret

    def set_cmd_timeout(self, timeout):
        """
        Set the timeout for commands other than sledgehammer and auto_sledgehammer.
        """
        self._chk_live()
        if not (isinstance(timeout, int) or timeout is None):
            raise ValueError("the argument `timeout` must be an int or None")
        mp.pack("\x05cmd_timeout", self.cout)
        mp.pack(timeout, self.cout)
        self.cout.flush()
        return Client._parse_control_(self.unpack.unpack())

    def kill(self):
        """
        Kill the entire server
        """
        os.kill(self.pid, signal.SIGKILL)

    @staticmethod
    def pretty_unicode(src):
        """
        Argument src: Any script that uses Isabelle's ASCII notation like `\\<Rightarrow>`
        Return: unicode version of `src`
        """
        # map every substring `s` in src to SYMBOLS[s] if s in SYMBOLS, otherwise s
        # Use a regular expression to find all potential Isabelle symbols
        pattern = r'\\<[^>]+>'
        subscript_pattern = r'⇩.|⇧.|❙.'
        
        # Function to replace each match with its Unicode equivalent if available
        def replace_symbol(match):
            symbol = match.group(0)
            return get_SYMBOLS().get(symbol, symbol)
        
        def replace_subsupscript(match):
            symbol = match.group(0)
            if symbol in SUBSUP_TRANS_TABLE:
                return SUBSUP_TRANS_TABLE[symbol]
            return symbol
        
        # Use re.sub to efficiently perform all replacements at once
        return re.sub(subscript_pattern, replace_subsupscript, re.sub(pattern, replace_symbol, src))

    @staticmethod
    def unicode_of_ascii(src):
        return Client.pretty_unicode(src)

    @staticmethod
    def ascii_of_unicode(src):
        """
        Argument src: Any unicode string
        Return: Isabelle's ASCII version of `src`.
        This method is the reverse of `pretty_unicode`.
        """
        # map every character `c` in `src` to REVERSE_SYMBOLS[c] if c in REVERSE_SYMBOLS, otherwise c
        # Use str.translate with a translation table for maximum efficiency
        trans_table = get_SYMBOLS_AND_REVERSED()[2]
        return src.translate(SUBSUP_RESTORE_TABLE_trans).translate(trans_table)

    def path_of_theory(self, theory_name, master_directory):
        self._chk_live()
        if not isinstance(theory_name, str):
            raise ValueError("the argument `theory_name` must be a string")
        if not isinstance(master_directory, str):
            raise ValueError("the argument `master_directory` must be a string")
        mp.pack("\x05path", self.cout)
        mp.pack((master_directory, theory_name), self.cout)
        self.cout.flush()
        return Client._parse_control_(self.unpack.unpack())
    
    def parse_thy_header(self, header_src):
        """
        Return: (fully_quantified_theory_name, theorys to import, keyword declarations)
        where fully_quantified_theory_name is a string,
              `theorys to import` is a list of strings, qualified or not as in the same shape of the given source,
        """
        self._chk_live()
        if not isinstance(header_src, str):
            raise ValueError("the argument `header_src` must be a string")
        lines = self.fast_lex(header_src)
        theory_line = None
        for _, line in lines:
            if line.strip().startswith('theory'):
                theory_line = line.strip()
                break
        if not theory_line:
            raise ValueError("no `theory` declaration found in the given `header_src`")
        mp.pack("\x05thy_header", self.cout)
        mp.pack(theory_line, self.cout)
        self.cout.flush()
        return Client._parse_control_(self.unpack.unpack())
    
    def translate_position(self, src : str) -> Callable[[int | IsabellePosition], int | Position]:
        if not isinstance(src, str):
            raise ValueError("the argument `src` must be a string")
        mp.pack("\x05symbpos", self.cout)
        mp.pack(src, self.cout)
        self.cout.flush()
        symbs = Client._parse_control_(self.unpack.unpack())
        
        # Implementation of column_of_pos functionality from SML
        # symbs is a list of strings (symbols), equivalent to Vector.map fst (Symbol_Pos.explode ...)
        # In SML, vectors are 1-indexed, but Python lists are 0-indexed
        ofs = 1
        line = 1
        colm = 1
        
        def calc(offset):
            nonlocal ofs, line, colm, symbs
            """Calculate line and column for a given offset (1-based)"""
            # offset corresponds to Position.offset_of pos
            # In Isabelle, symbol indices correspond to character offsets
            if offset < ofs:
                # Reset if we need to go backwards
                ofs = 1
                line = 1
                colm = 0
            
            # Walk through symbols until we reach the target offset
            # ofs is 1-based index, so we subtract 1 to access Python list
            while ofs < offset:
                idx = ofs - 1  # convert to 0-based index for Python list
                if idx < len(symbs):
                    s = symbs[idx]
                    if s == "\n":
                        line += 1
                        ofs += 1
                        colm = 1
                    else:
                        ofs += 1
                        colm += len(s)
                else:
                    break
            s = symbs[ofs - 1]
            if len(s) > 1:
                return colm - len(s) + 1
            else:
                return colm
        
        def translate(pos):
            if isinstance(pos, int):
                return calc(pos)
            elif isinstance(pos, IsabellePosition):
                column = calc(pos.raw_offset)
                return Position(pos.line, column, pos.file)
            else:
                raise TypeError("`pos` must be either an IsabellePosition or an integer")
        return translate

    
    def premise_selection(self, mode, number : int, methods : list[str], params : dict[str, str] = {}, printer : str='pretty'):
        """
        Conduct the premise selection provided by Sledgehammer.
        @param number: the number of relevant premises to return
        @param methods: the methods to use for premise selection, any of ['mesh', 'mepo', 'mash']
        @param params: the parameters sent to Sledgehammer. Check Sledgehammer's user guide for details.
        @param printer: the printer to print the expressions of the retrived lemmas,
                        'pretty' for the system pretty printing, 'sexpr' for S-expression.
        @param mode: the mode of the premise selection, any of ['leading', 'final', 'each'].
            'leading': only select the lemmas relevant to the leading goal.
            'final' : select the lemmas relevant to the final goal(s).
                      Multiple final goals are connected by '&&' to be considered as a single goal.
            'each'  : select the lemmas relevant to each goal.
        @return:
            for mode = 'leading' or 'final':
                return a dictionary from the name of the retrived lemmas to their expressions.
            for mode = 'each':
                return a list of such dictionaries for each of the subgoal.
        """
        self._chk_live()
        if not isinstance(number, int):
            raise ValueError("the argument `number` must be an int")
        if not isinstance(methods, list):
            raise ValueError("the argument `methods` must be a list")
        if not isinstance(params, dict):
            raise ValueError("the argument `params` must be a dict")
        if not isinstance(printer, str):
            raise ValueError("the argument `printer` must be a string")
        if not isinstance(mode, str):
            raise ValueError("the argument `mode` must be a string")
        mp.pack("\x05premise_selection", self.cout)
        mp.pack((number, methods, params, printer, mode), self.cout)
        self.cout.flush()
        return Client._parse_control_(self.unpack.unpack())

    def _health_of_clients(self):
        """
        Return a dictionary from the client_id to (live : bool, errors since last check : string list).
        You may use `Client.clients` to check the Client instance from the client_id.
        """
        self._chk_live()
        mp.pack("\x05diagnosis", self.cout)
        self.cout.flush()
        return dict(Client._parse_control_(self.unpack.unpack()))

    def config(self, atrributes : list[str]):
        if not isinstance(atrributes, list):
            raise ValueError("the argument `atrributes` must be a list")
        if not all(isinstance(attr, str) for attr in atrributes):
            raise ValueError("every element in `atrributes` must be a string")
        self._send_call1("\x05config", atrributes)
        return self._read()
    
    _watchers = {}
    _watchers_lock = threading.Lock()
    @classmethod
    def install_watcher(cls, addr, handler, interval : int = 2, allow_multiple_watchers : bool = False, replace_existing : bool = True, verbose = False):
        """
        Install a watcher to monitor the health of each client regularly.

        handler: a funciton handling any abnormal status of client,
                 of type (client_id, (is_live : bool, errors : string list)) -> None
        interval: the interval in seconds to check the health of each client, default to 2 seconds.
        allow_multiple_watchers: By default, only one watcher is allowed. But you can allow multiple watchers
            by truning on this flag. Note, each error message can only be dispatched to one watcher of the multiple watchers randomly.
        """
        def _watcher_loop(stop):
            with Client(addr, 'HOL') as client:
                while not stop.is_set():
                    health_of_clients = client._health_of_clients()
                    for cid, (is_live, errors) in health_of_clients.items():
                        if verbose or not is_live or errors:
                            handler(cid, (is_live, errors))
                    bads = None
                    for cid, cc in cls.clients.items():
                        if cid not in health_of_clients:
                            handler(cid, (False, []))
                            if bads is None:
                                bads = []
                            bads.append(cc)
                    if bads is not None:
                        for cc in bads:
                            cc.close()
                    time.sleep(interval)
        with cls._watchers_lock:
            if addr in cls._watchers:
                watchers = cls._watchers[addr]
            else:
                watchers = []
                cls._watchers[addr] = watchers
            if replace_existing:
                for _, stop in watchers:
                    stop.set()
                watchers.clear()
            if watchers and not allow_multiple_watchers:
                raise ValueError("Only one watcher is allowed")
            stop = threading.Event()
            watcher_thread = threading.Thread(target=_watcher_loop, args=(stop,))
            watcher_thread.daemon = True
            watchers.append((watcher_thread, stop))
        watcher_thread.start()





