from __future__ import annotations

import os
import sys
from typing import Any, Callable

from hatchling.builders.config import BuilderConfig
from hatchling.builders.plugin.interface import BuilderInterface


class AppBuilderConfig(BuilderConfig):
    SUPPORTED_VERSIONS = ('3.11', '3.10', '3.9', '3.8', '3.7')

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        super().__init__(*args, **kwargs)

        self.__scripts: list[str] | None = None
        self.__python_version: str | None = None
        self.__pyapp_version: str | None = None

    @property
    def scripts(self) -> list[str]:
        if self.__scripts is None:
            known_scripts = self.builder.metadata.core.scripts
            scripts = self.target_config.get('scripts', [])

            if not isinstance(scripts, list):
                message = f'Field `tool.hatch.build.targets.{self.plugin_name}.scripts` must be an array'
                raise TypeError(message)

            for i, script in enumerate(scripts, 1):
                if not isinstance(script, str):
                    message = (
                        f'Script #{i} of field `tool.hatch.build.targets.{self.plugin_name}.scripts` must be a string'
                    )
                    raise TypeError(message)
                elif script not in known_scripts:
                    message = f'Unknown script in field `tool.hatch.build.targets.{self.plugin_name}.scripts`: {script}'
                    raise ValueError(message)

            self.__scripts = sorted(set(scripts)) if scripts else list(known_scripts)

        return self.__scripts

    @property
    def python_version(self) -> str:
        if self.__python_version is None:
            python_version = self.target_config.get('python-version', '')

            if not isinstance(python_version, str):
                message = f'Field `tool.hatch.build.targets.{self.plugin_name}.python-version` must be a string'
                raise TypeError(message)

            if not python_version and 'PYAPP_DISTRIBUTION_SOURCE' not in os.environ:
                for supported_version in self.SUPPORTED_VERSIONS:
                    if self.builder.metadata.core.python_constraint.contains(supported_version):
                        python_version = supported_version
                        break
                else:
                    message = 'Field `project.requires-python` is incompatible with the known distributions'
                    raise ValueError(message)

            self.__python_version = python_version

        return self.__python_version

    @property
    def pyapp_version(self) -> str:
        if self.__pyapp_version is None:
            pyapp_version = self.target_config.get('pyapp-version', '')

            if not isinstance(pyapp_version, str):
                message = f'Field `tool.hatch.build.targets.{self.plugin_name}.pyapp-version` must be a string'
                raise TypeError(message)

            self.__pyapp_version = pyapp_version

        return self.__pyapp_version


class AppBuilder(BuilderInterface):
    """
    Build applications
    """

    PLUGIN_NAME = 'app'

    def get_version_api(self) -> dict[str, Callable]:
        return {'bootstrap': self.build_bootstrap}

    def get_default_versions(self) -> list[str]:
        return ['bootstrap']

    def clean(self, directory: str, versions: list[str]) -> None:
        import shutil

        app_dir = os.path.join(directory, 'app')
        if os.path.isdir(app_dir):
            shutil.rmtree(app_dir)

    def build_bootstrap(self, directory: str, **build_data: Any) -> str:
        import shutil
        import tempfile

        cargo_path = os.environ.get('CARGO', '')
        if not cargo_path:
            if not shutil.which('cargo'):
                message = 'Executable `cargo` could not be found on PATH'
                raise OSError(message)

            cargo_path = 'cargo'

        app_dir = os.path.join(directory, 'app')
        if not os.path.isdir(app_dir):
            os.makedirs(app_dir)

        on_windows = sys.platform == 'win32'
        base_env = dict(os.environ)
        base_env['PYAPP_PROJECT_NAME'] = self.metadata.name
        base_env['PYAPP_PROJECT_VERSION'] = self.metadata.version

        if self.config.python_version:
            base_env['PYAPP_PYTHON_VERSION'] = self.config.python_version

        # https://doc.rust-lang.org/cargo/reference/config.html#buildtarget
        build_target = os.environ.get('CARGO_BUILD_TARGET', '')

        # This will determine whether we install from crates.io or build locally and is currently required for
        # cross compilation: https://github.com/cross-rs/cross/issues/1215
        repo_path = os.environ.get('PYAPP_REPO', '')

        with tempfile.TemporaryDirectory() as temp_dir:
            exe_name = 'pyapp.exe' if on_windows else 'pyapp'
            if repo_path:
                context_dir = repo_path
                target_dir = os.path.join(temp_dir, 'build')
                if build_target:
                    temp_exe_path = os.path.join(target_dir, build_target, 'release', exe_name)
                else:
                    temp_exe_path = os.path.join(target_dir, 'release', exe_name)
                install_command = [cargo_path, 'build', '--release', '--target-dir', target_dir]
            else:
                context_dir = temp_dir
                temp_exe_path = os.path.join(temp_dir, 'bin', exe_name)
                install_command = [cargo_path, 'install', 'pyapp', '--force', '--root', temp_dir]
                if self.config.pyapp_version:
                    install_command.extend(['--version', self.config.pyapp_version])

            if self.config.scripts:
                for script in self.config.scripts:
                    env = dict(base_env)
                    env['PYAPP_EXEC_SPEC'] = self.metadata.core.scripts[script]

                    self.cargo_build(install_command, cwd=context_dir, env=env)

                    exe_stem = f'{script}-{build_target}' if build_target else script
                    exe_path = os.path.join(app_dir, f'{exe_stem}.exe' if on_windows else exe_stem)
                    shutil.move(temp_exe_path, exe_path)
            else:
                self.cargo_build(install_command, cwd=context_dir, env=base_env)

                exe_stem = f'{self.metadata.name}-{build_target}' if build_target else self.metadata.name
                exe_path = os.path.join(app_dir, f'{exe_stem}.exe' if on_windows else exe_stem)
                shutil.move(temp_exe_path, exe_path)

        return app_dir

    def cargo_build(self, *args: Any, **kwargs: Any) -> None:
        import subprocess

        if not self.app.verbosity:
            kwargs['stdout'] = subprocess.PIPE
            kwargs['stderr'] = subprocess.STDOUT

        process = subprocess.run(*args, **kwargs)
        if process.returncode:
            message = f'Compilation of failed (code {process.returncode})'
            if not self.app.verbosity:
                message = f'{process.stdout.decode("utf-8")}\n{message}'

            raise OSError(message)

    @classmethod
    def get_config_class(cls) -> type[AppBuilderConfig]:
        return AppBuilderConfig
