# -*- coding: utf-8 -*-
#
# WinPython Package Manager
# Copyright © 2012 Pierre Raybaut
# Copyright © 2014-2025+ The Winpython development team https://github.com/winpython/
# Licensed under the terms of the MIT License
# (see winpython/__init__.py for details)

import os
import re
import sys
import shutil
import subprocess
import json
from pathlib import Path
from argparse import ArgumentParser, RawTextHelpFormatter
from winpython import utils, piptree, associate

# Workaround for installing PyVISA on Windows from source:
os.environ["HOME"] = os.environ["USERPROFILE"]

class Package:
    """Standardize a Package from filename or pip list."""
    def __init__(self, fname: str, suggested_summary: str = None):
        self.fname = fname
        self.description = piptree.sum_up(suggested_summary) if suggested_summary else ""
        self.name, self.version = None, None
        if fname.lower().endswith((".zip", ".tar.gz", ".whl")):
            bname = Path(self.fname).name # e.g., "sqlite_bro-1.0.0..."
            infos = utils.get_source_package_infos(bname) # get name, version
            if infos:
                self.name, self.version = utils.normalize(infos[0]), infos[1]
        self.url = f"https://pypi.org/project/{self.name}"
        self.files = []

    def __str__(self):
        return f"{self.name} {self.version}\r\n{self.description}\r\nWebsite: {self.url}"


