"""
Functionality for the whole app, easily importable via one name
"""

import logging
import os
import re
import sys
from logging.handlers import RotatingFileHandler

import runez

from pickley import pickley_program_path


PICKLEY = "pickley"

WRAPPER_MARK = "# Wrapper generated by https://pypi.org/project/pickley/"

SETTINGS = None  # type: pickley.settings.Settings
DESIRED_PYTHON = None  # type: str # Desired python installation (if explicitly stated via CLI flags)

LATEST_CHANNEL = "latest"
VENV_PACKAGER = "venv"
DEFAULT_DELIVERY = "symlink"
INVOKER = "invoker"

RE_PYTHON_LOOSE = re.compile(r"(py(thon ?)?)?([0-9])?\.?([0-9])?\.?[0-9]*", re.IGNORECASE)
RE_PYTHON_STRICT = re.compile(r"(python([0-9]\.[0-9])|([0-9]\.[0-9])\.?[0-9]*)")


class LogHandlers:
    """Log handlers, allows to setup logging once"""
    audit_handler = None
    debug_handler = None


PICKLEY_PROGRAM_PATH = runez.resolved_path(pickley_program_path())
sys.argv[0] = PICKLEY_PROGRAM_PATH


def short(path, meta=True):
    """
    :param path: Path to represent in its short form
    :param bool meta: If True, shorten paths relatively to SYSTEM.meta as well
    :return str: Short form, using '~' if applicable
    """
    if not path:
        return path
    if not meta:
        runez.pop_anchors(SETTINGS.meta.path)
    result = runez.short(str(path))
    if not meta:
        runez.add_anchors(SETTINGS.meta.path)
    return result


def despecced(text):
    """
    :param str text: Text of form <name>==<version>, or just <name>
    :return str, str|None: Name and version
    """
    version = None
    if text and "==" in text:
        i = text.strip().index("==")
        version = text[i + 2:]
        text = text[:i]
    return text, version


def installed_names():
    """Yield names of currently installed packages"""
    result = []
    if os.path.isdir(SETTINGS.meta.path):
        for fname in os.listdir(SETTINGS.meta.path):
            fpath = os.path.join(SETTINGS.meta.path, fname)
            if os.path.isdir(fpath):
                if os.path.exists(os.path.join(fpath, ".current.json")):
                    result.append(fname)
    return result


def setup_audit_log():
    """Log to <meta>/audit.log"""
    if runez.DRYRUN or LogHandlers.audit_handler:
        return
    path = SETTINGS.meta.full_path("audit.log")
    runez.ensure_folder(path)
    LogHandlers.audit_handler = RotatingFileHandler(path, maxBytes=500 * 1024, backupCount=1)
    LogHandlers.audit_handler.setLevel(logging.DEBUG)
    LogHandlers.audit_handler.setFormatter(logging.Formatter("%(asctime)s [%(process)s] %(levelname)s - %(message)s"))
    logging.root.addHandler(LogHandlers.audit_handler)
    runez.State.logging = True
    runez.info(":: %s", runez.represented_args(sys.argv), output=False)


def setup_debug_log():
    """Log to stderr"""
    # Log to console with --debug or --dryrun
    if LogHandlers.debug_handler:
        return
    runez.State.output = False
    LogHandlers.debug_handler = logging.StreamHandler()
    LogHandlers.debug_handler.setLevel(logging.DEBUG)
    LogHandlers.debug_handler.setFormatter(logging.Formatter("%(levelname)s - %(message)s"))
    logging.root.addHandler(LogHandlers.debug_handler)
    logging.root.setLevel(logging.DEBUG)
    runez.State.logging = True


def virtualenv_path():
    """
    :return str: Path to our own virtualenv.py
    """
    import virtualenv
    path = virtualenv.__file__
    if path and path.endswith(".pyc"):
        path = path[:-1]
    return path


def is_universal(wheels_folder):
    """
    :param str wheels_folder: Path to folder where wheels reside
    :return bool: True if all wheels in the folder are universal
    """
    if wheels_folder and os.path.isdir(wheels_folder):
        return not any(n.endswith(".whl") and not n.endswith("-py2.py3-none-any.whl") for n in os.listdir(wheels_folder))


