# SPDX-License-Identifier: Apache-2.0
# Copyright 2012-2016 The Meson development team

from __future__ import annotations

from dataclasses import dataclass
import subprocess
import typing as T
from enum import Enum

from . import mesonlib
from .mesonlib import EnvironmentException, HoldableObject
from . import mlog
from pathlib import Path

if T.TYPE_CHECKING:
    from .options import ElementaryOptionValues


# These classes contains all the data pulled from configuration files (native
# and cross file currently), and also assists with the reading environment
# variables.
#
# At this time there isn't an ironclad difference between this and other sources
# of state like `coredata`. But one rough guide is much what is in `coredata` is
# the *output* of the configuration process: the final decisions after tests.
# This, on the other hand has *inputs*. The config files are parsed, but
# otherwise minimally transformed. When more complex fallbacks (environment
# detection) exist, they are defined elsewhere as functions that construct
# instances of these classes.


known_cpu_families = (
    'aarch64',
    'alpha',
    'arc',
    'arm',
    'avr',
    'c2000',
    'c6000',
    'csky',
    'dspic',
    'e2k',
    'ft32',
    'ia64',
    'loongarch64',
    'm68k',
    'microblaze',
    'mips',
    'mips64',
    'msp430',
    'parisc',
    'pic24',
    'ppc',
    'ppc64',
    'riscv32',
    'riscv64',
    'rl78',
    'rx',
    's390',
    's390x',
    'sh4',
    'sparc',
    'sparc64',
    'sw_64',
    'wasm32',
    'wasm64',
    'x86',
    'x86_64',
    'tricore'
)

# It would feel more natural to call this "64_BIT_CPU_FAMILIES", but
# python identifiers cannot start with numbers
CPU_FAMILIES_64_BIT = [
    'aarch64',
    'alpha',
    'ia64',
    'loongarch64',
    'mips64',
    'ppc64',
    'riscv64',
    's390x',
    'sparc64',
    'sw_64',
    'wasm64',
    'x86_64',
]

# Map from language identifiers to environment variables.
ENV_VAR_COMPILER_MAP: T.Mapping[str, str] = {
    # Compilers
    'c': 'CC',
    'cpp': 'CXX',
    'cs': 'CSC',
    'cython': 'CYTHON',
    'd': 'DC',
    'fortran': 'FC',
    'objc': 'OBJC',
    'objcpp': 'OBJCXX',
    'rust': 'RUSTC',
    'vala': 'VALAC',
    'nasm': 'NASM',

    # Linkers
    'c_ld': 'CC_LD',
    'cpp_ld': 'CXX_LD',
    'd_ld': 'DC_LD',
    'fortran_ld': 'FC_LD',
    'objc_ld': 'OBJC_LD',
    'objcpp_ld': 'OBJCXX_LD',
    'rust_ld': 'RUSTC_LD',
}

# Map from utility names to environment variables.
ENV_VAR_TOOL_MAP: T.Mapping[str, str] = {
    # Binutils
    'ar': 'AR',
    'as': 'AS',
    'ld': 'LD',
    'nm': 'NM',
    'objcopy': 'OBJCOPY',
    'objdump': 'OBJDUMP',
    'ranlib': 'RANLIB',
    'readelf': 'READELF',
    'size': 'SIZE',
    'strings': 'STRINGS',
    'strip': 'STRIP',
    'windres': 'WINDRES',

    # Other tools
    'cmake': 'CMAKE',
    'qmake': 'QMAKE',
    'pkg-config': 'PKG_CONFIG',
    'make': 'MAKE',
    'vapigen': 'VAPIGEN',
    'llvm-config': 'LLVM_CONFIG',
}

ENV_VAR_PROG_MAP = {**ENV_VAR_COMPILER_MAP, **ENV_VAR_TOOL_MAP}

# Deprecated environment variables mapped from the new variable to the old one
# Deprecated in 0.54.0
DEPRECATED_ENV_PROG_MAP: T.Mapping[str, str] = {
    'd_ld': 'D_LD',
    'fortran_ld': 'F_LD',
    'rust_ld': 'RUST_LD',
    'objcpp_ld': 'OBJCPP_LD',
}

