# GARstow package operations
# Copyright 2003, 2004, 2005, 2006, 2007, 2009, 2010, 2011 Adam Sampson <ats@offog.org>

import os, sys, stat, re, subprocess, fnmatch, hashlib, shutil

from config import *
from parseport import *
from utils import *

@memoised
def get_package_names():
	packages = {}
	categories = list_dirs_in(gar_dir)
	for cat in categories:
		if cat.startswith("gar.") or cat == "CVS": continue
		catdir = gar_dir + "/" + cat
		ps = list_dirs_in(catdir)
		for pkg in ps:
			if pkg == "CVS": continue
			pkgdir = catdir + "/" + pkg
			try:
				os.stat(pkgdir + "/Makefile")
			except OSError:
				continue
			if pkg in packages:
				die("Duplicate package name: ", packages[pkg], ", ", cat + "/" + pkg)
			packages[pkg] = cat + "/" + pkg
	return packages

@memoised
def get_portfile(package):
	return load_portfile(get_source_dir(package) + "/Makefile")

@memoised
def get_config_portfile():
	"""Return an arbitrary portfile that can be used to look up
	configuration settings that don't change between packages."""
	return get_portfile("compatlibs")

def get_stow_dir():
	return get_config_portfile().variable("prefix")
def get_packages_dir():
	return get_config_portfile().variable("packagesdir")
def get_scripts_dir():
	return get_config_portfile().variable("SCRIPTSDIR")
def get_temp_dir():
	return get_config_portfile().variable("TEMPDIR")
def get_garball_dir():
	return get_config_portfile().variable("GARBALLDIR")

def get_source_dir(package):
	return gar_dir + "/" + get_package_names()[package]
def get_dotgar_package(package):
	return get_stow_dir() + "/.gar/" + package
def get_dotgar_versioned(versioned):
	package = versioned_to_package(versioned)
	return get_packages_dir() + "/" + versioned + "/.gar/" + package

def get_description(package):
	return get_portfile(package).variable("DESCRIPTION")
def get_version(package):
	return get_portfile(package).variable("GARVERSION")
def get_full_package_name(package):
	return get_portfile(package).variable("CATEGORIES").split()[0] + "/" + package
def get_package_arch(package):
	return get_portfile(package).variable("GARARCH")

@memoised
def get_package_hash(package):
	"""Return a string that uniquely identifies the source and
	configuration that would be used to build a package."""

	m = hashlib.sha1()

	port = get_portfile(package)
	m.update(encode_package_vars(package, "PACKAGE_VARS"))
	m.update(encode_package_vars(package, "PACKAGE_IDENT_VARS"))
	for fn in port.variable("PACKAGE_IDENT_FILES").split():
		m.update(fn + ":\n")
		f = open(get_source_dir(package) + "/" + fn, "rb")
		m.update(f.read())
		f.close()

	return m.hexdigest()

def parse_dep_list(list):
	list = list.strip()
	if list == "":
		return []
	else:
		return [s.split("/")[1] for s in split_whitespace(list)]

@memoised
def get_ignore_list():
	ignore = {}
	for p in parse_dep_list(get_config_portfile().variable("IGNORE_DEPS")):
		ignore[p] = True
	return ignore

@memoised
def get_dependencies(package, build = True):
	allnames = get_package_names()
	ignore = get_ignore_list()

	port = get_portfile(package)
	possible_deps = parse_dep_list(port.variable("LIBDEPS"))
	if build:
		possible_deps += parse_dep_list(port.variable("BUILDDEPS"))

	deps = []
	for p in possible_deps:
		if not p in allnames:
			warn("Warning: ", package, " has dependency on non-existant package ", p, "; ignoring")
			continue
		if ignore.has_key(p):
			continue
		deps.append(p)

	return deps

@memoised
def get_categories(package):
	return split_whitespace(get_portfile(package).variable("CATEGORIES"))

@memoised
def get_dep_tree(build = True):
	ps = get_package_names()
	tree = {}
	for pkg in ps:
		tree[pkg] = get_dependencies(pkg, build=build)
	return tree

@memoised
def get_req_tree(build = True):
	dt = get_dep_tree(build=build)
	tree = {}
	for pkg in dt.keys():
		tree[pkg] = []
	for pkg in dt.keys():
		for dep_pkg in dt[pkg]:
			tree[dep_pkg].append(pkg)
	return tree

