#!/usr/bin/env python3
import sys
import os
import json
import re
import argparse
import subprocess
import logging
import time

# global configuration object
configuration = {}

# ----------------------------------------------------------------------------------------------------------------------------------
# Sample usage:
#
#   {"command":"ls", "file_extensions":["*.cpp","*.hpp","*.h"], "exclude_patterns": ["build-debug"], "exclude_extensions": ["*.o","*.pyc"], "root_dir":"$HOME/devl/codelite"}
#   {"command":"find", "file_extensions": [".cpp",".hpp",".h"], "root_dir": "/c/src/codelite/LiteEditor", "find_what": "frame", "whole_word": false, "icase": true, "exclude_patterns": ["build-debug"]}
#   {"command":"write_file", "path": "/tmp/myfile.txt", "content": "hello world"}
#   {"command":"exec", "cmd": "/usr/bin/passwd", "wd": "/c/src/codelite/AutoSave", "env": [{"name":"PATH", "value":"/c/src/codelite/Runtime"}]}
#   {"command": "locate", "path": "/usr/bin", "name": "clangd", "ext": "", "versions": [15,14,13,12,11,10,9,8,7,6]}
#   {"command": "find_path", "path": "$HOME/devl/codelite/LiteEditor/.git"}
#   {"command": "list_lsps"}
#
# Command line usage:
#   python3 codelite-remote.py --context builder
#
# ----------------------------------------------------------------------------------------------------------------------------------


def print_message_terminator():
    """
    Prints a message terminator string used for code remote communication.

    This function outputs the specific string ">>codelite-remote-msg-end<<"
    which serves as a marker for end of message in code remote operations.
    """
    print(">>codelite-remote-msg-end<<")


def _load_config_file(filepath):
    """
    Loads a configuration file from the specified filepath and parses it as JSON.

    This function attempts to read and parse a JSON configuration file. If the
    file exists and contains valid JSON, it updates the global 'configuration'
    variable with the parsed content. If the file doesn't exist or contains
    invalid JSON, appropriate error messages are logged and False is returned.

    Args:
        filepath (str): The path to the configuration file to be loaded

    Returns:
        bool: True if the configuration file was successfully loaded and parsed,
              False otherwise

    Example:
        >>> _load_config_file("/path/to/config.json")
        True

    Note:
        This function modifies the global 'configuration' variable on successful
        load. It uses logging to report success or failure of the operation.
    """
    if not os.path.exists(filepath):
        return False

    config_content = open(filepath, "r").read()
    try:
        global configuration
        configuration = json.loads(config_content)
        logging.info(
            "successfully loaded configuration file: {}".format(filepath)
        )
        return True
    except json.decoder.JSONDecodeError as e:
        logging.error("Failed to load configuration file: {}".format(filepath))
        logging.error(e)

    return False


def _create_empty_config_file_if_not_exists(directory):
    """
    Create an empty configuration file if it doesn't already exist.

    This function checks if a configuration file named 'codelite-remote.json' exists
    in the specified directory. If the file doesn't exist, it creates the file with
    an empty JSON object. The function also ensures that the directory exists by
    creating it if necessary, handling the case where the directory already exists.

    Args:
        directory (str): The directory path where the configuration file should be created

    Returns:
        None

    Example:
        >>> _create_empty_config_file_if_not_exists("/home/user/.config")
        # Creates /home/user/.config/codelite-remote.json with empty JSON object

    Note:
        This function uses logging to report its actions and handles OSError exceptions
        when creating directories.
    """
    config_file = "{}/codelite-remote.json".format(directory)
    if os.path.exists(config_file):
        return

    # create a default configuration file
    logging.info("creating empty configuration file: {}".format(config_file))
    try:
        # Create the directory in the path
        os.makedirs(directory, exist_ok=True)
        logging.info("successfully created directory {}".format(directory))
    except OSError as error:
        logging.debug("directory {} already exists".format(directory))

    fp = open(config_file, "w")
    fp.write("{}")
    fp.close()