class CMakeSkipCompilerTest(Enum):
    ALWAYS = 'always'
    NEVER = 'never'
    DEP_ONLY = 'dep_only'

class Properties:
    def __init__(
            self,
            properties: T.Optional[T.Dict[str, ElementaryOptionValues]] = None,
    ):
        self.properties = properties or {}

    def has_stdlib(self, language: str) -> bool:
        return language + '_stdlib' in self.properties

    # Some of get_stdlib, get_root, get_sys_root are wider than is actually
    # true, but without heterogeneous dict annotations it's not practical to
    # narrow them
    def get_stdlib(self, language: str) -> T.Union[str, T.List[str]]:
        stdlib = self.properties[language + '_stdlib']
        if isinstance(stdlib, str):
            return stdlib
        assert isinstance(stdlib, list)
        for i in stdlib:
            assert isinstance(i, str)
        return stdlib

    def get_root(self) -> T.Optional[str]:
        root = self.properties.get('root', None)
        assert root is None or isinstance(root, str)
        return root

    def get_sys_root(self) -> T.Optional[str]:
        sys_root = self.properties.get('sys_root', None)
        assert sys_root is None or isinstance(sys_root, str)
        return sys_root

    def get_pkg_config_libdir(self) -> T.Optional[T.List[str]]:
        p = self.properties.get('pkg_config_libdir', None)
        if p is None:
            return p
        res = mesonlib.listify(p)
        for i in res:
            assert isinstance(i, str)
        return res

    def get_cmake_defaults(self) -> bool:
        if 'cmake_defaults' not in self.properties:
            return True
        res = self.properties['cmake_defaults']
        assert isinstance(res, bool)
        return res

    def get_cmake_toolchain_file(self) -> T.Optional[Path]:
        if 'cmake_toolchain_file' not in self.properties:
            return None
        raw = self.properties['cmake_toolchain_file']
        assert isinstance(raw, str)
        cmake_toolchain_file = Path(raw)
        if not cmake_toolchain_file.is_absolute():
            raise EnvironmentException(f'cmake_toolchain_file ({raw}) is not absolute')
        return cmake_toolchain_file

    def get_cmake_skip_compiler_test(self) -> CMakeSkipCompilerTest:
        if 'cmake_skip_compiler_test' not in self.properties:
            return CMakeSkipCompilerTest.DEP_ONLY
        raw = self.properties['cmake_skip_compiler_test']
        assert isinstance(raw, str)
        try:
            return CMakeSkipCompilerTest(raw)
        except ValueError:
            raise EnvironmentException(
                '"{}" is not a valid value for cmake_skip_compiler_test. Supported values are {}'
                .format(raw, [e.value for e in CMakeSkipCompilerTest]))

    def get_cmake_use_exe_wrapper(self) -> bool:
        if 'cmake_use_exe_wrapper' not in self.properties:
            return True
        res = self.properties['cmake_use_exe_wrapper']
        assert isinstance(res, bool)
        return res

    def get_java_home(self) -> T.Optional[Path]:
        value = T.cast('T.Optional[str]', self.properties.get('java_home'))
        return Path(value) if value else None

    def get_bindgen_clang_args(self) -> T.List[str]:
        value = mesonlib.listify(self.properties.get('bindgen_clang_arguments', []))
        if not all(isinstance(v, str) for v in value):
            raise EnvironmentException('bindgen_clang_arguments must be a string or an array of strings')
        return T.cast('T.List[str]', value)

    def __eq__(self, other: object) -> bool:
        if isinstance(other, type(self)):
            return self.properties == other.properties
        return NotImplemented

    # TODO consider removing so Properties is less freeform
    def __getitem__(self, key: str) -> T.Optional[T.Union[str, bool, int, T.List[str]]]:
        return self.properties[key]

    # TODO consider removing so Properties is less freeform
    def __contains__(self, item: T.Union[str, bool, int, T.List[str]]) -> bool:
        return item in self.properties

    # TODO consider removing, for same reasons as above
    def get(self, key: str, default: T.Optional[T.Union[str, bool, int, T.List[str]]] = None) -> T.Optional[T.Union[str, bool, int, T.List[str]]]:
        return self.properties.get(key, default)

