#!/usr/bin/python

import os
import re
import sys
import json
import shutil
import os.path
import logging
import argparse
import tempfile
import subprocess
import urllib.request

logging.basicConfig(format="[mpm] %(levelname)s: %(message)s", level=logging.INFO)

try:
  import packaging.version
except ImportError:
  logging.error("failed to import 'packaging' module, trying to install...")

  subprocess.check_call([sys.executable, "-m", "pip", "install", "packaging"])
finally:
  import packaging.version

GLOBAL_INSTALLATION_PATH = os.path.expanduser("~") + "/mei_include"
LOCAL_INSTALLATION_PATH = "./mei_include"

PACKAGE_INDEX_PATH = os.path.expanduser("~") + "/.mpm_index.json"
PACKAGE_INDEX_URL = "https://codeberg.org/txlyre/mpm_index/raw/branch/main/mpm_index.json"

REQUIRED_FILE = "required.txt"

current_installation_path = GLOBAL_INSTALLATION_PATH

auto_confirm = False

def abort(message):
  logging.fatal(message)

  sys.exit(1)

def confirm(message):
  if auto_confirm:
    return True

  answer = input(f"{message} [Y/n]").lower()

  if answer in ('', 'y', "yes")\
 or "yes" in answer:
    return True

  return False

def parse_package_name(package_name):
  match = re.search(r"^([a-zA-Z_0-9]+)/([a-zA-Z_0-9]+)(?:-([0-9\.]+))?$", package_name)

  if not match:
    return None

  repo_owner = match.group(1)
  repo_name = match.group(2)

  version = None

  if match.group(3):
    try:
      version = packaging.version.Version(match.group(3))
    except:
      return None

  return (repo_owner, repo_name, version)

def clone_repo(repo_owner, repo_name, branch):
  temp_dir = tempfile.TemporaryDirectory()
   
  exit_code = subprocess.run(["git", "clone", "--config", "advice.detachedHead=false", "--single-branch", "--branch", branch, f"https://codeberg.org/{repo_owner}/{repo_name}.git", temp_dir.name],
                             stdout=sys.stdout,
                             stderr=sys.stdout)
    
  if exit_code.returncode != 0:
    return None

  return temp_dir

def parse_info(data):
  try:
    info = json.loads(data)
  except:
    return None

  if type(info) is not dict:
    return None

  if "name" not in info\
  or type(info["name"]) != str:
    return None

  if "author" not in info\
  or type(info["author"]) != str:
    return None
    
  if "description" not in info\
  or type(info["description"]) != str:
    return None

  if "version" not in info\
  or type(info["version"]) != str:
    return None

  try:
    info["version"] = packaging.version.Version(info["version"])
  except:
    return None

  if "dependencies" not in info\
  or type(info["dependencies"]) != list\
  or not all(map(lambda s: type(s) == str, info["dependencies"])):
    return None

  dependencies = info["dependencies"]
  info["dependencies"] = []

  for package_name in dependencies:
    parsed = parse_package_name(package_name)

    if not parsed:
      return None

    info["dependencies"].append((package_name, parsed))
  
  if "install_cmd" in info\
 and type(info["install_cmd"]) != str:
    return None

  return info

def read_local_package(package_name):
  parsed = parse_package_name(package_name)

  if not parsed:
    logging.error(f"can't parse package name: `{package_name}'.")

    return None

  repo_owner, repo_name, _ = parsed

  package_info_path = os.path.join(os.path.join(current_installation_path, f"{repo_owner}.{repo_name}"), "mpm.json")

  if not os.path.isfile(package_info_path):
    return None

  try:
    with open(package_info_path, "r") as info_file:
      info_data = info_file.read()
  except IOError as e:
    logging.error(f"failed to read `mpm.json' file from local package `{package_name}`: {e}")

    return None

  info = parse_info(info_data)

  if not info:
    logging.error(f"can't parse `mpm.json' file from local package `{package_name}`.")

  return info

class DummyDir:
  def __init__(self, path):
    self.name = path

  def cleanup(self):
    pass