class Distribution:
    """Handles operations on a WinPython distribution."""
    def __init__(self, target: str = None, verbose: bool = False):
        self.target = target or str(Path(sys.executable).parent) # Default target more explicit
        self.verbose = verbose
        self.pip = None
        self.to_be_removed = []
        self.version, self.architecture = utils.get_python_infos(self.target)
        self.short_exe = Path(utils.get_python_executable(self.target)).name

    def clean_up(self):
        """Remove directories that were marked for removal."""
        for path in self.to_be_removed:
            try:
                shutil.rmtree(path, onexc=utils.onerror)
            except OSError as e:
                print(f"Error: Could not remove directory {path}: {e}", file=sys.stderr)

    def remove_directory(self, path: str):
        """Try to remove a directory, add to removal list on failure."""
        try:
            shutil.rmtree(path)
        except OSError:
            self.to_be_removed.append(path)

    def create_file(self, package, name, dstdir, contents):
        """Generate data file -- path is relative to distribution root dir"""
        dst = Path(dstdir) / name
        if self.verbose:
            print(f"create:  {dst}")
        full_dst = Path(self.target) / dst
        with open(full_dst, "w") as fd:
            fd.write(contents)
        package.files.append(str(dst))

    def get_installed_packages(self, update: bool = False) -> list[Package]:
        """Return installed packages."""

        # Include package installed via pip (not via WPPM)
        if str(Path(sys.executable).parent) == self.target:
            self.pip = piptree.PipData()
        else:
            self.pip = piptree.PipData(utils.get_python_executable(self.target))
        pip_list = self.pip.pip_list()

        # return a list of package objects
        return [Package(f"{i[0].replace('-', '_').lower()}-{i[1]}-py3-none-any.whl") for i in pip_list]

    def find_package(self, name: str) -> Package | None:
        """Find installed package by name."""
        for pack in self.get_installed_packages():
            if utils.normalize(pack.name) == utils.normalize(name):
                return pack

    def patch_all_shebang(self, to_movable: bool = True, max_exe_size: int = 999999, targetdir: str = ""):
        """Make all python launchers relative."""
        for ffname in Path(self.target).glob("Scripts/*.exe"):
            if ffname.stat().st_size <= max_exe_size:
                utils.patch_shebang_line(ffname, to_movable=to_movable, targetdir=targetdir)
        for ffname in Path(self.target).glob("Scripts/*.py"):
            utils.patch_shebang_line_py(ffname, to_movable=to_movable, targetdir=targetdir)

    def install(self, package: Package, install_options: list[str] = None):
        """Install package in distribution."""
        if package.fname.endswith((".whl", ".tar.gz", ".zip")): # Check extension with tuple
            self.install_bdist_direct(package, install_options=install_options)
        self.handle_specific_packages(package)
        # minimal post-install actions
        self.patch_standard_packages(package.name)

    def do_pip_action(self, actions: list[str] = None, install_options: list[str] = None):
        """Execute pip action in the distribution."""
        my_list = install_options or []
        my_actions = actions or []
        executing = str(Path(self.target).parent / "scripts" / "env.bat")
        if Path(executing).is_file():
            complement = [r"&&", "cd", "/D", self.target, r"&&", utils.get_python_executable(self.target), "-m", "pip"]
        else:
            executing = utils.get_python_executable(self.target)
            complement = ["-m", "pip"]
        try:
            fname = utils.do_script(this_script=None, python_exe=executing, verbose=self.verbose, install_options=complement + my_actions + my_list)
        except RuntimeError as e:
            if not self.verbose:
                print("Failed!")
                raise
            else:
                print(f"Pip action failed with error: {e}") # Print error if verbose

    def patch_standard_packages(self, package_name="", to_movable=True):
        """patch Winpython packages in need"""
        import filecmp

        # 'pywin32' minimal post-install (pywin32_postinstall.py do too much)
        if package_name.lower() in ("", "pywin32"):
            origin = Path(self.target) / "site-packages" / "pywin32_system32"
            destin = Path(self.target)
            if origin.is_dir():
                for name in os.listdir(origin):
                    here, there = origin / name, destin / name
                    if not there.exists() or not filecmp.cmp(here, there):
                        shutil.copyfile(here, there)
        # 'pip' to do movable launchers (around line 100) !!!!
        # rational: https://github.com/pypa/pip/issues/2328
        if package_name.lower() == "pip" or package_name == "":
            # ensure pip will create movable launchers
            # sheb_mov1 = classic way up to WinPython 2016-01
            # sheb_mov2 = tried  way, but doesn't work for pip (at least)
            the_place = Path(self.target) / "lib" / "site-packages" / "pip" / "_vendor" / "distlib" / "scripts.py"
            sheb_fix = " executable = get_executable()"
            sheb_mov1 = " executable = os.path.join(os.path.basename(get_executable()))"
            sheb_mov2 = " executable = os.path.join('..',os.path.basename(get_executable()))"
            if to_movable:
                utils.patch_sourcefile(the_place, sheb_fix, sheb_mov1)
                utils.patch_sourcefile(the_place, sheb_mov2, sheb_mov1)
            else:
                utils.patch_sourcefile(the_place, sheb_mov1, sheb_fix)
                utils.patch_sourcefile(the_place, sheb_mov2, sheb_fix)

            # create movable launchers for previous package installations
            self.patch_all_shebang(to_movable=to_movable)
        if package_name.lower() in ("", "spyder"):
            # spyder don't goes on internet without I ask
            utils.patch_sourcefile(
                Path(self.target) / "lib" / "site-packages" / "spyder" / "config" / "main.py",
                "'check_updates_on_startup': True,",
                "'check_updates_on_startup': False,",
            )


    def handle_specific_packages(self, package):
        """Packages requiring additional configuration"""
        if package.name.lower() in ("pyqt4", "pyqt5", "pyside2"):
            # Qt configuration file (where to find Qt)
            name = "qt.conf"
            contents = """[Paths]\nPrefix = .\nBinaries = ."""
            self.create_file(package, name, str(Path("Lib") / "site-packages" / package.name), contents)
            self.create_file(package, name, ".", contents.replace(".", f"./Lib/site-packages/{package.name}"))
            # pyuic script
            if package.name.lower() == "pyqt5":
                # see http://code.activestate.com/lists/python-list/666469/
                tmp_string = r"""@echo off
if "%WINPYDIR%"=="" call "%~dp0..\..\scripts\env.bat"
"%WINPYDIR%\python.exe" -m PyQt5.uic.pyuic %1 %2 %3 %4 %5 %6 %7 %8 %9"""
            else:
                tmp_string = r"""@echo off
if "%WINPYDIR%"=="" call "%~dp0..\..\scripts\env.bat"
"%WINPYDIR%\python.exe" "%WINPYDIR%\Lib\site-packages\package.name\uic\pyuic.py" %1 %2 %3 %4 %5 %6 %7 %8 %9"""
            # PyPy adaption: python.exe or pypy3.exe
            my_exec = Path(utils.get_python_executable(self.target)).name
            tmp_string = tmp_string.replace("python.exe", my_exec).replace("package.name", package.name)
            self.create_file(package, f"pyuic{package.name[-1]}.bat", "Scripts", tmp_string)
            # Adding missing __init__.py files (fixes Issue 8)
            uic_path = str(Path("Lib") / "site-packages" / package.name / "uic")
            for dirname in ("Loader", "port_v2", "port_v3"):
                self.create_file(package, "__init__.py", str(Path(uic_path) / dirname), "")

    def _print(self, package: Package, action: str):
        """Print package-related action text."""
        text = f"{action} {package.name} {package.version}"
        if self.verbose:
            utils.print_box(text)
        else:
            print(f"    {text}...", end=" ")

    def _print_done(self):
        """Print OK at the end of a process"""
        if not self.verbose:
            print("OK")

    def uninstall(self, package):
        """Uninstall package from distribution"""
        self._print(package, "Uninstalling")
        if package.name != "pip":
            # trick to get true target (if not current)
            this_exec = utils.get_python_executable(self.target)  # PyPy !
            subprocess.call([this_exec, "-m", "pip", "uninstall", package.name, "-y"], cwd=self.target)
        self._print_done()

    def install_bdist_direct(self, package, install_options=None):
        """Install a package directly !"""
        self._print(package,f"Installing {package.fname.split('.')[-1]}")
        try:
            fname = utils.direct_pip_install(
                package.fname,
                python_exe=utils.get_python_executable(self.target),  # PyPy !
                verbose=self.verbose,
                install_options=install_options,
            )
        except RuntimeError:
            if not self.verbose:
                print("Failed!")
                raise
        package = Package(fname)
        self._print_done()