@dataclass(unsafe_hash=True)
class MachineInfo(HoldableObject):
    system: str
    cpu_family: str
    cpu: str
    endian: str
    kernel: T.Optional[str]
    subsystem: T.Optional[str]

    def __post_init__(self) -> None:
        self.is_64_bit: bool = self.cpu_family in CPU_FAMILIES_64_BIT

    def __repr__(self) -> str:
        return f'<MachineInfo: {self.system} {self.cpu_family} ({self.cpu})>'

    @classmethod
    def from_literal(cls, raw: T.Dict[str, ElementaryOptionValues]) -> 'MachineInfo':
        # We don't have enough type information to be sure of what we loaded
        # So we need to accept that this might have ElementaryOptionValues, but
        # then ensure that it's actually strings, since that's what the
        # [*_machine] section should have.
        assert all(isinstance(v, str) for v in raw.values()), 'for mypy'
        literal = T.cast('T.Dict[str, str]', raw)
        minimum_literal = {'cpu', 'cpu_family', 'endian', 'system'}
        if minimum_literal - set(literal):
            raise EnvironmentException(
                f'Machine info is currently {literal}\n' +
                'but is missing {}.'.format(minimum_literal - set(literal)))

        cpu_family = literal['cpu_family']
        if cpu_family not in known_cpu_families:
            mlog.warning(f'Unknown CPU family {cpu_family}, please report this at https://github.com/mesonbuild/meson/issues/new')

        endian = literal['endian']
        if endian not in ('little', 'big'):
            mlog.warning(f'Unknown endian {endian}')

        system = literal['system']
        kernel = literal.get('kernel', None)
        subsystem = literal.get('subsystem', None)

        return cls(system, cpu_family, literal['cpu'], endian, kernel, subsystem)

    def is_windows(self) -> bool:
        """
        Machine is windows?
        """
        return self.system == 'windows'

    def is_cygwin(self) -> bool:
        """
        Machine is cygwin?
        """
        return self.system == 'cygwin'

    def is_linux(self) -> bool:
        """
        Machine is linux?
        """
        return self.system == 'linux'

    def is_darwin(self) -> bool:
        """
        Machine is Darwin (macOS/iOS/tvOS/visionOS/watchOS)?
        """
        return self.system in {'darwin', 'ios', 'tvos', 'visionos', 'watchos'}

    def is_android(self) -> bool:
        """
        Machine is Android?
        """
        return self.system == 'android'

    def is_haiku(self) -> bool:
        """
        Machine is Haiku?
        """
        return self.system == 'haiku'

    def is_netbsd(self) -> bool:
        """
        Machine is NetBSD?
        """
        return self.system == 'netbsd'

    def is_openbsd(self) -> bool:
        """
        Machine is OpenBSD?
        """
        return self.system == 'openbsd'

    def is_dragonflybsd(self) -> bool:
        """Machine is DragonflyBSD?"""
        return self.system == 'dragonfly'

    def is_freebsd(self) -> bool:
        """Machine is FreeBSD?"""
        return self.system == 'freebsd'

    def is_sunos(self) -> bool:
        """Machine is illumos or Solaris?"""
        return self.system == 'sunos'

    def is_hurd(self) -> bool:
        """
        Machine is GNU/Hurd?
        """
        return self.system == 'gnu'

    def is_aix(self) -> bool:
        """
        Machine is aix?
        """
        return self.system == 'aix'

    def is_irix(self) -> bool:
        """Machine is IRIX?"""
        return self.system.startswith('irix')

    # Various prefixes and suffixes for import libraries, shared libraries,
    # static libraries, and executables.
    # Versioning is added to these names in the backends as-needed.
    def get_exe_suffix(self) -> str:
        if self.is_windows() or self.is_cygwin():
            return 'exe'
        else:
            return ''

    def get_object_suffix(self) -> str:
        if self.is_windows():
            return 'obj'
        else:
            return 'o'

    def libdir_layout_is_win(self) -> bool:
        return self.is_windows() or self.is_cygwin()