def relocate_venv(path, source, destination, fatal=True, logger=runez.debug, _seen=None):
    """
    :param str path: Path of file or folder to relocate (change mentions of 'source' to 'destination')
    :param str source: Where venv used to be
    :param str destination: Where venv is moved to
    :param bool fatal: Abort execution on failure if True
    :param callable|None logger: Logger to use
    :return int: Number of relocated files (0 if no-op, -1 on failure)
    """
    original_call = False
    if _seen is None:
        original_call = True
        _seen = set()

    if not path or path in _seen:
        return 0

    _seen.add(path)
    if os.path.isdir(path):
        relocated = 0
        if original_call:
            for bin_folder in find_venvs(path):
                for name in os.listdir(bin_folder):
                    fpath = os.path.join(bin_folder, name)
                    r = relocate_venv(fpath, source, destination, fatal=fatal, logger=logger, _seen=_seen)
                    if r < 0:
                        return r
                    relocated += r
            if logger and relocated:
                logger("Relocated %s files in %s: %s -> %s", relocated, short(path), short(source), short(destination))
        return relocated

    content = runez.get_lines(path, fatal=fatal)
    if not content:
        return 0

    modified = False
    lines = []
    for line in content:
        if source in line:
            line = line.replace(source, destination)
            modified = True
        lines.append(line)

    if not modified:
        return 0

    r = runez.write_contents(path, "".join(lines), fatal=fatal)
    if r > 0 and logger and original_call:
        logger("Relocated %s: %s -> %s", short(path), short(source), short(destination))
    return r


def find_venvs(folder, _seen=None):
    """
    :param str folder: Folder to scan for venvs
    :param set|None _seen: Allows to not get stuck on circular symlinks
    """
    if folder and os.path.isdir(folder):
        if _seen is None:
            folder = os.path.realpath(folder)
            _seen = set()
        if folder not in _seen:
            _seen.add(folder)
            files = os.listdir(folder)
            if "bin" in files:
                bin_folder = os.path.join(folder, "bin")
                if runez.is_executable(os.path.join(bin_folder, "python")):
                    yield bin_folder
                    return
            for name in files:
                fname = os.path.join(folder, name)
                for path in find_venvs(fname, _seen=_seen):
                    yield path


def copy(source, destination, fatal=True, logger=runez.debug):
    """
    Copy source -> destination

    :param str source: Source file or folder
    :param str destination: Destination file or folder
    :param bool fatal: Abort execution on failure if True
    :param callable|None logger: Logger to use
    :return int: 1 if effectively done, 0 if no-op, -1 on failure
    """
    return runez.copy(source, destination, adapter=_relocator, fatal=fatal, logger=logger)


def move(source, destination, fatal=True, logger=runez.debug):
    """
    Move source -> destination

    :param str source: Source file or folder
    :param str destination: Destination file or folder
    :param bool fatal: Abort execution on failure if True
    :param callable|None logger: Logger to use
    :return int: 1 if effectively done, 0 if no-op, -1 on failure
    """
    return runez.move(source, destination, adapter=_relocator, fatal=fatal, logger=logger)


def _relocator(source, destination, fatal=True, logger=None):
    """Adapter for move/copy file"""
    relocated = relocate_venv(source, source, destination, fatal=fatal, logger=None)
    return " (relocated %s)" % relocated if relocated else ""


def run_python(*args, **kwargs):
    """Invoke targetted python interpreter with given args"""
    package_name = kwargs.pop("package_name", None)
    python = target_python(package_name=package_name)
    return runez.run_program(python.executable, *args, **kwargs)


class FolderBase(object):
    """
    This class allows to more easily deal with folders
    """

    def __init__(self, path, name=None):
        """
        :param str path: Path to folder
        :param str|None name: Name of this folder (defaults to basename of 'path')
        """
        self.path = runez.resolved_path(path)
        self.name = name or os.path.basename(path)

    def relative_path(self, path):
        """
        :param str path: Path to relativize
        :return str: 'path' relative to self.path
        """
        return os.path.relpath(path, self.path)

    def relativize(self, component):
        return component or "" if not component or not component.startswith("/") else component[1:]

    def full_path(self, *relative):
        """
        :param list(str) *relative: Relative components
        :return str: Full path based on self.path
        """
        relative = [self.relativize(c) for c in relative]
        return os.path.join(self.path, *relative)

    def __repr__(self):
        return "%s: %s" % (self.name, short(self.path))