def load_configuration():
    """
    Save configuration to a file.

    This function creates the directory path if it doesn't exist and writes
    an empty JSON object to the configuration file. It handles the case where
    the directory already exists by catching OSError exceptions.

    The configuration file is saved in the current working directory with
    the name 'codelite-remote.json'.
    """
    curdir = os.path.dirname(__file__)
    local_config_file = "{}/codelite-remote.json".format(curdir)

    # try to load the local configuration first
    config_loaded = _load_config_file(local_config_file)
    if not config_loaded:
        # we could not find a local configuration file -
        # try loading the global one (`~/.codelite/codelite-remote.json`)
        # if a global configuration file does not exist, create an empty one
        home_dir = os.path.expandvars("$HOME/.codelite")
        global_config_file = "{}/codelite-remote.json".format(home_dir)
        _create_empty_config_file_if_not_exists(home_dir)
        config_loaded = _load_config_file(global_config_file)
    return config_loaded


def write_file(cmd):
    """
    Load the global CodeLite remote configuration file.

    This function attempts to load the global configuration file located at
    '~/.codelite/codelite-remote.json'. If the file does not exist, it creates
    an empty configuration file in the ~/.codelite directory before loading.

    Returns:
        The loaded configuration data from the JSON file

    Note:
        This function modifies the filesystem by creating an empty config file
        if one doesn't already exist.
    """
    try:
        fp = open(cmd["path"], "w")
        fp.write(cmd["content"])
        fp.close()
    except Exception as e:
        logging.error("write_file error: {}".format(e))
    print_message_terminator()


def run_command(command, working_directory=None, env=None):
    """
    Execute a command and return its output.

    This function runs a shell command in the specified working directory with the given environment.
    It captures the command's output and returns it along with a success status.

    Args:
        command (str): The command to execute
        working_directory (str, optional): The directory to run the command in
        env (dict, optional): Environment variables to use for the command

    Returns:
        tuple: A tuple containing (success_flag, output_bytes) where success_flag is a boolean
               indicating if the command succeeded, and output_bytes contains the command output

    Note:
        This function uses subprocess.check_output which will raise an exception if the command fails.
        The function handles exceptions internally and prints error messages to stdout.
    """
    try:
        completed_proc = subprocess.run(
            args=command,
            cwd=working_directory,
            shell=True,
            env=env,
            stdin=subprocess.PIPE,
        )

    except Exception as e:
        print(f"error: command `{command}` exited with error. {e}")


def run_command_and_return_output(command, working_directory=None, env=None):
    """
    Execute a command and return its output as a string.

    This function runs a shell command with optional working directory and environment variables.
    It captures the standard output and returns it as a decoded UTF-8 string.

    Args:
        command (str): The command to execute
        working_directory (str, optional): The directory to run the command in
        env (dict, optional): Environment variables to use for the command

    Returns:
        tuple: A tuple containing (success_flag, output_or_error_message)
               - success_flag (bool): True if command succeeded, False otherwise
               - output_or_error_message (str): Command output on success, error message on failure
    """
    try:
        return True, subprocess.check_output(
            args=command,
            cwd=working_directory,
            shell=True,
            env=env,
            stderr=subprocess.STDOUT,
        ).decode("utf-8")

    except Exception as e:
        return False, f"error: {e}"


def expand_vars(s):
    """
    Expand user home directory and environment variables in a string.

    This function first expands the user home directory (~) using os.path.expanduser(),
    then expands any environment variables using os.path.expandvars().

    Args:
        s (str): The input string to expand

    Returns:
        str: The expanded string with user home directory and environment variables replaced
    """
    expanded = os.path.expanduser(s)
    expanded = os.path.expandvars(expanded)
    return expanded


def on_exec(cmd):
    """
    Execute command and print its output

    Args:
        cmd (dict): Command configuration containing 'env', 'wd', and 'cmd' keys
    """
    # preare the environment
    env_dict = dict(os.environ.copy())
    user_env = cmd["env"]
    for env_entry in user_env:
        name = env_entry["name"]
        value = expand_vars(env_entry["value"])
        if env_dict.get(name, -1) == -1:
            env_dict[name] = value
        else:
            env_dict[name] = "{}:{}".format(value, env_dict[name])

    working_directory = expand_vars(cmd["wd"])
    command = expand_vars(cmd["cmd"])
    run_command(command, working_directory=working_directory, env=env_dict)

    # always print the terminating message
    print_message_terminator()