class BinaryTable:

    def __init__(
            self,
            binaries: T.Optional[T.Mapping[str, ElementaryOptionValues]] = None,
    ):
        self.binaries: T.Dict[str, T.List[str]] = {}
        if binaries:
            for name, command in binaries.items():
                if not isinstance(command, (list, str)):
                    raise mesonlib.MesonException(
                        f'Invalid type {command!r} for entry {name!r} in cross file')
                self.binaries[name] = mesonlib.listify(command)
            if 'pkgconfig' in self.binaries:
                if 'pkg-config' not in self.binaries:
                    mlog.deprecation('"pkgconfig" entry is deprecated and should be replaced by "pkg-config"', fatal=False)
                    self.binaries['pkg-config'] = self.binaries['pkgconfig']
                elif self.binaries['pkgconfig'] != self.binaries['pkg-config']:
                    raise mesonlib.MesonException('Mismatched pkgconfig and pkg-config binaries in the machine file.')
                else:
                    # Both are defined with the same value, this is allowed
                    # for backward compatibility.
                    # FIXME: We should still print deprecation warning if the
                    # project targets Meson >= 1.3.0, but we have no way to know
                    # that here.
                    pass
                del self.binaries['pkgconfig']

    @staticmethod
    def detect_ccache() -> T.List[str]:
        try:
            subprocess.check_call(['ccache', '--version'], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        except (OSError, subprocess.CalledProcessError):
            return []
        return ['ccache']

    @staticmethod
    def detect_sccache() -> T.List[str]:
        try:
            subprocess.check_call(['sccache', '--version'], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        except (OSError, subprocess.CalledProcessError):
            return []
        return ['sccache']

    @staticmethod
    def detect_compiler_cache() -> T.List[str]:
        # Sccache is "newer" so it is assumed that people would prefer it by default.
        cache = BinaryTable.detect_sccache()
        if cache:
            return cache
        return BinaryTable.detect_ccache()

    @classmethod
    def parse_entry(cls, entry: T.Union[str, T.List[str]]) -> T.Tuple[T.List[str], T.List[str]]:
        parts = mesonlib.stringlistify(entry)
        # Ensure ccache exists and remove it if it doesn't
        if parts[0] == 'ccache':
            compiler = parts[1:]
            ccache = cls.detect_ccache()
        elif parts[0] == 'sccache':
            compiler = parts[1:]
            ccache = cls.detect_sccache()
        else:
            compiler = parts
            ccache = []
        if not compiler:
            raise EnvironmentException(f'Compiler cache specified without compiler: {parts[0]}')
        # Return value has to be a list of compiler 'choices'
        return compiler, ccache

    def lookup_entry(self, name: str) -> T.Optional[T.List[str]]:
        """Lookup binary in cross/native file and fallback to environment.

        Returns command with args as list if found, Returns `None` if nothing is
        found.
        """
        command = self.binaries.get(name)
        if not command:
            return None
        elif not command[0].strip():
            return None
        return command

class CMakeVariables:
    def __init__(self, variables: T.Optional[T.Dict[str, T.Any]] = None) -> None:
        variables = variables or {}
        self.variables: T.Dict[str, T.List[str]] = {}

        for key, value in variables.items():
            value = mesonlib.listify(value)
            for i in value:
                if not isinstance(i, str):
                    raise EnvironmentException(f"Value '{i}' of CMake variable '{key}' defined in a machine file is a {type(i).__name__} and not a str")
            self.variables[key] = value

    def get_variables(self) -> T.Dict[str, T.List[str]]:
        return self.variables