def main(test=False):

    registerWinPythonHelp = f"Register distribution: associate file extensions, icons and context menu with this WinPython"
    unregisterWinPythonHelp = f"Unregister distribution: de-associate file extensions, icons and context menu from this WinPython"
    parser = ArgumentParser(
        description="WinPython Package Manager: handle a WinPython Distribution and its packages",
        formatter_class=RawTextHelpFormatter,
    )
    parser.add_argument(
        "fname",
        metavar="package",
        nargs="?",
        default="",
        type=str,
        help="optional package name or package wheel",
    )
    parser.add_argument(
        "--register",
        dest="registerWinPython",
        action="store_true", # Store True when flag is present
        help=registerWinPythonHelp,
    )
    parser.add_argument(
        "--unregister",
        dest="unregisterWinPython",
        action="store_true",
        help=unregisterWinPythonHelp,
    )
    parser.add_argument(
        "-v", "--verbose",
        action="store_true",
        help="show more details on packages and actions",
    )
    parser.add_argument(
        "-ls", "--list",
        action="store_true",
        help="list installed packages matching the given [optional] package expression: wppm -ls, wppm -ls pand",
    )
    parser.add_argument(
        "-p",
        dest="pipdown",
        action="store_true",
        help="show Package dependencies of the given package[option]: wppm -p pandas[test]",
    )
    parser.add_argument(
        "-r",
        dest="pipup",
        action="store_true",
        help=f"show Reverse dependancies of the given package[option]: wppm -r pytest[test]",
    )
    parser.add_argument(
        "-l", "--levels",
        type=int,
        default=2,
        help="show 'LEVELS' levels of dependencies (with -p, -r), default is 2: wppm -p pandas -l1",
    )
    parser.add_argument(
        "-lsa",
        dest="all",
        action="store_true",
        help=f"list details of package names matching given regular expression: wppm -lsa pandas -l1",
    )
    parser.add_argument(
        "-t", "--target",
        default=sys.prefix,
        help=f'path to target Python distribution (default: "{sys.prefix}")',
    )
    parser.add_argument(
        "-i", "--install",
        action="store_true",
        help="install a given package wheel (use pip for more features)",
    )
    parser.add_argument(
        "-u", "--uninstall",
        action="store_true", # Store True when flag is present
        help="uninstall package  (use pip for more features)",
    )
    args = parser.parse_args()
    targetpython = None
    if args.target and args.target != sys.prefix:
        targetpython = args.target if args.target.lower().endswith('.exe') else str(Path(args.target) / 'python.exe')
    if args.install and args.uninstall:
        raise RuntimeError("Incompatible arguments: --install and --uninstall")
    if args.registerWinPython and args.unregisterWinPython:
        raise RuntimeError("Incompatible arguments: --install and --uninstall")
    if args.pipdown:
        pip = piptree.PipData(targetpython)
        pack, extra, *other = (args.fname + "[").replace("]", "[").split("[")
        print(pip.down(pack, extra, args.levels, verbose=args.verbose))
        sys.exit()
    elif args.pipup:
        pip = piptree.PipData(targetpython)
        pack, extra, *other = (args.fname + "[").replace("]", "[").split("[")
        print(pip.up(pack, extra, args.levels, verbose=args.verbose))
        sys.exit()
    elif args.list:
        pip = piptree.PipData(targetpython)
        todo = [l for l in pip.pip_list(full=True) if bool(re.search(args.fname, l[0]))]
        titles = [['Package', 'Version', 'Summary'], ['_' * max(x, 6) for x in utils.columns_width(todo)]]
        listed = utils.formatted_list(titles + todo, max_width=70)
        for p in listed:
            print(*p)
        sys.exit()
    elif args.all:
        pip = piptree.PipData(targetpython)
        todo = [l for l in pip.pip_list(full=True) if bool(re.search(args.fname, l[0]))]
        for l in todo:
            # print(pip.distro[l[0]])
            title = f"**  Package: {l[0]}  **"
            print("\n" + "*" * len(title), f"\n{title}", "\n" + "*" * len(title))
            for key, value in pip.raw[l[0]].items():
                rawtext = json.dumps(value, indent=2, ensure_ascii=False)
                lines = [l for l in rawtext.split(r"\n") if len(l.strip()) > 2]
                if key.lower() != 'description' or args.verbose:
                    print(f"{key}: ", "\n".join(lines).replace('"', ""))
        sys.exit()
    if args.registerWinPython:
        print(registerWinPythonHelp)
        if utils.is_python_distribution(args.target):
            dist = Distribution(args.target)
        else:
            raise OSError(f"Invalid Python distribution {args.target}")
        print(f"registering {args.target}")
        print("continue ? Y/N")
        theAnswer = input()
        if theAnswer == "Y":
            associate.register(dist.target, verbose=args.verbose)
            sys.exit()
    if args.unregisterWinPython:
        print(unregisterWinPythonHelp)
        if utils.is_python_distribution(args.target):
            dist = Distribution(args.target)
        else:
            raise OSError(f"Invalid Python distribution {args.target}")
        print(f"unregistering {args.target}")
        print("continue ? Y/N")
        theAnswer = input()
        if theAnswer == "Y":
            associate.unregister(dist.target, verbose=args.verbose)
            sys.exit()
    elif not args.install and not args.uninstall:
        args.install = True
    if not Path(args.fname).is_file() and args.install:
        if args.fname == "":
            parser.print_help()
            sys.exit()
        else:
            raise FileNotFoundError(f"File not found: {args.fname}")
    if utils.is_python_distribution(args.target):
        dist = Distribution(args.target, verbose=True)
        try:
            if args.uninstall:
                package = dist.find_package(args.fname)
                dist.uninstall(package)
            else:
                package = Package(args.fname)
                if args.install:
                    dist.install(package)
        except NotImplementedError:
            raise RuntimeError("Package is not (yet) supported by WPPM")
    else:
        raise OSError(f"Invalid Python distribution {args.target}")


if __name__ == "__main__":
    main()