"""NCS compilers: inbuilt (native) and external (nwnnsscomp) NSS-to-NCS compilation."""

from __future__ import annotations

import subprocess

from datetime import date
from enum import Enum
from pathlib import Path  # pyright: ignore[reportMissingImports]
from typing import TYPE_CHECKING, NamedTuple

from pykotor.common.misc import Game
from pykotor.resource.formats._base import BiowareResource
from pykotor.resource.formats.ncs.compiler.classes import EntryPointError
from pykotor.resource.formats.ncs.ncs_auto import compile_nss, write_ncs
from pykotor.resource.formats.ncs.ncs_data import NCSCompiler
from pykotor.tools.encoding import decode_bytes_with_fallbacks
from utility.misc import generate_hash  # pyright: ignore[reportMissingImports]

if TYPE_CHECKING:
    import os

    from subprocess import CompletedProcess

    from pykotor.resource.formats.ncs.ncs_data import NCS, NCSOptimizer


class InbuiltNCSCompiler(NCSCompiler):
    """Built-in NSS to NCS compiler using PyKotor's native implementation.

    This compiler provides full NSS compilation without external dependencies,
    supporting all KOTOR/TSL script features including:
    - Functions, variables, structs
    - Control flow (if/else, while, for, do-while, switch)
    - All data types (int, float, string, object, vector, etc.)
    - #include directive support
    - Optimization passes

    References:
    ----------
        See ncs_data module docstring for engine addresses (K1 + TSL TODO). CResNCS::CResNCS (K1: 0x005d4c30),
        ReadScriptFile (K1: 0x005d2260), InitializeScript (K1: 0x005d461b, TSL: TODO). CVirtualMachineInternal — NWScript VM.
        Note: PyKotor NSS compiler produces NCS bytecode compatible with the engine VM.
        Derivations and Other Implementations:
        ----------
        https://github.com/th3w1zard1/KotOR.js/tree/master/src/nwscript/NWScriptCompiler.ts (NSS compilation reference)


    """

    def compile_script(  # noqa: PLR0913
        self,
        source_file: os.PathLike | str,
        output_file: os.PathLike | str,
        game: Game | int,
        timeout: int = 5,  # noqa: ARG002
        *,
        debug: bool = False,
        optimizers: list[NCSOptimizer] | None = None,
    ) -> tuple[str, str]:
        if not isinstance(game, Game):
            game = Game(game)
        source_filepath: Path = Path(source_file)
        nss_data: bytes = source_filepath.read_bytes()
        nss_contents: str = decode_bytes_with_fallbacks(nss_data)
        ncs: NCS = compile_nss(nss_contents, game, optimizers, library_lookup=[source_filepath.parent], debug=debug)
        write_ncs(ncs, output_file)
        return "", ""


class ExternalCompilerConfig(NamedTuple):
    sha256: str
    name: str
    release_date: date
    author: str
    commandline: dict[str, list[str]]