def get_list_files_commands(cmd):
    """
    Generate a shell command to find files with specified extensions and exclude certain patterns.

    This function constructs a 'find' command that searches for files in a given directory
    with specific extensions, while optionally excluding files with certain extensions
    or matching specific patterns.

    Args:
        cmd (dict): A dictionary containing the following keys:
            - root_dir (str): The directory to search in
            - file_extensions (list): List of file extensions to include
            - exclude_extensions (list, optional): List of file extensions to exclude
            - exclude_patterns (list, optional): List of patterns to exclude from results

    Returns:
        str: The constructed shell command string

    Example:
        >>> cmd = {
        ...     "root_dir": "/home/user",
        ...     "file_extensions": [".txt", ".py"],
        ...     "exclude_extensions": [".tmp"],
        ...     "exclude_patterns": ["backup"]
        ... }
        >>> get_list_files_commands(cmd)
        'find /home/user -type f -name ".txt" -o -name ".py" ! -name ".tmp"|grep -v "backup"'
    """
    logging.info(f"Processing request: {cmd}")
    where = cmd["root_dir"]
    extensions = cmd["file_extensions"]
    exclude_extensions = None
    exclude_patterns = None

    if "exclude_extensions" in cmd:
        exclude_extensions = cmd["exclude_extensions"]

    if "exclude_patterns" in cmd:
        exclude_patterns = cmd["exclude_patterns"]

    command = f"find {where} -type f "
    first_ext = True
    for ext in extensions:
        if not first_ext:
            command += " -o "
        else:
            first_ext = False
        command += f'-name "{ext}" '

    # Exclude these file extensions
    if exclude_extensions and len(exclude_extensions) > 0:
        for ext in exclude_extensions:
            command += f'! -name "{ext}"'

    # add exclude patterns
    if exclude_patterns and len(exclude_patterns) > 0:
        command += "|"
        for exclude_pat in exclude_patterns:
            command += f'grep -v "{exclude_pat}"|'
        # strip the last "|"
        command = command[:-1]
    command += "| sort"
    logging.info(f"Running command: {command}")
    return command


def get_files(cmd):
    """
    Retrieve files using the provided command.

    This function executes a command to find files and returns them as a set.
    If the command execution fails, None is returned.

    Args:
        cmd: The command to execute for finding files

    Returns:
        A set of files if successful, None if the command execution fails

    Example:
        >>> get_files("find . -name '*.py'")
        {'file1.py', 'file2.py'}
    """
    files = set()
    find_cmd = get_list_files_commands(cmd)
    success, find_output = run_command_and_return_output(find_cmd)
    if success == False:
        return None

    files_arr = find_output.splitlines()
    current_batch: str = ""
    for i in range(0, len(files_arr)):
        current_batch += f'"{files_arr[i]}" '
        if i % 17 == 16:
            current_batch = current_batch.rstrip()
            files.add(current_batch)
            current_batch = ""

    current_batch = current_batch.strip()
    if len(current_batch) > 0:
        files.add(current_batch)

    if len(files) == 0:
        return None
    else:
        return files


def on_find_files(cmd):
    """
    Find list of files with a given extension and from a given root directory

    Example command:

    {"command":"ls", "file_extensions":["*.cpp","*.hpp","*.h"], "exclude_patterns": ["build-debug"], "exclude_extensions": ["*.o","*.pyc"], "root_dir":"$HOME/devl/codelite"}
    """
    # build the find command
    command = get_list_files_commands(cmd)
    run_command(command)
    print_message_terminator()


def get_grep_command(cmd):
    """
    Find list of files with a given extension and from a given root directory

    Example command:

    {"command":"find","file_extensions":["*.cpp","*.hpp","*.h"],"root_dir":"/home/eran/devl/codelite","find_what":"wxStringSet_t","icase": true,"whole_word": false}
    """
    command = "grep --line-number --with-filename "
    if cmd["whole_word"] == True:
        command += "-w "

    if cmd["icase"] == True:
        command += "-i "

    find_what: str = cmd["find_what"]

    # Until we support regex, escape "[]-"
    find_what = find_what.replace("[", "\\[")
    find_what = find_what.replace("]", "\\]")
    find_what = find_what.replace("-", "\\-")

    command += f"'{find_what}' "
    command += "%FILE%"

    return command