def get_requirements(package, build = True):
	return get_req_tree(build=build)[package]

@memoised
def get_deps_rec(package, is_deps = True, build = True):
	if is_deps:
		tree = get_dep_tree(build=build)
	else:
		tree = get_req_tree(build=build)
	deps = {}
	def rec(deps, tree, item, stack):
		if item in stack:
			die("Recursive dependency detected: " + " -> ".join(stack + [item]))
		elif item in deps:
			return
		deps[item] = True
		for dep in tree[item]:
			rec(deps, tree, dep, stack + [item])
	rec(deps, tree, package, [])
	del deps[package]
	return deps.keys()

def get_reqs_rec(package, build = True):
	return get_deps_rec(package, is_deps=False, build=build)

@memoised
def get_depths():
	tree = get_dep_tree()
	depths = {}
	def rec(depths, tree, item, stack):
		if item in stack:
			die("Recursive dependency detected: " + " -> ".join(stack + [item]))
		elif depths.has_key(item):
			return depths[item]
		d = 0
		for i in tree[item]:
			sd = rec(depths, tree, i, stack + [item])
			if sd > d: d = sd
		d += 1
		depths[item] = d
		return d
	for i in tree.keys(): rec(depths, tree, i, [])
	return depths

@memoised
def cmp_install(a, b):
	depths = get_depths()
	return cmp(depths.get(a), depths.get(b))

def cmp_remove(a, b): return -cmp_install(a, b)
	
def list_dirs_in(dir):
	ds = []
	for name in os.listdir(dir):
		mode = os.lstat(dir + "/" + name).st_mode	
		if stat.S_ISDIR(mode):
			ds.append(name)
	return ds

def list_packagesdir():
	contents = {}
	for x in os.listdir(in_root(get_packages_dir())):
		name = get_packages_dir() + "/" + x
		contents[x] = os.lstat(in_root(name))
	return contents

def get_installed():
	"""Get all installed packages, returning a dict mapping package
	names to versioned names, e.g. {"hello": "hello-1.4" ...}."""
	installed = {}

	# Every package provides a PREFIX/.gar/GARNAME directory.
	for name in os.listdir(in_root(get_stow_dir() + "/.gar")):
		if name.startswith("gar."):
			continue

		installed[name] = package_to_versioned(name)

	return installed

def package_to_versioned(package):
	"""Return the versioned name of an installed package (e.g. "hello" ->
	"hello-1.4"), or None if the package is not installed."""

	# Every package provides a PREFIX/.gar/GARNAME directory,
	# so we can just look for the owner of that.
	dotgar_dir = get_dotgar_package(package)

	if os.access(in_root(dotgar_dir), os.F_OK):
		return get_owning_versioned(dotgar_dir)
	else:
		return None

def versioned_to_package(versioned):
	"""Return the package name from a versioned name."""

	# You might think that this could just do a regexp match,
	# but the names aren't actually parseable (for example,
	# "hyphenated-tool-1.4-rc2"). So, instead...

	# Scan for the PREFIX/.gar/GARNAME directory in the package.
	dotgar_dir = get_packages_dir() + "/" + versioned + "/.gar"
	names = [name for name in os.listdir(in_root(dotgar_dir))
	              if not name.startswith("gar.")]

	if len(names) != 1:
		die("Can't find package name: " + versioned)

	return names[0]

def get_installed_version(package):
	"""Return the version number of an installed package,
	or None if the package is not installed."""
	versioned = package_to_versioned(package)
	if versioned is None:
		return None

	return versioned[len(package) + 1:]

def split_packagedir_path(path):
	"""Given a path inside packagedir, return the versioned name and path
	within the package.
	e.g. PACKAGEDIR/hello-1.4/bin/hello -> ("hello-1.4", "bin/hello")"""

	packagedir_re = re.compile(r'^' + get_packages_dir() + r'/([^/]+)/(.*)$')
	m = packagedir_re.match(path)
	if m is None:
		die("Cannot match packagedir path: " + path)

	return m.group(1, 2)