class KnownExternalCompilers(Enum):
    """Known external NSS compilers and their configurations.

    References:
    ----------

    Derivations and Other Implementations:
    -------------------------------------
        https://github.com/th3w1zard1/TSLPatcher/tree/master/TSLPatcher.pl (unfinished perl rewrite of TSLPatcher)
        https://github.com/th3w1zard1/Kotor.NET/tree/master/Kotor.NET.Patcher/


    """

    TSLPATCHER = ExternalCompilerConfig(
        sha256="539EB689D2E0D3751AEED273385865278BEF6696C46BC0CAB116B40C3B2FE820",
        name="TSLPatcher",
        release_date=date(2009, 1, 1),
        author="todo",
        commandline={
            "compile": ["-c", "{source}", "-o", "{output}"],
            "decompile": ["-d", "{source}", "-o", "{output}"],
        },
    )
    KOTOR_TOOL = ExternalCompilerConfig(
        sha256="E36AA3172173B654AE20379888EDDC9CF45C62FBEB7AB05061C57B52961C824D",
        name="KOTOR Tool",
        release_date=date(2005, 1, 1),
        author="Fred Tetra",
        commandline={
            "compile": ["-c", "--outputdir", "{output_dir}", "-o", "{output_name}", "-g", "{game_value}", "{source}"],
            "decompile": ["-d", "--outputdir", "{output_dir}", "-o", "{output_name}", "-g", "{game_value}", "{source}"],
        },
    )
    V1 = ExternalCompilerConfig(
        sha256="EC3E657C18A32AD13D28DA0AA3A77911B32D9661EA83CF0D9BCE02E1C4D8499D",
        name="v1.3 first public release",
        release_date=date(2003, 12, 31),
        author="todo",
        commandline={
            "compile": ["-c", "{source}", "{output}"],
            "decompile": ["-d", "{source}", "{output}"],
        },
    )
    KOTOR_SCRIPTING_TOOL = ExternalCompilerConfig(
        sha256="B7344408A47BE8780816CF68F5A171A09640AB47AD1A905B7F87DE30A50A0A92",
        name="KOTOR Scripting Tool",
        release_date=date(2016, 5, 18),
        author="James Goad",  # TODO: double check
        commandline={
            "compile": ["-c", "--outputdir", "{output_dir}", "-o", "{output_name}", "-g", "{game_value}", "{source}"],
            "decompile": ["-d", "--outputdir", "{output_dir}", "-o", "{output_name}", "-g", "{game_value}", "{source}"],
        },
    )
    DENCS = ExternalCompilerConfig(
        sha256="539EB689D2E0D3751AEED273385865278BEF6696C46BC0CAB116B40C3B2FE820",
        name="DeNCS",
        release_date=date(2006, 5, 30),
        author="todo",
        commandline={},
    )
    XOREOS = ExternalCompilerConfig(
        sha256="",
        name="Xoreos Tools",
        release_date=date(2016, 1, 1),  # Approximate based on project history
        author="Xoreos Team",
        commandline={},  # Xoreos tools are primarily for engine reimplementation
    )
    KNSSCOMP = ExternalCompilerConfig(
        # knsscomp is Nick Hugi's modern NSS compiler
        # Hash should be determined per binary version
        sha256="",  # TODO: Obtain hash from actual knsscomp binary
        name="knsscomp",
        release_date=date(2022, 1, 1),  # Approximate
        author="Nick Hugi",
        commandline={
            "compile": ["-c", "{source}", "-o", "{output}"],
            "decompile": [],  # knsscomp doesn't support decompilation
        },
    )

    @classmethod
    def from_sha256(cls: type[KnownExternalCompilers], sha256: str) -> KnownExternalCompilers:
        uppercase_sha256: str = sha256.upper()
        for known_ext_compiler in cls:
            if known_ext_compiler.value.sha256 == uppercase_sha256:
                return known_ext_compiler

        msg = f"No compilers found with sha256 hash '{uppercase_sha256}'"
        raise ValueError(msg)


class NwnnsscompConfig(BiowareResource):
    """Unifies the arguments passed to each different version of nwnnsscomp, since no versions offer backwards-compatibility with each other."""

    def __init__(
        self,
        sha256_hash: str,
        sourcefile: Path,
        outputfile: Path,
        game: Game,
    ):
        self.sha256_hash: str = sha256_hash
        self.source_file: Path = sourcefile
        self.output_file: Path = outputfile
        self.output_dir: Path = outputfile.parent
        self.output_name: str = outputfile.name
        self.game: Game = game

        self.chosen_compiler: KnownExternalCompilers = KnownExternalCompilers.from_sha256(self.sha256_hash)

    def get_compile_args(self, executable: str) -> list[str]:
        return self._format_args(self.chosen_compiler.value.commandline["compile"], executable)

    def get_decompile_args(self, executable: str) -> list[str]:
        return self._format_args(self.chosen_compiler.value.commandline["decompile"], executable)

    def _format_args(self, args_list: list[str], executable: str) -> list[str]:
        formatted_args: list[str] = [
            arg.format(
                source=self.source_file,
                output=self.output_file,
                output_dir=self.output_dir,
                output_name=self.output_name,
                game_value="1" if self.game.is_k1() else "2",
            )
            for arg in args_list
        ]
        formatted_args.insert(0, executable)
        return formatted_args


