import hashlib
import json
import subprocess
import time
import traceback
from pathlib import Path
from typing import Optional, Type

from actions_logging.app_logging import logger
from env_files.constants import NEW_KEY, REMOVED_KEY, VALUE_CHANGED
from github.env import exit_on_error_and_write_summary


def run_command(
    command: str, print_output: bool = True, return_output: bool = True, exit_on_error: bool = True, print_command=True
) -> str:
    if not command:
        exit_on_error_and_write_summary("command is empty in run_command")
    try:
        if print_command:
            logger.info_blue(f"Running command: {command}")
        result = subprocess.run(command, shell=True, text=True, capture_output=True)
        if result.returncode != 0:
            if exit_on_error:
                message = f"error in run command while running: {command}, with exit code: {result.returncode}"
                if result.stderr:
                    message += f" and error message: {result.stderr.strip()}"
                else:
                    message += f" and output message: {result.stdout.strip()}"
                exit_on_error_and_write_summary(message)
            message = f"error in run_command: {result.stderr.strip()}"
            log_level = logger.error
        else:
            message = result.stdout
            log_level = logger.info
        if print_output:
            log_level(message)
        if return_output:
            return message
    except Exception as e:
        exit_on_error_and_write_summary(f"error in run_command: {e}")


def load_json_data(filename: str) -> dict:
    """Load JSON data from a file."""
    try:
        logger.debug(f"Loading JSON data from {filename}")
        with open(filename, "r") as file:
            data = json.load(file)
            return data
    except Exception as e:
        exit_on_error_and_write_summary(f"Could not read json file due to the following error: {e}")


def wait_for_file(
    target_dir: str,
    target_file_name: str = "",
    target_file_extension: str = "",
    max_wait_time: int = 300,
    check_interval: int = 5,
) -> bool:
    logger.debug(
        f"checking if file or pattern exists in: {target_dir}, "
        f"target file name: {target_file_name}, "
        f"target file extension: {target_file_extension}, "
        f"max wait time: {max_wait_time}s, "
        f"check interval: {check_interval}s"
    )
    if not target_dir or not Path(target_dir).is_dir():
        logger.error("Invalid target directory")
        return False
    if not target_file_name and not target_file_extension:
        logger.error("Must specify either file name or extension")
        return False
    target_path = Path(target_dir)
    elapsed_time = 0
    while elapsed_time < max_wait_time:
        if elapsed_time > 0:
            time.sleep(check_interval)
        pattern = f"{target_file_name or '*.'}{target_file_extension}"
        logger.debug(f"Checking for {pattern}, elapsed: {elapsed_time}s")
        if list(target_path.glob(pattern)):
            return True
        elapsed_time += check_interval
    logger.debug(f"Timeout waiting for {pattern} files")
    return False


def compare_dict_keys_and_values(base_data: dict, head_data: dict) -> list[tuple[Optional[str], Optional[str], str]]:
    """
    This function will compare the 2 dictionaries and return a list of tuples with the changes.
    change can be one of 3:
    new key, removed key or value changed.
    :param base_data: old dict
    :param head_data: new dict
    :return: list of tuples with the changes (base_key, head_key, change_type)
    """
    try:
        changes = []
        logger.info("comparing 2 dicts keys and values")
        all_keys = sorted(set(base_data.keys()).union(set(head_data.keys())))
        logger.debug(f"all keys: {all_keys}")
        for key in all_keys:
            base_value = base_data.get(key)
            head_value = head_data.get(key)
            if key not in base_data:
                changes.append((None, key, NEW_KEY))
            elif key not in head_data:
                changes.append((key, None, REMOVED_KEY))
            elif base_value != head_value:
                changes.append((key, key, VALUE_CHANGED))
        logger.debug(f"changes: {changes}")
        return changes
    except Exception as e:
        raise RuntimeError(f"An error occurred in compare_dict_keys_and_values: {e}")


