# Copyright © 2012-2013 Piotr Ożarowski <piotr@debian.org>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.

import logging
import os
import re
from os.path import exists, join, split
from dhpython import INTERPRETER_DIR_TPLS, PUBLIC_DIR_RE, OLD_SITE_DIRS

SHEBANG_RE = re.compile(r'''
    (?:\#!\s*){0,1}  # shebang prefix
    (?P<path>
        .*?/bin/.*?)?
    (?P<name>
        python|pypy)
    (?P<version>
        \d[\.\d]*)?
    (?P<debug>
        -dbg)?
    (?P<options>.*)
    ''', re.VERBOSE)
EXTFILE_RE = re.compile(r'''
    (?P<name>.*?)
    (?:\.
        (?P<stableabi>abi\d+)
     |(?:\.
        (?P<soabi>
            (?P<impl>cpython|pypy)
            -
            (?P<ver>\d{2,})
            (?P<flags>[a-z]*)
        )?
        (?:
            (?:(?<!\.)-)?  # minus sign only if soabi is defined
            (?P<multiarch>[^/]*?)
        )?
    ))?
    (?P<debug>_d)?
    \.so$''', re.VERBOSE)
log = logging.getLogger('dhpython')


class Interpreter:
    """
    :attr path: /usr/bin/ in most cases
    :attr name: pypy or python (even for python3 and python-dbg) or empty string
    :attr version: interpreter's version
    :attr debug: -dbg version of the interpreter
    :attr impl: implementation (cpytho2, cpython3 or pypy)
    :attr options: options parsed from shebang
    :type path: str
    :type name: str
    :type version: Version or None
    :type debug: bool
    :type impl: str
    :type options: tuple
    """
    path = '/usr/bin/'
    name = 'python'
    version = None
    debug = False
    impl = ''
    options = ()
    _cache = {}

    def __init__(self, value=None, path=None, name=None, version=None,
                 debug=None, impl=None, options=None):
        params = locals()
        del params['self']
        del params['value']

        if isinstance(value, Interpreter):
            for key in params.keys():
                if params[key] is None:
                    params[key] = getattr(value, key)
        elif value:
            if value.replace('.', '').isdigit() and not version:
                # version string
                params['version'] = Version(value)
            else:
                # shebang or other string
                for key, val in self.parse(value).items():
                    # prefer values passed to constructor over shebang ones:
                    if params[key] is None:
                        params[key] = val

        for key, val in params.items():
            if val is not None:
                setattr(self, key, val)
            elif key == 'version':
                setattr(self, key, val)

    def __setattr__(self, name, value):
        if name == 'name':
            if value not in ('python', 'pypy', ''):
                raise ValueError("interpreter not supported: %s" % value)
            if value == 'python':
                if self.version:
                    if self.version.major == 3:
                        self.__dict__['impl'] = 'cpython3'
                    else:
                        self.__dict__['impl'] = 'cpython2'
            elif value == 'pypy':
                self.__dict__['impl'] = 'pypy'
        elif name == 'version' and value is not None:
            value = Version(value)
            if not self.impl and self.name == 'python':
                if value.major == 3:
                    self.impl = 'cpython3'
                else:
                    self.impl = 'cpython2'
        if name in ('path', 'name', 'impl', 'options') and value is None:
            pass
        elif name == 'debug':
            self.__dict__[name] = bool(value)
        else:
            self.__dict__[name] = value

    def __repr__(self):
        result = self.path
        if not result.endswith('/'):
            result += '/'
        result += self._vstr(self.version)
        if self.options:
            result += ' ' + ' '.join(self.options)
        return result

    def __str__(self):
        return self._vstr(self.version)

    def _vstr(self, version=None, consider_default_ver=False):
        if self.impl == 'pypy':
            # TODO: will Debian support more than one PyPy version?
            return self.name
        version = version or self.version or ''
        if consider_default_ver and (not version or version == self.default_version):
            version = '3' if self.impl == 'cpython3' else '2'
        if self.debug:
            return 'python{}-dbg'.format(version)
        return self.name + str(version)

    def binary(self, version=None):
        return '{}{}'.format(self.path, self._vstr(version))

    @property
    def binary_dv(self):
        """Like binary(), but returns path to default intepreter symlink
        if version matches default one for given implementation.
        """
        return '{}{}'.format(self.path, self._vstr(consider_default_ver=True))

    @property
    def default_version(self):
        if self.impl:
            return default(self.impl)

    @staticmethod
    def parse(shebang):
        """Return dict with parsed shebang

        >>> sorted(Interpreter.parse('/usr/bin/python3.2-dbg').items())
        [('debug', '-dbg'), ('name', 'python'), ('options', ()), ('path', '/usr/bin/'), ('version', '3.2')]
        >>> sorted(Interpreter.parse('#! /usr/bin/python3.2').items())
        [('debug', None), ('name', 'python'), ('options', ()), ('path', '/usr/bin/'), ('version', '3.2')]
        >>> sorted(Interpreter.parse('/usr/bin/python3.2-dbg --foo --bar').items())
        [('debug', '-dbg'), ('name', 'python'), ('options', ('--foo', '--bar')),\
 ('path', '/usr/bin/'), ('version', '3.2')]
        """
        result = SHEBANG_RE.search(shebang)
        if not result:
            return {}
        result = result.groupdict()
        if 'options' in result:
            # TODO: do we need "--key value" here?
            result['options'] = tuple(result['options'].split())
        if result['name'] == 'python' and result['version'] is None:
            result['version'] = '2'
        return result

    @classmethod
    def from_file(cls, fpath):
        """Read file's shebang and parse it."""
        interpreter = Interpreter()
        with open(fpath, 'rb') as fp:
            data = fp.read(96)
            if b"\0" in data:
                raise ValueError('cannot parse binary file')
        # make sure only first line is checkeed
        data = str(data, 'utf-8').split('\n')[0]
        if not data.startswith('#!'):
            raise ValueError("doesn't look like a shebang: %s" % data)

        parsed = cls.parse(data)
        if not parsed:
            raise ValueError("doesn't look like a shebang: %s" % data)
        for key, val in parsed.items():
            setattr(interpreter, key, val)
        return interpreter

    def sitedir(self, package=None, version=None, gdb=False):
        """Return path to site-packages directory.

        Note that returned path is not the final location of .py files

        >>> i = Interpreter('python')
        >>> i.sitedir(version='3.1')
        '/usr/lib/python3/dist-packages/'
        >>> i.sitedir(version='2.5')
        '/usr/lib/python2.5/site-packages/'
        >>> i.sitedir(version=Version('2.7'))
        '/usr/lib/python2.7/dist-packages/'
        >>> i.sitedir(version='3.1', gdb=True, package='python3-foo')
        'debian/python3-foo/usr/lib/debug/usr/lib/python3/dist-packages/'
        >>> i.sitedir(version=Version('3.2'))
        '/usr/lib/python3/dist-packages/'
        """
        try:
            version = Version(version or self.version)
        except Exception as err:
            raise ValueError("cannot find valid version: %s" % err)
        if self.impl == 'pypy':
            path = '/usr/lib/pypy/dist-packages/'
        elif version << Version('2.6'):
            path = "/usr/lib/python%s/site-packages/" % version
        elif version << Version('3.0'):
            path = "/usr/lib/python%s/dist-packages/" % version
        else:
            path = '/usr/lib/python3/dist-packages/'

        if gdb:
            path = "/usr/lib/debug%s" % path
        if package:
            path = "debian/%s%s" % (package, path)

        return path

    def old_sitedirs(self, package=None, version=None, gdb=False):
        """Return deprecated paths to site-packages directories."""
        try:
            version = Version(version or self.version)
        except Exception as err:
            raise ValueError("cannot find valid version: %s" % err)
        result = []
        for item in OLD_SITE_DIRS.get(self.impl, []):
            if isinstance(item, str):
                result.append(item.format(version))
            else:
                res = item(version)
                if res is not None:
                    result.append(res)

        if gdb:
            result = ['/usr/lib/debug{}'.format(i) for i in result]
            if self.impl.startswith('cpython'):
                result.append('/usr/lib/debug/usr/lib/pyshared/python{}'.format(version))
        if package:
            result = ['debian/{}{}'.format(package, i) for i in result]

        return result

    def parse_public_dir(self, path):
        """Return version assigned to site-packages path
        or True is it's unversioned public dir."""
        match = PUBLIC_DIR_RE[self.impl].match(path)
        if match:
            vers = match.groups(0)
            if vers and vers[0]:
                return Version(vers)
            return True

    def should_ignore(self, path):
        """Return True if path is used by another interpreter implementation."""
        cache_key = 'should_ignore_{}'.format(self.impl)
        if cache_key not in self.__class__._cache:
            expr = [v for k, v in INTERPRETER_DIR_TPLS.items() if k != self.impl]
            regexp = re.compile('|'.join('({})'.format(i) for i in expr))
            self.__class__._cache[cache_key] = regexp
        else:
            regexp = self.__class__._cache[cache_key]
        return regexp.search(path)

    def cache_file(self, fpath, version=None):
        """Given path to a .py file, return path to its .pyc/.pyo file.

        This function is inspired by Python 3.2's imp.cache_from_source.

        :param fpath: path to file name
        :param version: Python version

        >>> i = Interpreter('python')
        >>> i.cache_file('foo.py', Version('3.1'))
        'foo.pyc'
        >>> i.cache_file('bar/foo.py', '3.8')          # doctest: +SKIP
        'bar/__pycache__/foo.cpython-38.pyc'
        """
        version = Version(version or self.version)
        last_char = 'o' if '-O' in self.options else 'c'
        if version <= Version('3.1'):
            return fpath + last_char

        fdir, fname = split(fpath)
        if not fname.endswith('.py'):
            fname += '.py'
        return join(fdir, '__pycache__', "%s.%s.py%s" %
                    (fname[:-3], self.magic_tag(version), last_char))

    def magic_number(self, version=None):
        """Return magic number."""
        version = Version(version or self.version)
        if self.impl == 'cpython2':
            return ''
        result = self._execute('import imp; print(imp.get_magic())', version)
        return eval(result)

    def magic_tag(self, version=None):
        """Return Python magic tag (used in __pycache__ dir to tag files).

        >>> i = Interpreter('python')
        >>> i.magic_tag(version='3.8')                 # doctest: +SKIP
        'cpython-38'
        """
        version = Version(version or self.version)
        if self.impl.startswith('cpython') and version << Version('3.2'):
            return ''
        return self._execute('import imp; print(imp.get_tag())', version)

    def multiarch(self, version=None):
        """Return multiarch tag."""
        version = Version(version or self.version)
        try:
            soabi, multiarch = self._get_config(version)[:2]
        except Exception:
            log.debug('cannot get multiarch', exc_info=True)
            # interpreter without multiarch support
            return ''
        return multiarch

    def stableabi(self, version=None):
        version = Version(version or self.version)
        # stable ABI was introduced in Python 3.3
        if self.impl == 'cpython3' and version >> Version('3.2'):
            return 'abi{}'.format(version.major)

    def soabi(self, version=None):
        """Return SOABI flag (used to in .so files)."""
        version = Version(version or self.version)
        # NOTE: it's not the same as magic_tag
        try:
            soabi, multiarch = self._get_config(version)[:2]
        except Exception:
            log.debug('cannot get soabi', exc_info=True)
            # interpreter without soabi support
            return ''
        return soabi

    @property
    def include_dir(self):
        """Return INCLUDE_DIR path.

        >>> Interpreter('python2.7').include_dir       # doctest: +SKIP
        '/usr/include/python2.7'
        >>> Interpreter('python3.8-dbg').include_dir   # doctest: +SKIP
        '/usr/include/python3.8d'
        """
        if self.impl == 'pypy':
            return '/usr/lib/pypy/include'
        try:
            result = self._get_config()[2]
            if result:
                return result
        except Exception:
            result = ''
            log.debug('cannot get include path', exc_info=True)
        result = '/usr/include/{}'.format(self.name)
        version = self.version
        if self.debug:
            if version >= '3.8':
                result += 'd'
            elif version << '3.3':
                result += '_d'
            else:
                result += 'dm'
        else:
            if version >= '3.8':
                pass
            elif version >> '3.2':
                result += 'm'
            elif version == '3.2':
                result += 'mu'
        return result

    @property
    def symlinked_include_dir(self):
        """Return path to symlinked include directory."""
        if self.impl in ('cpython2', 'pypy') or self.debug \
           or self.version >> '3.7' or self.version << '3.3':
            # these interpreters do not provide symlink,
            # others provide it in libpython3.X-dev
            return
        try:
            result = self._get_config()[2]
            if result:
                if result.endswith('m'):
                    return result[:-1]
                else:
                    # there's include_dir, but no "m"
                    return
        except Exception:
            result = '/usr/include/{}'.format(self.name)
            log.debug('cannot get include path', exc_info=True)
        return result

    @property
    def library_file(self):
        """Return libfoo.so file path."""
        if self.impl == 'pypy':
            return ''
        libpl, ldlibrary = self._get_config()[3:5]
        if ldlibrary.endswith('.a'):
            # python3.1-dbg, python3.2, python3.2-dbg returned static lib
            ldlibrary = ldlibrary.replace('.a', '.so')
        if libpl and ldlibrary:
            return join(libpl, ldlibrary)
        raise Exception('cannot find library file for {}'.format(self))

    def check_extname(self, fname, version=None):
        """Return extension file name if file can be renamed."""
        if not version and not self.version:
            return

        version = Version(version or self.version)

        if '/' in fname:
            fdir, fname = fname.rsplit('/', 1)  # in case full path was passed
        else:
            fdir = ''

        info = EXTFILE_RE.search(fname)
        if not info:
            return
        info = info.groupdict()
        if info['ver'] and (not version or version.minor is None):
            # get version from soabi if version is not set of only major
            # version number is set
            version = Version("%s.%s" % (info['ver'][0], info['ver'][1]))

        if info['stableabi']:
            # files with stable ABI in name don't need changes
            return
        if info['debug'] and self.debug is False:
            # do not change Python 2.X extensions already marked as debug
            # (the other way around is acceptable)
            return
        if info['soabi'] and info['multiarch']:
            # already tagged, nothing we can do here
            return

        try:
            soabi, multiarch = self._get_config(version)[:2]
        except Exception:
            log.debug('cannot get soabi/multiarch', exc_info=True)
            return

        if info['soabi'] and soabi and info['soabi'] != soabi:
            return

        tmp_soabi = info['soabi'] or soabi
        tmp_multiarch = info['multiarch'] or multiarch

        result = info['name']
        if result.endswith('module') and result != 'module' and (
           self.impl == 'cpython3' and version >> '3.2' or
           self.impl == 'cpython2' and version == '2.7'):
            result = result[:-6]

        if tmp_soabi:
            result = "{}.{}".format(result, tmp_soabi)
            if tmp_multiarch and not (self.impl == 'cpython3' and version << '3.3') and tmp_multiarch not in soabi:
                result = "{}-{}".format(result, tmp_multiarch)
        elif self.impl == 'cpython2' and version == '2.7' and tmp_multiarch:
            result = "{}.{}".format(result, tmp_multiarch)

        if self.debug and self.impl == 'cpython2':
            result += '_d'
        result += '.so'
        if fname == result:
            return
        return join(fdir, result)

    def suggest_pkg_name(self, name):
        """Suggest binary package name with for given library name

        >>> Interpreter('python3.1').suggest_pkg_name('foo')
        'python3-foo'
        >>> Interpreter('python3.8').suggest_pkg_name('foo_bar')
        'python3-foo-bar'
        >>> Interpreter('python2.7-dbg').suggest_pkg_name('bar')
        'python-bar-dbg'
        """
        name = name.replace('_', '-')
        if self.impl == 'pypy':
            return 'pypy-{}'.format(name)
        version = '3' if self.impl == 'cpython3' else ''
        result = 'python{}-{}'.format(version, name)
        if self.debug:
            result += '-dbg'
        return result

    def _get_config(self, version=None):
        version = Version(version or self.version)
        # sysconfig module is available since Python 3.2
        # (also backported to Python 2.7)
        if self.impl == 'pypy' or self.impl.startswith('cpython') and (
                version >> '2.6' and version << '3'
                or version >> '3.1' or version == '3'):
            cmd = 'import sysconfig as s;'
        else:
            cmd = 'from distutils import sysconfig as s;'
        cmd += 'print("__SEP__".join(i or "" ' \
               'for i in s.get_config_vars('\
               '"SOABI", "MULTIARCH", "INCLUDEPY", "LIBPL", "LDLIBRARY")))'
        conf_vars = self._execute(cmd, version).split('__SEP__')
        if conf_vars[1] in conf_vars[0]:
            # Python >= 3.5 includes MILTIARCH in SOABI
            conf_vars[0] = conf_vars[0].replace("-%s" % conf_vars[1], '')
        try:
            conf_vars[1] = os.environ['DEB_HOST_MULTIARCH']
        except KeyError:
            pass
        return conf_vars

    def _execute(self, command, version=None, cache=True):
        version = Version(version or self.version)
        exe = "{}{}".format(self.path, self._vstr(version))
        command = "{} -c '{}'".format(exe, command.replace("'", "\'"))
        if cache and command in self.__class__._cache:
            return self.__class__._cache[command]
        if not exists(exe):
            raise Exception("cannot execute command due to missing "
                            "interpreter: %s" % exe)

        output = execute(command)
        if output['returncode'] != 0:
            log.debug(output['stderr'])
            raise Exception('{} failed with status code {}'.format(command, output['returncode']))

        result = output['stdout'].splitlines()

        if len(result) == 1:
            result = result[0]

        if cache:
            self.__class__._cache[command] = result

        return result

# due to circular imports issue
from dhpython.tools import execute
from dhpython.version import Version, default