class ExternalNCSCompiler(NCSCompiler):
    def __init__(self, nwnnsscomp_path: os.PathLike | str):
        self.change_nwnnsscomp_path(nwnnsscomp_path)

    def get_info(self) -> KnownExternalCompilers:
        return KnownExternalCompilers.from_sha256(self.filehash)

    def change_nwnnsscomp_path(self, nwnnsscomp_path: os.PathLike | str):
        self.nwnnsscomp_path: Path = Path(nwnnsscomp_path)
        self.filehash: str = generate_hash(self.nwnnsscomp_path, hash_algo="sha256").upper()

    def config(
        self,
        source_file: os.PathLike | str,
        output_file: os.PathLike | str,
        game: Game | int,
        *,
        debug: bool = False,
    ) -> NwnnsscompConfig:
        """Configures a Nwnnsscomp run.

        Args:
        ----
            source_file: Path to the source file to compile
            output_file: Path to output file to generate
            game: Game enum or integer to configure in one line
            debug - bool (kwarg): Whether to debug Ply and verbosely output more information. Defaults to False.

        Returns:
        -------
            NwnnsscompConfig: Config object for Nwnnsscomp run

        Processing Logic:
        ----------------
            - Resolves source and output file paths
            - Converts game arg to Game enum if integer
            - Returns NwnnsscompConfig object configured with args useable with the compile_script and decompile_script functions.
        """
        source_filepath, output_filepath = map(Path, (source_file, output_file))
        if not isinstance(game, Game):
            game = Game(game)
        return NwnnsscompConfig(self.filehash, source_filepath, output_filepath, game)

    def compile_script(
        self,
        source_file: os.PathLike | str,
        output_file: os.PathLike | str,
        game: Game | int,
        timeout: int = 5,
        *,
        debug: bool = False,
    ) -> tuple[str, str]:
        """Compiles a NSS script into NCS using the external compiler.

        Function is compatible with any nwnnsscomp.exe version.

        Args:
        ----
            source_file: The path or name of the script source file to compile.
            output_file: The path or name of the compiled module file to output.
            game: The Game object or game ID to configure the compiler for.
            timeout: The timeout in seconds to wait for compilation to finish before aborting.
            debug - bool (kwarg): (does nothing for external compilers)

        Returns:
        -------
            A tuple of (stdout, stderr) strings from the compilation process.

        Processing Logic:
        ----------------
            - Validates source file exists
            - Configures the compiler based on the nwnnsscomp.exe used
            - Runs the compiler process, capturing stdout and stderr
            - Returns a tuple of the stdout and stderr strings on completion

        Raises:
        ------
            FileNotFoundError: If source file doesn't exist
            RuntimeError: If compiler executable doesn't exist
            EntryPointError: If file has no entry point and is an include file
            subprocess.TimeoutExpired: If compilation exceeds timeout
        """
        source_path = Path(source_file)
        if not source_path.is_file():
            msg = f"Source file not found: {source_path}"
            raise FileNotFoundError(msg)

        if not self.nwnnsscomp_path.is_file():
            msg = f"Compiler executable not found: {self.nwnnsscomp_path}"
            raise RuntimeError(msg)

        config: NwnnsscompConfig = self.config(source_file, output_file, game)

        try:
            result: CompletedProcess[str] = subprocess.run(
                args=config.get_compile_args(str(self.nwnnsscomp_path)),
                capture_output=True,  # Capture stdout and stderr
                text=True,
                timeout=timeout,
                check=False,
            )
        except subprocess.TimeoutExpired as e:
            msg = f"Compilation timed out after {timeout} seconds"
            raise RuntimeError(msg) from e
        except Exception as e:
            msg = f"Failed to run compiler: {e}"
            raise RuntimeError(msg) from e

        stdout, stderr = self._get_output(result)

        # Check for known error conditions
        if "File is an include file, ignored" in stdout:
            msg = "This file has no entry point and cannot be compiled (Most likely an include file)."
            raise EntryPointError(msg)

        if result.returncode != 0 and stderr:
            msg = f"Compilation failed with return code {result.returncode}: {stderr}"
            raise RuntimeError(msg)

        return stdout, stderr

    def decompile_script(
        self,
        source_file: os.PathLike | str,
        output_file: os.PathLike | str,
        game: Game | int,
        timeout: int = 5,
    ) -> tuple[str, str]:
        """Decompiles a NCS bytecode file to NSS source code.

        Args:
        ----
            source_file: Path to the NCS bytecode file to decompile
            output_file: Path to output the decompiled NSS source code
            game: The Game object or ID for configuration
            timeout: Seconds to wait before aborting (default: 5)

        Returns:
        -------
            tuple[str, str]: Tuple of (stdout, stderr) from the decompile process

        Processing Logic:
        ----------------
            - Validates source file exists
            - Validates compiler supports decompilation
            - Runs the decompile process and captures output
            - Returns stdout/stderr streams

        Raises:
        ------
            FileNotFoundError: If source file doesn't exist
            RuntimeError: If compiler doesn't support decompilation or fails
            subprocess.TimeoutExpired: If decompilation exceeds timeout
        """
        source_path = Path(source_file)
        if not source_path.is_file():
            msg = f"Source file not found: {source_path}"
            raise FileNotFoundError(msg)

        if not self.nwnnsscomp_path.is_file():
            msg = f"Compiler executable not found: {self.nwnnsscomp_path}"
            raise RuntimeError(msg)

        config: NwnnsscompConfig = self.config(source_file, output_file, game)

        # Check if this compiler supports decompilation
        if not config.chosen_compiler.value.commandline.get("decompile"):
            msg = f"Compiler '{config.chosen_compiler.value.name}' does not support decompilation"
            raise RuntimeError(msg)

        try:
            result: CompletedProcess[str] = subprocess.run(
                args=config.get_decompile_args(str(self.nwnnsscomp_path)),
                capture_output=True,  # Capture stdout and stderr
                text=True,
                timeout=timeout,
                check=False,
            )
        except subprocess.TimeoutExpired as e:
            msg = f"Decompilation timed out after {timeout} seconds"
            raise RuntimeError(msg) from e
        except Exception as e:
            msg = f"Failed to run decompiler: {e}"
            raise RuntimeError(msg) from e

        stdout, stderr = self._get_output(result)

        if result.returncode != 0 and stderr:
            msg = f"Decompilation failed with return code {result.returncode}: {stderr}"
            raise RuntimeError(msg)

        return stdout, stderr

    def _get_output(self, result: CompletedProcess[str]) -> tuple[str, str]:
        stdout: str = result.stdout
        stderr: str = (
            f"No error provided, but return code is nonzero: ({result.returncode})"
            if result.returncode != 0 and (not result.stderr or not result.stderr.strip())
            else result.stderr
        )

        if "Error:" in stdout:
            stdout_lines: list[str] = stdout.split("\n")
            error_line: str = ""
            # Find and remove the line with 'Error:'
            filtered_stdout_lines: list[str] = []
            for line in stdout_lines:
                if "Error:" in line:
                    error_line += "\n" + line
                else:
                    filtered_stdout_lines.append(line)

            # Reconstruct stdout without the error line
            stdout = "\n".join(filtered_stdout_lines)

            # Append the error line to stderr if it was found
            if error_line:
                if stderr:  # If there's already content in stderr, add a newline before appending
                    stderr += "\n" + error_line
                else:
                    stderr = error_line
        return stdout, stderr