def versioned_part_of(path):
	"""Given a path inside packagesdir (which must exist), return the
	versioned name."""
	versioned = split_packagedir_path(path)[0]

	versioned_path = get_packages_dir() + "/" + versioned
	if stat.S_ISLNK(os.lstat(in_root(versioned_path)).st_mode):
		# Two-link compatibility: what we've found is a symlink, so we
		# need to read it to get the actual version.
		versioned = os.readlink(in_root(versioned_path))

	return versioned

def get_owning_versioned(filename):
	"""Given a path inside stowdir, return the versioned name that it
	belongs to, or None if it doesn't belong to any package."""

	pd = get_packages_dir()
	sd = get_stow_dir()
	if not filename.startswith(sd):
		die("Not in a package: " + filename)

	orig_filename = filename
	while True:
		if filename.startswith(pd):
			# We're in a package -- success!
			return versioned_part_of(filename)
		elif filename == sd or filename == "/":
			# We've got to the bottom without finding a symlink, so
			# it can't be part of a package.
			return None

		if stat.S_ISLNK(os.lstat(in_root(filename)).st_mode):
			# This is a symlink: follow it.
			filename = out_root(norm_readlink(in_root(filename)))
		else:
			# This isn't a symlink -- so it must be in a directory
			# that's part of a package. Try going up.
			filename = os.path.dirname(filename)

def get_owning_package(filename):
	"""Given a path inside stowdir, return the package that it belongs
	to, or None if it doesn't belong to any package."""

	versioned = get_owning_versioned(filename)
	if versioned is None:
		return None
	else:
		return versioned_to_package(versioned)

def make_operation(package, operation):
	status("In package ", package, ": make ", operation)
	if in_root("/") != "/":
		die("Cannot do make operations when root directory is set")
	pns = get_package_names()
	command(["make", "-C", get_source_dir(package), operation])

def remove_versioned_package(versioned):
	"""Delete the directory corresponding to an installed package."""
	command(["rm", "-rf", in_root(get_packages_dir() + "/" + versioned)])

class CommandException(Exception):
	"""Exception thrown when a command fails.
	cmd is the command array; rc is the exit code."""
	def __init__(self, cmd, rc):
		self.cmd = cmd
		self.rc = rc
		super(CommandException, self).__init__()

def command(cmd, nonfatal = False):
	"""Run a command that will exit 0 on success.
	if it fails, raise CommandException."""
	rc = subprocess.call(cmd)
	if rc != 0:
		raise CommandException(cmd, rc)

def locked_command(name, cmd):
	"""Run a command using with-lock."""
	mkdir_p(get_temp_dir())
	command([get_scripts_dir() + "/with-lock", get_temp_dir() + "/lock." + name] + cmd)

def stow_command(args):
	"""Run a stow command."""
	locked_command("stow", ["stow", "-d", in_root(get_packages_dir()), "-t", in_root(get_stow_dir())] + args)

def encode_package_vars(package, list_name):
	"""Return the string encoding of a list of package variables."""

	port = get_portfile(package)
	config = get_config_portfile()
	vars = []

	for var in config.variable(list_name).split():
		vars.append("%s=%s" % (var, port.variable(var)))

	for var in config.variable("OPTIONAL_" + list_name).split():
		value = port.variable(var)
		if value != config.variable(var):
			vars.append("%s=%s" % (var, value))

	return "; ".join(vars)

# XXX: This API is wrong -- it should take a versioned.
def write_package_vars(package):
	"""Store variables for a package."""

	vars = encode_package_vars(package, "PACKAGE_VARS")

	packagedir = get_portfile(package).variable("packagedir")
	fn = "%s/.gar/%s/package_vars" % (packagedir, package)
	f = open(in_root(fn), "wb")
	content = "%s; GARHASH=%s\n" % (vars, get_package_hash(package))
	f.write(content.encode("UTF-8"))
	f.close()

def get_versioned_vars(versioned):
	"""Return a dict containing the stored variables for a versioned
	package."""

	dotgardir = get_dotgar_versioned(versioned)
	try:
		f = open(in_root(dotgardir + "/package_vars"), "rb")
		s = f.read().decode("UTF-8").strip()
		f.close()
	except IOError:
		return None

	vars = {}
	for item in s.split(";"):
		item = item.strip()
		i = item.find("=")
		vars[item[:i]] = item[i + 1:]

	config = get_config_portfile()
	for var in config.variable("OPTIONAL_PACKAGE_VARS").split():
		if var not in vars:
			vars[var] = config.variable(var)

	return vars