def install_package(package_name):
  repo_dir = None

  if package_name.startswith("file:"):
    package_dir = package_name.lstrip("file:")
    info_path = os.path.join(package_dir, "mpm.json")

    if not os.path.isfile(info_path):
      abort("provided path doesn't contain a `mpm.json' file.")

    try:
      with open(info_path, "r") as info_file:
        info_data = info_file.read()
    except IOError as e:
      abort(f"failed to read `mpm.json' file: {e}")

    remote_info = parse_info(info_data)
    if not remote_info:
      abort("can't parse `mpm.json' file.")

    repo_owner, repo_name, version = remote_info["author"], remote_info["name"], remote_info["version"]
    package_name = f"{repo_owner}/{repo_name}-{version}"

    repo_dir = DummyDir(package_dir)
  else:
    remote_info = query_package(package_name)

    parsed = parse_package_name(package_name)

    if not parsed:
      abort(f"can't parse package name: `{package_name}'.")

    repo_owner, repo_name, version = parsed
  
  logging.info(f"install `{repo_owner}/{repo_name}' ({version.public if version else 'latest'}).")

  local_package = read_local_package(package_name)

  if local_package:    
    if remote_info["version"] == local_package["version"]:
      logging.info("already installed and up-to-date.")

      return
 
    logging.warning(f"going to install `{repo_owner}/{repo_name}-{remote_info['version']}' over `{repo_owner}/{repo_name}-{local_package['version']}'.")

  if not repo_dir:
    repo_dir = clone_repo(repo_owner, repo_name, version.public if version else "main")

    if not repo_dir:
      abort("error while cloning repository: `git clone' returned non-zero exit status.")

  info_path = os.path.join(repo_dir.name, "mpm.json")

  if not os.path.isfile(info_path):
    repo_dir.cleanup()

    abort("repository doesn't contain a `mpm.json' file.")

  try:
    with open(info_path, "r") as info_file:
      info_data = info_file.read()
  except IOError as e:
    repo_dir.cleanup()

    abort(f"failed to read `mpm.json' file: {e}")

  info = parse_info(info_data)

  if not info:
    repo_dir.cleanup()

    abort("can't parse `mpm.json' file.")

  installation_path = os.path.join(current_installation_path, f"{repo_owner}.{repo_name}")
  
  try:
    if not os.path.isdir(current_installation_path):
      os.mkdir(current_installation_path)

    if os.path.isdir(installation_path):
      shutil.rmtree(installation_path)
    elif os.path.exists(installation_path):
      os.remove(installation_path)

    shutil.copytree(repo_dir.name, installation_path)
  except Exception as e:
    abort(f"error while copying package files: {e}")
  finally:
    repo_dir.cleanup()

  install_cmd = info.get("install_cmd", None)

  if install_cmd:
    logging.warning("this package contains an installation command.")

    if confirm("Are you sure to run the installation command?"):
      rc = os.system(install_cmd.replace("@MEI_PACKAGE_ROOT@", installation_path))

      if rc != 0:
        if os.path.isdir(installation_path):
          shutil.rmtree(installation_path)

        abort(f"installation command returned non-zero exit code: {rc}")

  if info["dependencies"]:
    logging.info("install dependencies.")

    for package_name, _ in info["dependencies"]:
      install_package(package_name)
 
def remove_package(package_name):
  parsed = parse_package_name(package_name)

  if not parsed:
    abort(f"can't parse package name: `{package_name}'.")

  repo_owner, repo_name, version = parsed
  
  logging.info(f"remove `{repo_owner}/{repo_name}' ({version.public if version else 'latest'}).")

  local_package = read_local_package(package_name)
  to_be_removed = []

  if local_package:   
    installation_path = os.path.join(current_installation_path, f"{repo_owner}.{repo_name}")

    try:
      shutil.rmtree(installation_path)
    except Exception as e:
      abort(f"error while removing package files: {e}")
      
      return

    for package in os.listdir(current_installation_path):
      package_info_path = os.path.join(os.path.join(current_installation_path, package), "mpm.json")
      package = package.replace('.', '/')

      if package == package_name:
        continue

      if os.path.isfile(package_info_path):
        info = read_local_package(package)

        if not info:
          continue

        for depending_package_name, _ in info["dependencies"]:
          if depending_package_name == package_name:
            logging.warning(f"there's a depending package `{package}` which is going to be removed.")
          
            to_be_removed.append(package)
  else:
    logging.warning("no such package.")
   
  if local_package and to_be_removed:
    logging.info("remove depending packages.")

    for package in to_be_removed:
      remove_package(package)

def display_info(package_name, info, show_if_installed=False):
  if show_if_installed:
    local_info = read_local_package(package_name)

  head = f"{package_name.strip()} : {info['name']} v{info['version'].public} ({info['author'] if info['author'] else 'n/a'})"
  if show_if_installed and local_info:
    head += f" [installed: {local_info['version']}]"

  print(head)

  if info["dependencies"]:
    print(f"Depends on: {' '.join(map(lambda package: package[0] if type(package) is tuple else package, info['dependencies']))}")

  if info["description"]:
    print(f"  {info['description']}")

