#!/usr/bin/env python3
"""
A Python 3.11 standard library only utility to help install an
environment for `trimesh` in a Debian Docker image.

It probably isn't useful for most people unless you are running
this exact configuration.
"""

import argparse
import json
import logging
import os
import subprocess
import sys
import tarfile
from fnmatch import fnmatch
from io import BytesIO

# define system packages for our debian docker image
# someday possibly add this to the `pyproject.toml` config
# but for now store them locally in the installer script
config_json = """
{
  "apt": {
    "build": [
      "build-essential",
      "g++",
      "make",
      "git"
    ],
    "docs": [
      "make",
      "pandoc"
    ],
    "llvmpipe": [
      "libgl1-mesa-dri",
      "xvfb",
      "xauth",
      "ca-certificates",
      "freeglut3-dev"
    ],
    "test": [
      "curl",
      "git",
      "libxkbcommon0",
      "libtbb-dev",
      "blender"
]
  },
  "fetch": {
    "gltf_validator": {
      "url": "https://github.com/KhronosGroup/glTF-Validator/releases/download/2.0.0-dev.3.8/gltf_validator-2.0.0-dev.3.8-linux64.tar.xz",
      "sha256": "374c7807e28fe481b5075f3bb271f580ddfc0af3e930a0449be94ec2c1f6f49a",
      "target": "$PATH",
      "chmod": {"gltf_validator": 755},
      "extract_only": "gltf_validator"
    },
    "pandoc": {
      "url": "https://github.com/jgm/pandoc/releases/download/3.1.1/pandoc-3.1.1-linux-amd64.tar.gz",
      "sha256": "52b25f0115517e32047a06d821e63729108027bd06d9605fe8eac0fa83e0bf81",
      "target": "$PATH",
      "chmod": {"pandoc": 755},
      "extract_only": "pandoc"
    },

    "binvox": {
      "url": "https://trimesh.s3-us-west-1.amazonaws.com/binvox",
      "sha256": "82ee314a75986f67f1d2b5b3ccdfb3661fe57a6b428aa0e0f798fdb3e1734fe0",
      "target": "$PATH",
      "chmod": {"binvox": 755}
    }
  }
}
"""


log = logging.getLogger("trimesh")
log.setLevel(logging.DEBUG)
log.addHandler(logging.StreamHandler(sys.stdout))

_cwd = os.path.abspath(os.path.expanduser(os.path.dirname(__file__)))


def apt(packages):
    """
    Install a list of debian packages using suprocess to call apt-get.

    Parameters
    ------------
    packages : iterable
      List, set, or other with package names.
    """
    if len(packages) == 0:
        return

    # start with updating the sources
    log.debug(subprocess.check_output("apt-get update -qq".split()).decode("utf-8"))

    # the install command
    install = "apt-get install -qq --no-install-recommends".split()
    # de-duplicate package list
    install.extend(set(packages))

    # call the install command
    log.debug(subprocess.check_output(install).decode("utf-8"))

    # delete any temporary files
    subprocess.check_output("rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*".split())


def argsort(items):
    """
    A standard-library implementation of `numpy.argsort`, a way
    to get a list sorted by index instead of by the sorted values.

    Parameters
    --------------
    item : (n,) any
      Items that are sortable.

    Returns
    --------------
    index : int
      Index such `items[index] == min(items)`
    """
    return [i for (v, i) in sorted((v, i) for (i, v) in enumerate(items))]


def fetch(url, sha256):
    """
    A simple standard-library only "fetch remote URL" function.

    Parameters
    ------------
    url : str
      Location of remote resource.
    sha256: str
      The SHA256 hash of the resource once retrieved,
      will raise a `ValueError` if the hash doesn't match.

    Returns
    -------------
    data : bytes
      Retrieved data in memory with correct hash.
    """
    import hashlib
    from urllib.request import urlopen

    data = urlopen(url).read()
    hashed = hashlib.sha256(data).hexdigest()
    if hashed != sha256:
        log.error(f"`{hashed}` != `{sha256}`")
        raise ValueError("sha256 hash does not match!")

    return data


def is_writable(path: str) -> bool:
    if not os.path.isdir(path):
        return False

    test_fn = os.path.join(path, ".test_writeable_file")
    try:
        with open(test_fn, "w") as f:
            f.write("can we write here?")
        os.remove(test_fn)
        return True
    except BaseException as E:
        print(path, E)
        return False