def on_find_in_files(cmd):
    """
    Find list of files with a given extension and from a given root directory

    Example command:

    {"command":"find","file_extensions":["*.cpp","*.hpp","*.h"],"root_dir":"/home/eran/devl/codelite","find_what":"wxStringSet_t","icase": true,"whole_word": false}
    """

    files = get_files(cmd)
    if files:
        # get list of files and run grep on each one of them
        grep_command = get_grep_command(cmd)
        for file in files:
            c = grep_command.replace("%FILE%", file)
            run_command(c)
    print_message_terminator()


def on_replace_in_files(cmd):
    """
    Replace `find_what` with `replace_with` in `root_dir` files that match pattern `file_extensions`

    Example command:

    {"command":"replace","file_extensions":["*.cpp","*.hpp","*.h"],"root_dir":"$HOME/devl/codelite","find_what":"wxStringSet_t","icase": true,"whole_word": false, "replace_with": "std::unordered_set<wxString>"}

    Args:
        cmd (dict): Command dictionary containing:
            - find_what (str): The string to find
            - replace_with (str): The replacement string
            - icase (bool): Whether to ignore case
            - whole_word (bool): Whether to match whole words only
            - file_extensions (list): List of file extensions to search in
            - root_dir (str): Root directory to search files in

    Returns:
        None: This function modifies files in place and does not return anything
    """
    files = get_files(cmd)
    if files:
        # build sed command
        find_what: str = cmd["find_what"]
        replace_with: str = cmd["replace_with"]
        ignore_case = cmd["icase"]
        whole_word = cmd["whole_word"]

        # escape special chars
        find_what = find_what.replace("/", "\\/")
        replace_with = replace_with.replace("/", "\\/")

        # escape double quotes
        find_what = find_what.replace('"', '\\"')
        replace_with = replace_with.replace('"', '\\"')

        if whole_word:
            whold_word_delim_start = "([^\\w]|^)"
            whold_word_delim_end = "([^\\w]|$)"
            find_what = (
                f"{whold_word_delim_start}{find_what}{whold_word_delim_end}"
            )
            base_command = (
                f'sed -E -i.bak "s/{find_what}/\\1{replace_with}\\2/g'
            )
        else:
            base_command = f'sed -E -i.bak "s/{find_what}/{replace_with}/g'

        if ignore_case:
            base_command += "I"
        base_command += '"'

        for file in files:
            sed_command = f"{base_command} {file}"
            run_command(sed_command)
            # print the modified files
            arr_files = file.split(" ")
            for f in arr_files:
                f = f.replace('"', "")
                print(f)
                # remove the backup file created
                backup_file = f"{f}.bak"
                if os.path.exists(backup_file):
                    os.remove(backup_file)

    print_message_terminator()


def locate_in_path(name, path, versions_arr, ext):
    """
    Attempting to locate "name" in a given path
    with versions suffix (optionally)

    Returns
    -------
        full path on success, empty string otherwise
    """

    ## first try with suffix
    for version_number in versions_arr:
        fullpath = "{}/{}-{}".format(path, name, version_number)
        if len(ext) > 0:
            fullpath = "{}.{}".format(fullpath, ext)

        if os.path.exists(fullpath):
            return fullpath

    # If we got here, we could not get a match with suffix
    # so try without it
    fullpath = f"{path}/{name}"
    if len(ext) > 0:
        fullpath = f"{fullpath}.{ext}"

    logging.debug("Checking path: {}".format(fullpath))
    if os.path.isfile(fullpath) or os.path.islink(fullpath):
        logging.debug("found: {}".format(fullpath))
        return fullpath
    return ""


def on_list_lsps(lsps_array):
    """
    Handle listing language servers from the global configuration.

    This function accesses the global configuration to retrieve and display
    the list of configured language servers. If no language server configuration
    is found, it prints an empty JSON array.

    The function prints the servers array in JSON format and terminates with
    a message terminator.

    Example:
        >>> on_list_lsps([])
        [{"name": "pylsp", "command": ["pylsp"]}]
        ...
    """
    # use the global configuration file
    global configuration
    if (
        "Language Server Plugin" in configuration
        and "servers" in configuration["Language Server Plugin"]
    ):
        # print the servers array
        print(json.dumps(configuration["Language Server Plugin"]["servers"]))
    else:
        # print an empty array
        print("[]")
    print_message_terminator()