def list_packages():
  if os.path.isdir(current_installation_path):
    listing_start = True

    for package in os.listdir(current_installation_path):
      package_info_path = os.path.join(os.path.join(current_installation_path, package), "mpm.json")
      package = package.replace('.', '/')

      if os.path.isfile(package_info_path):
        info = read_local_package(package)

        if not info:
          continue

        if listing_start:
          listing_start = False
        else:
          sys.stdout.write('\n')

        display_info(package, info)        

def query_package(package_name):
  parsed = parse_package_name(package_name)

  if not parsed:
    abort(f"can't parse package name: `{package_name}'.")

  repo_owner, repo_name, version = parsed
  
  logging.info(f"query `{repo_owner}/{repo_name}' ({version.public if version else 'latest'}).")

  if version:
    branch = f'tag/{version.public}'
  else:
    branch = 'branch/main'

  try:
    info_data = urllib.request.urlopen(f"https://codeberg.org/{repo_owner}/{repo_name}/raw/{branch}/mpm.json").read()
    info_data = info_data.decode("UTF-8")
  except Exception as e:
    abort(f"error while receiving `mpm.json` file from the repository: {e}")

  info = parse_info(info_data)

  if not info:
    abort("can't parse `mpm.json' file.")

  display_info(package_name, info, True)

  return info

def update_index():
  logging.info("update package index.")

  try:
    index_data = urllib.request.urlopen(PACKAGE_INDEX_URL).read()
    index_data = index_data.decode("UTF-8")

    index = json.loads(index_data)

    new_index = {}

    for package_name in index:
      parsed = parse_package_name(package_name)

      if not parsed:
        logging.error(f"can't parse package name: `{package_name}'.")

        continue

      repo_owner, repo_name, version = parsed

      if version:
        branch = f'tag/{version.public}'
      else:
        branch = 'branch/main'

      try:
        info_data = urllib.request.urlopen(f"https://codeberg.org/{repo_owner}/{repo_name}/raw/{branch}/mpm.json").read()
        info_data = info_data.decode("UTF-8")
      except Exception as e:
        logging.error(f"error while receiving `mpm.json` file from the repository `{package_name}': {e}")

        continue

      info = parse_info(info_data)

      if not info:
        logging.error(f"can't parse `mpm.json' file from the repository `{package_name}'.")

        continue

      new_index[package_name] = {
        "name": info["name"],
        "author": info["author"],
        "description": info["description"],
        "version": info["version"].public,
        "dependencies": list(map(lambda d: d[0], info["dependencies"]))
      }

    with open(PACKAGE_INDEX_PATH, "w") as index_file:
      json.dump(new_index, index_file)
  except Exception as e:
    abort(f"error while receiving package index: {e}")

  logging.info(f"{len(index)} entr{'ies' if len(index) != 1 else 'y'} retrieved.")

def search_index(query):
  logging.info(f"search package index: `{query}'.")

  if not os.path.isfile(PACKAGE_INDEX_PATH):
    update_index()

  try:
    with open(PACKAGE_INDEX_PATH, "r") as index_file:
      index = json.load(index_file)
  except Exception as e:
    abort(f"error while reading package index: {e}")

  listing_start = True
  matches = 0

  for package_name in index:
    info = index[package_name]

    try:
      info["version"] = packaging.version.Version(info["version"])
    except:
      continue

    if query in package_name\
    or query in info["name"]\
    or query in info["author"]\
    or query in info["description"]:
      if listing_start:
        listing_start = False
      else:
        sys.stdout.write('\n')

      display_info(package_name, info)

      matches += 1
  
  if not matches:
    logging.info("no matches.")
  else:
    logging.info(f"{matches} match{'es' if matches != 1 else ''}.")

def update_all_packages():
  logging.info("update all installed packages.")

  if os.path.isdir(current_installation_path):
    listing_start = True

    for package in os.listdir(current_installation_path):
      package_info_path = os.path.join(os.path.join(current_installation_path, package), "mpm.json")
      package = package.replace('.', '/')

      if os.path.isfile(package_info_path):
        info = read_local_package(package)

        if not info:
          continue

        parsed = parse_package_name(package)

        if not parsed:
          logging.error(f"can't parse package name: `{package}'.")

          continue

        repo_owner, repo_name, _ = parsed

        try:
          info_data = urllib.request.urlopen(f"https://codeberg.org/{repo_owner}/{repo_name}/raw/branch/main/mpm.json").read()
          info_data = info_data.decode("UTF-8")
        except Exception as e:
          logging.error(f"error while receiving `mpm.json` file from the repository `{package}': {e}")

          continue

        new_info = parse_info(info_data)

        if not new_info:
          logging.error(f"can't parse `mpm.json' file from the repository `{package}'.")

          continue

        if new_info["version"] > info["version"]:
          install_package(f"{repo_owner}/{repo_name}")

