# Copyright 2024 Macéo Tuloup

# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at

#     http://www.apache.org/licenses/LICENSE-2.0

# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.


import os
import sys
import subprocess
import typing as T
import __main__ as __makefile__
from threading import Lock

from .config import Config
from .display import print_info, print_debug_info


_print_lock = Lock()
_commands_counter = 0


def run_command(config: Config, command: T.Union[T.List[str], str], shell: bool = False, target: T.Union[str, None] = None, output_filter: T.Union[T.Callable[[bytes], bytes], None] = None, **kwargs: T.Any) -> int:
    global _commands_counter
    
    returncode = 0
    try:
        output = subprocess.check_output(command, shell=shell, stderr=subprocess.STDOUT, **kwargs)
    except subprocess.CalledProcessError as e:
        output = e.output
        returncode = e.returncode
    
    if output_filter is not None:
        output = output_filter(output)

    _print_lock.acquire()

    _commands_counter += 1

    print_info(f"Generating {os.path.basename(target)}" if target is not None else "", config.verbosity, _commands_counter, config.nb_total_operations)

    print_debug_info(command, config.verbosity)

    sys.stdout.flush()
    stdout_fd = sys.stdout.fileno()
    os.write(stdout_fd, output)

    _print_lock.release()

    return returncode


def resolve_path(current_folder: str, additional_includedirs: T.List[str], filepath: str) -> T.Union[str, None]:
    path = os.path.join(current_folder, filepath)
    if os.path.exists(path):
        return path
    for folder in additional_includedirs:
        path = os.path.join(folder, filepath)
        if os.path.exists(path):
            return path
    return None


def is_file_uptodate_recursive(output_date: float, filename: str, additional_includedirs: T.List[str], headers_already_found: T.List[str] = []) -> bool:
    try:
        if os.path.getmtime(filename) >= output_date:
            return False
    except OSError:
        return False

    if not filename.endswith((".c", ".cpp", ".cc", ".C", ".h", ".hpp", ".lex", ".y")):
        return True

    headers_found = []
    file = open(filename, "r", encoding="latin1")

    # The four lines under this comment are here for the compatibility with python < 3.8
    # They are equivalent to `while line := file.readline():`
    while True:
        line = file.readline()
        if not line:
            break

        i = 0
        while i < len(line) and (line[i] == ' ' or line[i] == '\t'):
            i += 1
        if i >= len(line):
            continue
        if line[i] != '#':
            continue
        i += 1
        while i < len(line) and (line[i] == ' ' or line[i] == '\t'):
            i += 1
        if i >= len(line):
            continue
        if line[i:].startswith("include"):
            i += len("include")
            if i >= len(line) or (line[i] != ' ' and line[i] != '\t'):
                continue
            i += 1
            while i < len(line) and (line[i] == ' ' or line[i] == '\t'):
                i += 1
            if i >= len(line) or line[i] != '"':
                continue
            i += 1
            j = i
            while j < len(line) and line[j] != '"':
                j += 1
            if j >= len(line):
                continue

            if line[i:j] not in headers_found:
                headers_found.append(line[i:j])

    file.close()

    if len(headers_found) == 0:
        return True

    current_folder = os.path.dirname(filename)
    new_paths = []
    for i in range(len(headers_found)):
        path = resolve_path(current_folder, additional_includedirs, headers_found[i])
        if path is not None and path not in headers_already_found:
            headers_already_found.append(path)
            new_paths.append(path)

    del headers_found

    for path in new_paths:
        if not is_file_uptodate_recursive(output_date, path, additional_includedirs, headers_already_found):
            return False

    return True


def needs_update(outputfile: str, dependencies: T.Iterable[str], additional_includedirs: T.List[str]) -> bool:
    """Returns whether or not `outputfile` is up to date with all his dependencies

    If `dependencies` includes C/C++ files and headers, all headers these files include recursively will be add as hidden dependencies.

    Args:
        outputfile (str): the path to the target file
        dependencies (set): a set of all files on which `outputfile` depends
        additional_includedirs (list): The list of additional includedirs used by the compiler. This is necessary to discover hidden dependencies.

    Returns:
        bool: True if `outputfile` is **not** up to date with all his dependencies and hidden dependencies.
    """
    try:
        output_date = os.path.getmtime(outputfile)
    except OSError:
        return True
    
    if hasattr(__makefile__, '__file__') and os.path.getmtime(__makefile__.__file__) >= output_date:
        return True
    
    headers_already_found: T.List[str] = []
    for dep in dependencies:
        if not is_file_uptodate_recursive(output_date, dep, additional_includedirs, headers_already_found):
            return True

    return False


class Operation:
    def __init__(self, outputfile: str, dependencies: T.Iterable[str], config: Config, command: T.List[str]):
        """Provide a simple object that can execute a command only if it's needed.

        Args:
            outputfile (str): the path to the target file
            dependencies (set): a set of all files on which `outputfile` depends
            config (Config): A powermake.Config object, the additional_includedirs in it should be completed
            command (list): The command that will be executed by subprocess. It's a list representing the argv that will be passed to the program at the first list position.
        """
        self.outputfile = outputfile
        self.dependencies = dependencies
        self._hidden_dependencies = None
        self.command = command
        self.config = config

    def execute(self, force: bool = False) -> str:
        global _commands_counter

        """Verify if the outputfile is up to date with his dependencies and if not, execute the command.

        Args:
            force (bool, optional): If True, this function will always execute the command without verifying if this is needed.

        Raises:
            RuntimeError: If the command fails.

        Returns:
            str: The outputfile, like that we can easily parallelize this method.
        """
        if force or needs_update(self.outputfile, self.dependencies, self.config.additional_includedirs):

            if run_command(self.config, self.command, target=self.outputfile) == 0:
                return self.outputfile
            else:
                raise RuntimeError(f"Unable to generate {os.path.basename(self.outputfile)}")
        else:
            _print_lock.acquire()
            _commands_counter += 1
            _print_lock.release()
        return self.outputfile

    def get_json_command(self) -> T.Dict[str, T.Any]:
        json_command = {
            "directory": os.getcwd(),
            "arguments": self.command,
            "output": self.outputfile
        }
        deps = list(self.dependencies)
        if len(deps) > 0:
            json_command["file"] = deps[0]

        return json_command