def choose_in_path(prefix="~") -> str:
    """
    Copy an executable file onto `PATH`, typically one of
    the options in the current user's home directory.

    Parameters
    --------------
    file_path : str
      Location of of file to copy into PATH.
    prefix : str
      The path prefix it is acceptable to copy into,
      typically `~` for `/home/{current_user}`.
    """

    # get all locations in PATH
    candidates = [
        os.path.abspath(os.path.expanduser(i)) for i in os.environ["PATH"].split(":")
    ]

    # cull candidates that don't start with our prefix
    if prefix is not None:
        # expand shortcut for user's home directory
        prefix = os.path.abspath(os.path.expanduser(prefix))
        # if we are the root user don't cull the available copy locations
        if not prefix.endswith("root"):
            # cull non-prefixed path entries
            candidates = [c for c in candidates if c.startswith(prefix)]

    # we want to encourage it to put stuff in the home directory
    encourage = [os.path.expanduser("~"), ".local"]

    # rank the candidate paths
    scores = [len(c) - sum(len(e) for e in encourage if e in c) for c in candidates]

    # try writing to the shortest paths first
    for index in argsort(scores):
        path = candidates[index]
        if is_writable(path):
            return path

    # none of our candidates worked
    raise ValueError("unable to write to file")


def extract(tar, member, path, chmod):
    """
    Extract a single member from a tarfile to a path.
    """
    if os.path.isdir(path):
        return
    data = tar.extractfile(member=member)
    if not hasattr(data, "read"):
        return
    data = data.read()
    if len(data) == 0:
        return

    # make sure root path exists
    os.makedirs(os.path.dirname(path), exist_ok=True)

    with open(path, "wb") as f:
        f.write(data)

    if chmod is not None:
        # python os.chmod takes an octal value
        os.chmod(path, int(str(chmod), base=8))


def handle_fetch(
    url,
    sha256,
    target,
    chmod=None,
    extract_skip=None,
    extract_only=None,
    strip_components=0,
):
    """
    A macro to fetch a remote resource (usually an executable) and
    move it somewhere on the file system.

    Parameters
    ------------
    url : str
      A string with a remote resource.
    sha256 : str
      A hex string for the hash of the remote resource.
    target : str
      Target location on the local file system.
    chmod : None or dict
      Change permissions for extracted files.
    extract_skip : None or iterable
      Skip a certain member of the archive using
      an `fnmatch` pattern, i.e. "lib/*"
    extract_only : None or iterable
      Extract only whitelisted files from the archive
      using an `fnmatch` pattern, i.e. "lib/*"
    strip_components : int
      Strip off this many components from the file path
      in the archive, i.e. at `1`, `a/b/c` is extracted to `target/b/c`
    """
    if target.lower().strip() == "$path":
        target = choose_in_path()
        log.debug(f"identified destination as `{target}`")

    if chmod is None:
        chmod = {}
        
    if extract_skip is None:
        extract_skip = []
    # if passed a single string
    if isinstance(extract_only, str):
        extract_only = [extract_only]

    # get the raw bytes
    log.debug(f"fetching: `{url}`")
    raw = fetch(url=url, sha256=sha256)

    if len(raw) == 0:
        raise ValueError(f"{url} is empty!")

    # if we have an archive that tar supports
    if url.endswith((".tar.gz", ".tar.xz", "tar.bz2")):
        # mode needs to know what type of compression
        mode = f'r:{url.split(".")[-1]}'
        # get the archive
        tar = tarfile.open(fileobj=BytesIO(raw), mode=mode)

        for member in tar.getmembers():
            if member.isdir():
                continue

            # final name after stripping components
            name = "/".join(member.name.split("/")[strip_components:])

            # if any of the skip patterns match continue
            if any(fnmatch(name, p) for p in extract_skip):
                log.debug(f"skipping: `{name}`")
                continue

            if extract_only is not None and not any(
                fnmatch(name, p) for p in extract_only
            ):
                log.debug(f"skipping: `{name}`")
                continue

            path = os.path.join(target, name)
            log.debug(f"extracting: `{path}`")
            extract(tar=tar, member=member, path=path, chmod=chmod.get(name, None))

    else:
        # a single file
        name = url.split("/")[-1].strip()
        path = os.path.join(target, name)
        with open(path, "wb") as f:
            f.write(raw)

        current = chmod.get(name, None)
        # apply chmod if requested
        if current is not None:
            # python os.chmod takes an octal value
            os.chmod(path, int(str(current), base=8))


def load_config():
    """ """
    return json.loads(config_json)


if __name__ == "__main__":
    config = load_config()

    options = set()
    for v in config.values():
        options.update(v.keys())

    parser = argparse.ArgumentParser(description="Install system packages for trimesh.")
    parser.add_argument(
        "--install", type=str, action="append", help=f"Install metapackages: {options}"
    )
    args = parser.parse_args()

    # collect `apt-get install`-able package
    apt_select = []
    handlers = {
        "apt": lambda x: apt_select.extend(x),
        "fetch": lambda x: handle_fetch(**x),
    }

    # allow comma delimiters and de-duplicate
    if args.install is None:
        parser.print_help()
        exit()
    else:
        select = " ".join(args.install).replace(",", " ").split()

    log.debug(f'installing metapackages: `{", ".join(select)}`')

    for key in select:
        for handle_name, handler in handlers.items():
            if key in config[handle_name]:
                handler(config[handle_name][key])

    # run the apt-get install
    apt(apt_select)