def synchronize_from_sources(path="."):
  logging.info(f"scan source files in `{path}' directory for dependencies.")
  
  dependencies = set()

  files = os.listdir(path)
  for file in files:
    if os.path.isdir(file) and file != "mei_include":
      dependencies.union(synchronize_from_sources(os.path.join(path, file)))

      continue
      
    if not file.endswith(".mei"):
      continue
    
    try:
      with open(file, "r") as source_file:
        source_lines = source_file.readlines()
    except IOError as e:
      logging.error(f"error while reading `{file}` file: {e}")
      
      continue
      
    for source_line in source_lines:
      match = re.match(r"^import \"([a-zA-Z_0-9]+)\.([a-zA-Z_0-9]+)/[a-zA-Z_0-9]+\"$", source_line.strip())
      if not match:
        match = re.match(r"^import \"([a-zA-Z_0-9]+)/([a-zA-Z_0-9]+)\"$", source_line.strip())

      if match:
        package_name = f"{match.group(1)}/{match.group(2)}"
        
        dependencies.add(package_name)

  return dependencies

def synchronize():
  logging.info(f"install dependencies from the `{REQUIRED_FILE}' file.")

  if not os.path.isfile(REQUIRED_FILE):
    logging.error(f"no `{REQUIRED_FILE}' file found in the working directory.")

    required_data = synchronize_from_sources()
    
    if len(required_data) == 0:
      abort("no Mei source files found in the working directory.")
  else:
    try:
      with open(REQUIRED_FILE, "r") as required_file:
        required_data = required_file.readlines()
    except IOError as e:
      abort(f"error while reading `{REQUIRED_FILE}` file: {e}")
    
  for package_name in required_data:
    install_package(package_name)

def get_mei_version():
  try:
    proc = subprocess.Popen(["mei", "-i"], stdin=subprocess.PIPE, stdout=subprocess.PIPE)
    output, _ = proc.communicate(b"main = put $ __VERSION ++ \" \" ++ __BUILD")

    if proc.returncode != 0:
      return "unknown"
  except:
    return "unknown"

  return output.decode('ASCII')

if __name__ == "__main__":
  parser = argparse.ArgumentParser(
    prog="mpm",
    description="Mei Package Manager v.6: A package installer for Mei programming language | by @txlyre, www: txlyre.website",
    epilog=f"Mei version: {get_mei_version()}; Global installation path: {GLOBAL_INSTALLATION_PATH}")

  parser.add_argument("-i", "--install", 
                      type=str,
                      nargs='+',
                      metavar="PACKAGE",
                      help="install/update packages.")  

  parser.add_argument("-r", "--remove", 
                      type=str,
                      nargs='+',
                      metavar="PACKAGE",
                      help="remove packages.")

  parser.add_argument("-q", "--query", 
                      type=str,
                      metavar="PACKAGE",
                      help="query information on a package.")

  parser.add_argument("-s", "--search", 
                      type=str,
                      metavar="QUERY",
                      help="search through local package index.")

  parser.add_argument("-S", "--synchronize", 
                      action="store_true",
                      help=f"install dependencies from `{REQUIRED_FILE}' file or from source files directly.")

  parser.add_argument("-U", "--update-all", 
                      action="store_true",
                      help="update all installed packages.")

  parser.add_argument("-u", "--update", 
                      action="store_true",
                      help="update local package index.")

  parser.add_argument("-l", "--list", 
                      action="store_true",
                      help="list installed packages.")

  parser.add_argument("-y", "--yes", 
                      action="store_true",
                      help="automatically confirm any prompts (UNSAFE!!!).")

  parser.add_argument("-H", "--here", 
                      action="store_true",
                      help="use the working directory as installation root.")

  args = parser.parse_args()

  if not args.install\
 and not args.remove\
 and not args.query\
 and not args.search\
 and not args.update_all\
 and not args.update\
 and not args.synchronize\
 and not args.list:
    logging.warning("nothing to do.")

    sys.exit(0)
 
  if args.yes:
    auto_confirm = True

  if args.here:
    current_installation_path = LOCAL_INSTALLATION_PATH

  if args.update:
    update_index()

  if args.update_all:
    update_all_packages()

  if args.synchronize:
    synchronize()

  if args.search:
    search_index(args.search)

  if args.install:
    for package_name in args.install:
      install_package(package_name)
  
  if args.remove:
    logging.warning(f"the following packages and the packages depending on these will be removed: {' '.join(args.remove)}")
   
    if confirm("Are you sure to proceed?"):
      for package_name in args.remove:
        remove_package(package_name)
   
  if args.query:
    query_package(args.query)

  if args.list:
    list_packages()