def get_package_vars(package):
	"""Return a dict containing the stored variables for a package,
	or None if the package is not installed."""

	versioned = package_to_versioned(package)
	if versioned is None:
		return None

	return get_versioned_vars(versioned)

def matches_default_collision(path_within):
	"""Return whether a path within a package matches one of the patterns
	in the default value of COLLISIONS (which means that it's a file that's
	expected to be created by something other than a package in the prefix
	-- e.g.  a cache file)."""
	for pattern in get_config_portfile().variable("COLLISIONS").split():
		if fnmatch.filter([path_within], pattern) != []:
			return True
	return False

def remove_stow_conflicts(packagedir, package, allow):
	"""For each given packageprefix, remove the files in the tree that it
	would collide with. This is useful if you're replacing a large port
	incrementally with smaller ones, or if you need to remove a port that's
	accidentally installed straight into the tree rather than the
	packageprefix."""

	fail = False
	prefix = get_stow_dir()
	packagesdir = get_packages_dir()

	# Find every non-directory the new package will install.
	f = os.popen("find " + in_root(packagedir) + " -not -type d", "r")
	for new_path in f.readlines():
		new_path = new_path[:-1]
		new_path = out_root(new_path)
		if not new_path.startswith(packagedir):
			die("Bad line in find output: " + new_path)

		# Work out the filename it would install to.
		path_within = new_path[len(packagedir) + 1:]
		dest = get_stow_dir() + "/" + path_within

		if not os.access(in_root(dest), os.F_OK):
			# Nothing there already -- so it can't be a conflict.
			continue

		conf_package = get_owning_package(dest)
		owner = ""
		if conf_package == package:
			# Currently owned by the package we're trying to
			# install -- so it's not a conflict.
			continue
		elif conf_package is None:
			# Not owned by a package.
			owner = "not stowed"
			pass
		elif conf_package in allow:
			# It's in a package in DECONFLICT.
			owner = "owned by package " + conf_package
			pass
		elif matches_default_collision(path_within):
			# It's in a package, but also matched by (the default
			# value of) COLLISIONS.
			owner = "COLLISIONS from package " + conf_package
			pass
		else:
			status("Trying to remove " + path_within + " from non-allowed package " + conf_package)
			fail = True
			continue

		if stat.S_ISLNK(os.lstat(in_root(dest)).st_mode):
			# dest is a symlink. We need to read it, remove the
			# link, and then remove whatever it points at.
			target = out_root(norm_readlink(in_root(dest)))

			status("Removing " + dest + " (stowed symlink)")
			os.unlink(in_root(dest))

			dest = target

		status("Removing " + dest + " (" + owner + ")")
		assert len(dest) > len(prefix)

		# Even though we're trying to install a file, it might have
		# been a directory originally. (And rmtree expects its argument
		# to be a directory!)
		if stat.S_ISDIR(os.lstat(in_root(dest)).st_mode):
			shutil.rmtree(in_root(dest))
		else:
			os.unlink(in_root(dest))

	f.close()
	if fail:
		die("Unresolved stow conflicts detected")

def copy_compatlibs(versioned, newpackagedir = None):
	"""Scan a package for any libraries that need copying to the compatlibs
	directory. Return whether stow_compatlibs needs to be called."""
	need_stow = False

	# If there isn't a version installed already, or it doesn't have a lib
	# directory, we have nothing to do.
	if versioned is None:
		return False
	oldlibdir = get_packages_dir() + "/" + versioned + "/lib"
	if not os.access(in_root(oldlibdir), os.F_OK):
		return False

	compatlibsdir = get_packages_dir() + "/compatlibs-0/lib"

	patterns = get_versioned_vars(versioned)["COMPATLIBS"].split()

	for lib in os.listdir(in_root(oldlibdir)):
		found = False
		for pattern in patterns:
			if re.match(pattern, lib) is not None:
				found = True
		if not found:
			# This isn't a library.
			continue

		if newpackagedir is not None:
			if os.access(in_root(newpackagedir + "/lib/" + lib), os.F_OK):
				# This exists in the new package.
				continue

		if not os.access(oldlibdir + "/" + lib, os.F_OK):
			# The old library wasn't readable anyway (probably a
			# broken symlink).
			continue

		status("Copying old shared library ", lib)
		mkdir_p(in_root(compatlibsdir))
		command(["cp", "-L",
		         in_root(oldlibdir + "/" + lib),
		         in_root(compatlibsdir + "/" + lib)])
		need_stow = True

	return need_stow