def on_find_path(cmd):
    """
    Find a directory or a file with a given name.

    If the path does not exist, check the parent folder until we hit root /.

    Args:
        cmd (dict): A dictionary containing the path to search for

    Returns:
        None: Prints the found path to stdout or nothing if not found
    """
    path = cmd["path"]
    path = os.path.expanduser(path)
    path = os.path.expandvars(path)

    # check for this folder and start going up
    dir_part, dir_name = os.path.split(path)
    dirs = dir_part.split("/")

    while len(dirs) > 0:
        fullpath = "{}/{}".format("/".join(dirs), dir_name)
        logging.debug("checking for dir {}".format(fullpath))
        if os.path.exists(fullpath):
            print("{}".format(fullpath))
            break

        # remove last element
        dirs.pop(len(dirs) - 1)
    print_message_terminator()


def locate(cmd):
    """
    attempt to locate file with possible version number
    """

    # {"command": "locate", "path": "/c/LLVM/bin", "name": "clangd", "ext": "exe", "versions": ["15","14"]}
    path = cmd["path"]
    path = os.path.expanduser(path)
    path = os.path.expandvars(path)

    # append $PATH content
    path = "{}{}{}".format(path, os.path.pathsep, os.path.expandvars("$PATH"))

    paths = path.split(os.path.pathsep)
    name = cmd["name"]
    ext = cmd["ext"]
    versions_arr = cmd["versions"]

    logging.debug(
        "locate: searching for: {}, using paths: {}".format(name, paths)
    )
    for p in paths:
        fullpath = locate_in_path(name, p, versions_arr, ext)
        if len(fullpath) > 0:
            logging.debug("locate: match found: {}".format(fullpath))
            print(fullpath)
            break
    logging.debug("locate: No match found :(")
    print_message_terminator()


def main_loop():
    """
    Main loop for codelite-remote helper that processes user commands.

    This function sets up logging, loads configuration, and enters an interactive
    loop where it accepts input from the user, parses JSON commands, and executes
    corresponding handler functions. It supports various commands like file finding,
    execution, and text replacement operations.

    The function handles the following commands:
    - ls: find files
    - find: find in files
    - exec: execute command
    - write_file: write to file
    - locate: locate files
    - find_path: find path
    - list_lsps: list language servers
    - replace: replace text in files

    The loop continues until 'exit', 'bye', 'quit', or 'q' is entered.
    """
    parser = argparse.ArgumentParser(description="codelite-remote helper")
    parser.add_argument(
        "--context",
        dest="context",
        help="execution context string",
        required=True,
    )
    args = parser.parse_args()

    # load the configruration file
    # it should be located next to this file
    curdir = os.path.dirname(__file__)
    log_file = "{}/codelite-remote.{}.log".format(curdir, args.context.lower())

    # configure the log module
    logging.basicConfig(
        filename=log_file,
        format="%(asctime)s: {}: %(levelname)s: %(message)s".format(
            args.context.upper()
        ),
        level=logging.WARNING,
    )
    logging.info("started")
    logging.info("current working directory is set to: {}".format(curdir))

    load_configuration()

    # interactive mode
    handlers = {
        "ls": on_find_files,
        "find": on_find_in_files,
        "exec": on_exec,
        "write_file": write_file,
        "locate": locate,
        "find_path": on_find_path,
        "list_lsps": on_list_lsps,
        "replace": on_replace_in_files,
    }

    logging.info("codelite-remote started")
    error_count = 0
    while True:
        try:
            text = input()
            text = text.strip()
            if text == "exit" or text == "bye" or text == "quit" or text == "q":
                logging.info("Bye!")
                exit(0)

            if len(text) == 0:
                continue

            # split the command line by spaces
            logging.info("processing command: {}".format(text))
            command = json.loads(text)
            func = handlers.get(command["command"], None)
            if func is not None:
                func(command)
            else:
                logging.error("unknown command '{}'".format(command["command"]))
        except Exception as e:
            error_count += 1
            print(e)
            logging.warning(e)
            if error_count == 10:
                logging.error("Too many errors. Exiting!")
                exit(1)
                break


def main():
    """
    Main function that initiates the main loop execution.

    This function serves as the entry point of the program, calling the
    main_loop() function to start the primary application logic.
    """
    main_loop()


if __name__ == "__main__":
    main()