def parent_python():
    prefix = getattr(sys, "real_prefix", None)
    if prefix:
        path = os.path.join(prefix, "bin", "python")
        if runez.is_executable(path):
            return path


def target_python(desired=None, package_name=None, fatal=True):
    """
    :param str|None desired: Desired python (overrides anything else configured)
    :param str|None package_name: Target pypi package
    :param bool|None fatal: If True, abort execution if python invalid
    :return PythonInstallation: Python installation to use
    """
    if not desired:
        desired = DESIRED_PYTHON or SETTINGS.resolved_value("python", package_name=package_name) or INVOKER
    python = PythonInstallation(desired)
    if not python.is_valid:
        return runez.abort(python.problem, fatal=(fatal, python))

    return python


class PythonInstallation:

    text = None  # type: str # Given description or path
    executable = None  # type: str # Full path to python executable
    major = None  # type: str # Major version
    minor = None  # type: str # Minor version

    def __init__(self, text):
        """
        :param str text: Python, of the form: python, or python3.6, or py36, or full path to exe
        """
        self.text = text
        if text == INVOKER:
            text = parent_python() or sys.executable
        if os.path.isabs(text):
            self._set_executable(text)
            return
        m = RE_PYTHON_LOOSE.match(text)
        if m:
            self.major = m.group(3)
            self.minor = m.group(4)
            self.resolve_executable()

    def _set_executable(self, path):
        path = runez.resolved_path(path)
        if runez.is_executable(path):
            self.executable = path
            if not self.major or not self.minor:
                output = runez.run_program(self.executable, "--version", dryrun=False, fatal=None, include_error=True, logger=None)
                if output:
                    m = RE_PYTHON_LOOSE.match(output)
                    if m:
                        self.major = m.group(3)
                        self.minor = m.group(4)

    def resolve_executable(self):
        """Resolve executable from given major/minor"""
        self._set_executable(self._resolve_from_configured(SETTINGS.get_value("python_installs")))
        if not self.executable:
            self._set_executable(runez.which(self.program_name, ignore_own_venv=True))

    def _resolve_from_configured(self, folder):
        """
        Resolve python executable from a configured folder
        This aims to support pyenv like installations, as well as /usr/bin-like ones
        """
        folder = runez.resolved_path(folder)
        if not folder or not self.major or not os.path.isdir(folder):
            return None

        timestamp = None
        result = None
        interesting = [self.program_name, "%s.%s" % (self.major, self.minor or "")]
        for fname in os.listdir(folder):
            m = RE_PYTHON_STRICT.match(fname)
            if not m:
                continue
            m = m.group(2) or m.group(3)
            if not m or not any(m.startswith(x) for x in interesting):
                continue
            ts = os.path.getmtime(os.path.join(folder, fname))
            if timestamp is None or timestamp < ts:
                timestamp = ts
                result = fname

        return result and self._first_executable(
            os.path.join(folder, result, "bin", "python"),
            os.path.join(folder, result),
        )

    def _first_executable(self, *paths):
        for path in paths:
            if runez.is_executable(path):
                return path
        return None

    def __repr__(self):
        if self.is_valid:
            return "%s [%s.%s]" % (self.executable, self.major, self.minor)
        return self.program_name

    def shebang(self, universal=False):
        """
        :param bool universal: True if produced package is universal
        :return str: Shebang to use
        """
        if DESIRED_PYTHON and os.path.isabs(DESIRED_PYTHON):
            return DESIRED_PYTHON
        if universal:
            return "/usr/bin/env python"
        if os.path.isabs(self.text):
            return self.executable
        return "/usr/bin/env %s" % self.program_name

    @property
    def problem(self):
        if not self.major or not self.minor:
            if self.major:
                return "'%s' is not a valid python installation" % (self.executable or self.program_name)
            return "No python installation '%s' found" % self.text
        if not self.executable:
            return "%s is not installed" % self.program_name
        return None

    @property
    def is_valid(self):
        return self.problem is None

    @property
    def short_name(self):
        return "py%s%s" % (self.major, self.minor)

    @property
    def program_name(self):
        if self.major and self.minor:
            return "python%s.%s" % (self.major, self.minor)
        elif self.major:
            return "python%s" % self.major
        elif self.text and self.text.startswith("python"):
            return self.text
        return "python '%s'" % self.text