def stow_compatlibs():
	"""Stow the compatlibs directory."""
	mkdir_p(in_root(get_packages_dir() + "/compatlibs-0"))
	command(["rm", "-f", in_root(get_packages_dir() + "/compatlibs")])
	command(["ln", "-sf", "compatlibs-0", in_root(get_packages_dir() + "/compatlibs")])
	stow_command(["compatlibs"])

def merge_directories(package):
	"""Merge out-of-prefix directories for a package."""

	merge_dirs = {}
	merge_dirs_raw = get_config_portfile().variable("MERGE_DIRS").split()
	for i in range(0, len(merge_dirs_raw), 2):
		(name, target) = (merge_dirs_raw[i], merge_dirs_raw[i + 1])
		source = get_dotgar_package(package) + "/" + name

		if not os.access(in_root(source), os.F_OK):
			continue

		status("Merging ", name, " into ", target)
		merge_cmd = [
			get_scripts_dir() + "/merge-dir",
			in_root(source),
			in_root(target),
			]
		locked_command("merge", merge_cmd)

# Variables to export from the Makefiles into the hook environment.
export_variables = [
	"LANG",
	"LC_ALL",
	"PATH",
	"LD_LIBRARY_PATH",
	"XDG_DATA_DIRS",
	]

def run_hooks(package, hook):
	"""Run a hook for a package."""

	hooksdir = get_stow_dir() + "/.gar/gar." + hook

	try:
		entries = os.listdir(in_root(hooksdir))
	except OSError:
		entries = []
	hooks = [hooksdir + "/" + entry for entry in sorted(entries)]

	packagehook = get_dotgar_package(package) + "/" + hook
	if os.access(in_root(packagehook), os.F_OK):
		hooks.append(packagehook)

	if in_root("/") == "/":
		cmd = []
	else:
		cmd = ["chroot", in_root("/")]

	cmd += ["env"]
	cmd += ["%s=%s" % (var, value)
	        for var, value in get_package_vars(package).items()]
	cmd += ["%s=%s" % (var, get_config_portfile().variable(var))
	        for var in export_variables]

	for hook in hooks:
		locked_command("hooks", cmd + [hook])

def install_version(package, version):
	"""Install a given version of a package.
	The packagedir for that version of the package must have already been
	created."""

	versioned = package + "-" + version
	old_versioned = package_to_versioned(package)

	if old_versioned is not None:
		replacing = " (replacing " + old_versioned + ")"
	else:
		replacing = ""
	status("Installing package ", versioned, replacing)

	packagedir = get_packages_dir() + "/" + versioned
	packagelink = get_packages_dir() + "/" + package

	if not os.access(in_root(packagedir), os.F_OK):
		die("Package directory does not exist: ", versioned)

	need_stow = copy_compatlibs(old_versioned, packagedir)

	if os.access(in_root(packagelink), os.F_OK):
		os.unlink(in_root(packagelink))
	os.symlink(versioned, in_root(packagelink))

	vars = get_versioned_vars(versioned)

	deconflict = vars["DECONFLICT"].split()
	remove_stow_conflicts(packagedir, package, deconflict)

	stow_command(["-R", package])

	if need_stow:
		stow_compatlibs()

	merge_directories(package)

	run_hooks(package, "post-install")

def remove_package(package):
	"""Remove an installed package (even if there's no port for it any
	more)."""

	versioned = package_to_versioned(package)
	if versioned is None:
		status(package, " is not installed")
		return

	run_hooks(package, "pre-remove")

	need_stow = copy_compatlibs(versioned)
	stow_command(["-D", package])
	packagelink = get_packages_dir() + "/" + package
	os.unlink(in_root(packagelink))
	if need_stow:
		stow_compatlibs()