def calculate_md5_file_checksum(file_path: str) -> str:
    """
    Calculate the MD5 checksum of a file.
    :param file_path:
    :return: md5 checksum of the file
    :raises RuntimeError: for any error that occurs during the file reading or hashing process
    """
    try:
        md5_hash = hashlib.md5()
        with open(file_path, "rb") as f:
            for chunk in iter(lambda: f.read(4096), b""):
                md5_hash.update(chunk)
        return md5_hash.hexdigest()
    except Exception as e:
        raise RuntimeError(f"An error occurred in calculate_md5_file_checksum: {e}")


def raise_with_context(
    e: Optional[Exception], exc_type: Type[BaseException] = RuntimeError, msg: Optional[str] = None
) -> None:
    """
    Raises a RuntimeError with the original exception message, function name, and line number.

    :param e: The original exception. If set to None, the function will raise an exception with the provided message.
              If set, then the raised exception will chain the original exception.
    :param exc_type: The type of the exception to raise.
    :param msg: Optional message to include in the error.
    """
    try:
        if e:
            # Get the first traceback entry
            tb = traceback.extract_tb(e.__traceback__)[0]
            # Extract the file name, function name and line number
            file_name = tb.filename
            func_name = tb.name
            line_no = tb.lineno
            exception_message = f"error in file {file_name} on function {func_name} at line {line_no}: {e}"

        if msg and e:
            raise_message = f"{msg}: {exception_message}"
        elif msg:
            raise_message = f"{msg}"
        elif e:
            raise_message = f"{exception_message}"
        else:
            raise_message = "Exception raised without context"

        if e:
            raise exc_type(raise_message) from e
        else:
            raise exc_type(raise_message)
    except Exception as internal_exception:
        logger.error(f"error in raise_with_context executed with parameters: {e}, {exc_type}, {msg}")
        raise RuntimeError(f"error in raise_with_context: {internal_exception}") from internal_exception


def append_to_partitioned_text(
    text_partitioned: list[str],
    text_to_append: str,
    max_length: int,
    text_to_end_nonlast_parts: str = "",
    text_to_start_nonfirst_parts: str = "",
) -> list[str]:
    """
    Append text to a partitioned list of texts, ensuring that the total length does not exceed the max_length.
    If the last text in the list is too long, a new text is started.
    If the list is empty, a new text is started with the text to append.

    :param text_partitioned: The list of partitioned texts.
    :param text_to_append: The text to append to the last partitioned text.
    :param max_length: The maximum length of a single text partition.
    :param text_to_end_nonlast_parts: Text to append at the end of non-last parts, if any.
    :param text_to_start_nonfirst_parts: Text to prepend to the non-first part of the partitioned text.
    :return: The updated list of partitioned texts.
    """
    text_to_end_nonlast_parts = f"\n\n{text_to_end_nonlast_parts}" if text_to_end_nonlast_parts else ""
    text_to_start_nonfirst_parts = f"{text_to_start_nonfirst_parts}\n\n" if text_to_start_nonfirst_parts else ""

    if not text_partitioned:
        # If the list is empty, start the first part with the text to append
        logger.debug("Starting new text partition as the list is empty")
        text_partitioned.append(text_to_append)
    elif len(text_partitioned[-1]) + len(text_to_append) + len(text_to_end_nonlast_parts) > max_length:
        # If the last text will exceed the max length, start a new text
        logger.debug(
            f"Last text partition ({len(text_partitioned[-1])} characters), "
            f"after appending the new text ({len(text_to_append)} characters) and "
            f"the end text ({len(text_to_end_nonlast_parts)} characters), "
            f"will exceed the max_length ({max_length} chars). "
            "The function will therefore end the last part and append the new text as a new part."
        )
        text_partitioned[-1] += text_to_end_nonlast_parts
        text_partitioned.append(f"{text_to_start_nonfirst_parts}{text_to_append}")
    else:
        # Otherwise, append to the last text
        logger.debug(f"Appending text {text_to_append} to the last partitioned text")
        text_partitioned[-1] += text_to_append
    return text_partitioned
