#! /usr/bin/env python
# -*- coding: utf-8 -*-

# opsi-makeproductfile is part of the desktop management solution opsi
# (open pc server integration) http://www.opsi.org
# Copyright (C) 2010-2016 uib GmbH <info@uib.de>

# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.

# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.

# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
"""
opsi-makeproductfile - create opsi-packages for deployment.

:copyright:	uib GmbH <info@uib.de>
:author: Niko Wenselowski <n.wenselowski@uib.de>
:author: Jan Schneider <j.schneider@uib.de>
:license: GNU Affero General Public License version 3
"""

import fcntl
import gettext
import os
import re
import struct
import sys
import termios
import tty

import OPSI.Util.File.Archive
from OPSI.Logger import LOG_DEBUG, LOG_ERROR, LOG_NONE, LOG_WARNING, Logger
from OPSI.System import execute
from OPSI.Types import forceFilename, forceUnicode
from OPSI.Util.Message import ProgressObserver, ProgressSubject
from OPSI.Util.Product import ProductPackageSource
from OPSI.Util.File.Opsi import PackageControlFile
from OPSI.Util.File import ZsyncFile
from OPSI.Util.Task.Rights import setRights
from OPSI.Util import md5sum

try:
	import argparse
except ImportError:
	from OPSI.Util import argparse

__version__ = '4.0.7.7'

logger = Logger()

try:
	t = gettext.translation('opsi-utils', '/usr/share/locale')
	_ = t.ugettext
except Exception as e:
	logger.error(u"Locale not found: %s" % e)

	def _(string):
		return string


class CancelledByUserError(Exception):
	pass


class ProgressNotifier(ProgressObserver):
	def __init__(self):
		self.usedWidth = 60
		try:
			tty = os.popen('tty').readline().strip()
			fd = open(tty)
			terminalWidth = struct.unpack('hh', fcntl.ioctl(fd, termios.TIOCGWINSZ, '1234'))[1]
			if (self.usedWidth > terminalWidth):
				self.usedWidth = terminalWidth
			fd.close()
		except Exception:
			pass

	def progressChanged(self, subject, state, percent, timeSpend, timeLeft, speed):
		if (subject.getEnd() <= 0):
			return
		barlen = self.usedWidth - 10
		filledlen = int("%0.0f" % (barlen * percent / 100))
		bar = u'='*filledlen + u' ' * (barlen - filledlen)
		percent = '%0.2f%%' % percent
		sys.stderr.write('\r %8s [%s]\r' % (percent, bar))
		sys.stderr.flush()

	def messageChanged(self, subject, message):
		sys.stderr.write('\n%s\n' % message)
		sys.stderr.flush()


def main(argv):
	os.umask(022)

	logger.setConsoleLevel(LOG_WARNING)
	logger.setConsoleColor(True)
	logger.setConsoleFormat(u'%L: %M')

	parser = argparse.ArgumentParser(add_help=False,
		description=("Provides an opsi package from a package source directory.\n"
				"If no source directory is supplied, the current directory will be used.")
	)
	parser.add_argument('--help', action='store_true', default=False,
				help="Show help.")  # Manual implementation because of -h
	parser.add_argument('--version', '-V', action='version', version=__version__)
	parser.add_argument('--quiet', '-q', action='store_true', default=False,
				help="do not show progress")
	parser.add_argument('--verbose', '-v', default=False, action="store_true",
				help="verbose")
	parser.add_argument('--log-level', '-l', dest="logLevel", type=int,
				default=LOG_WARNING, help="Log-level 0..9")
	parser.add_argument('-n', dest="compression", help="Do not compress",
				default='gzip', action='store_const', const=None)
	parser.add_argument('-F', dest="format", default='cpio', choices=['cpio', 'tar'],
						help="Archive format to use. Default: cpio")
	parser.add_argument('-h', dest="dereference", help="follow symlinks",
				default=False, action='store_true')
	parser.add_argument('-I', dest="incremental", help="incremental package",
				metavar='required version', default=0)
	customGroup = parser.add_mutually_exclusive_group()
	customGroup.add_argument('-i', dest="customName", default=u'',
					metavar='custom name',
					help="custom name (add custom files)")
	customGroup.add_argument('-c', dest="customOnly", default=False,
					metavar='custom name',
					help="custom name (custom only)")
	parser.add_argument('-C', dest="compatibilityMode", default=False,
				action="store_true", help="compatibility mode (opsi 3)")
	parser.add_argument('-t', dest="tempDir", help="temp dir", default=u'/tmp',
				metavar='directory')
	parser.add_argument('-m', dest="createMd5SumFile", help="create md5sum file",
				default=False, action='store_true')
	parser.add_argument('-z', dest="createZsyncFile", help="create zsync file",
				default=False, action='store_true')
	parser.add_argument('--no-pigz', dest="disablePigz",
				default=False, action='store_true',
				help="Disable the usage of pigz")
	parser.add_argument('packageSourceDir', metavar="source directory",
				nargs='?', default=os.getcwd())

	vgroup = parser.add_argument_group('Versions',
		'Set versions for package. Combinations are possible.')
	vgroup.add_argument('--keep-versions', '-k', action='store_true',
				help="Keep versions and overwrite package", dest="keepVersions")
	vgroup.add_argument('--package-version', help="Set new package version ",
				default=u'', metavar='packageversion', dest="newPackageVersion")
	vgroup.add_argument('--product-version', default=u'',
				dest="newProductVersion", metavar='productversion',
				help="Set new product version for package")

	args = parser.parse_args()
	if args.help:
		parser.print_help()
		sys.exit(1)

	reload(sys)
	sys.setdefaultencoding("utf-8")

	keepVersions = args.keepVersions
	needOneVersion = False
	newProductVersion = args.newProductVersion
	newPackageVersion = args.newPackageVersion
	if newProductVersion or newPackageVersion:
		needOneVersion = True
	doNotUseTerminal = False
	if keepVersions:
		doNotUseTerminal = True
	if newPackageVersion and newProductVersion:
		doNotUseTerminal = True

	customName = args.customName
	customOnly = bool(args.customOnly)
	if customOnly:
		customName = args.customOnly
	requiredVersion = args.incremental
	dereference = args.dereference
	logLevel = args.logLevel
	compression = args.compression
	quiet = args.quiet
	tempDir = forceFilename(args.tempDir)
	format = forceUnicode(args.format)
	compatibilityMode = args.compatibilityMode
	createMd5SumFile = args.createMd5SumFile
	createZsyncFile = args.createZsyncFile
	packageSourceDir = args.packageSourceDir
	disablePigz = args.disablePigz

	if args.verbose:
		logLevel = LOG_DEBUG

	if logLevel != LOG_WARNING:
		logger.setColor(True)

	if quiet:
		logLevel = LOG_NONE

	logger.setConsoleLevel(logLevel)

	logger.info(u"Source dir: %s" % packageSourceDir)
	logger.info(u"Temp dir: %s" % tempDir)
	logger.info(u"Custom name: %s" % customName)
	logger.info(u"Archive format: %s" % format)

	if format not in ['tar', 'cpio']:
		raise ValueError(u"Unsupported archive format: %s" % format)

	if not os.path.isdir(packageSourceDir):
		raise OSError(u"No such directory: %s" % packageSourceDir)

	if customName:
		packageControlFilePath = os.path.join(packageSourceDir, u'OPSI.%s' % customName, u'control')
	if not customName or not os.path.exists(packageControlFilePath):
		packageControlFilePath = os.path.join(packageSourceDir, u'OPSI', u'control')
		if not os.path.exists(packageControlFilePath):
			raise OSError(u"Control file '%s' not found" % packageControlFilePath)

	if not quiet:
		print ""
		print _(u"Locking package")
	pcf = PackageControlFile(packageControlFilePath, opsi3compatible=compatibilityMode)

	lockPackage(tempDir, pcf)
	pps = None
	try:
		while True:
			product = pcf.getProduct()
			if requiredVersion:
				match = re.search('^\s*([<>]?=?)(^[<>=]+)\s*$', requiredVersion)
				if not match:
					raise ValueError(u"Bad version string '%s' in dependency" % requiredVersion)
				packageDependency = {
					'package': product.id,
					'condition': match.group(1),
					'version': match.group(2)
				}
				pcf.setPackageDependencies([packageDependency])
				pcf.setIncrementalPackage(True)

			if not quiet:
				print u""
				print _(u"Package info")
				print u"----------------------------------------------------------------------------"
				print u"   %-20s : %s" % (u'version', product.packageVersion)
				print u"   %-20s : %s" % (u'custom package name', customName)
				print u"   %-20s : %s" % (u'incremental package', bool(requiredVersion))

				dependencies = []
				for dep in pcf.getPackageDependencies():
					dependencies.append(u'%s(%s%s)' % (dep['package'], dep['condition'], dep['version']))
				print u"   %-20s : %s" % (u'package dependencies', u', '.join(dependencies))

				print ""
				print _(u"Product info")
				print u"----------------------------------------------------------------------------"
				print u"   %-20s : %s" % (u'product id', product.id)

				if product.getType() == 'LocalbootProduct':
					print u"   %-20s : %s" % (u'product type', u'localboot')
				elif product.getType() == 'NetbootProduct':
					print u"   %-20s : %s" % (u'product type', u'netboot')

				print u"   %-20s : %s" % (u'version', product.productVersion)
				print u"   %-20s : %s" % (u'name', product.name)
				print u"   %-20s : %s" % (u'description', product.description)
				print u"   %-20s : %s" % (u'advice', product.advice)
				print u"   %-20s : %s" % (u'priority', product.priority)
				print u"   %-20s : %s" % (u'licenseRequired', product.licenseRequired)
				print u"   %-20s : %s" % (u'product classes', u', '.join(product.productClassIds))
				print u"   %-20s : %s" % (u'windows software ids', u', '.join(product.windowsSoftwareIds))

				if product.getType() == 'NetbootProduct':
					print u"   %-20s : %s" % (u'pxe config template', product.pxeConfigTemplate)

				print ""
				print _(u"Product scripts")
				print u"----------------------------------------------------------------------------"
				print u"   %-20s : %s" % (u'setup', product.setupScript)
				print u"   %-20s : %s" % (u'uninstall', product.uninstallScript)
				print u"   %-20s : %s" % (u'update', product.updateScript)
				print u"   %-20s : %s" % (u'always', product.alwaysScript)
				print u"   %-20s : %s" % (u'once', product.onceScript)
				print u"   %-20s : %s" % (u'custom', product.customScript)
				if product.getType() == 'LocalbootProduct':
					print u"   %-20s : %s" % (u'user login', product.userLoginScript)
				print u""

			if disablePigz:
				logger.debug("Disabling pigz")
				OPSI.Util.File.Archive.PIGZ_ENABLED = False

			pps = ProductPackageSource(
				packageSourceDir=packageSourceDir,
				tempDir=tempDir,
				customName=customName,
				customOnly=customOnly,
				packageFileDestDir=os.getcwd(),
				format=format,
				compression=compression,
				dereference=dereference
			)

			if not quiet and os.path.exists(pps.getPackageFile()):
				print _(u"Package file '%s' already exists.") % pps.getPackageFile()
				print _(u"Press <O> to overwrite, <C> to abort or <N> to specify a new version:"),
				newVersion = False
				if keepVersions and needOneVersion:
					newVersion = True
				elif keepVersions:
					if os.path.exists(pps.packageFile + u'.md5'):
						os.remove(pps.packageFile + u'.md5')
					if os.path.exists(pps.packageFile + u'.zsync'):
						os.remove(pps.packageFile + u'.zsync')
				elif needOneVersion:
					newVersion = True

				if not doNotUseTerminal:
					fd = sys.stdin.fileno()
					savedSettings = termios.tcgetattr(fd)
					tty.setraw(fd)
					try:
						while True:
							ch = sys.stdin.read(1)
							if ch in ('o', 'O'):
								if os.path.exists(pps.packageFile + u'.md5'):
									os.remove(pps.packageFile + u'.md5')
								if os.path.exists(pps.packageFile + u'.zsync'):
									os.remove(pps.packageFile + u'.zsync')
								break
							elif ch in ('c', 'C'):
								raise Exception(_(u"Aborted"))
							elif ch in ('n', 'N'):
								newVersion = True
								break
					finally:
						termios.tcsetattr(fd, termios.TCSADRAIN, savedSettings)
						print '\r\033[0K'

				if newVersion:
					while True:
						print '\r%s' % _(u"Please specify new product version, press <ENTER> to keep current version (%s):") % product.productVersion,
						newVersion = newProductVersion
						if not keepVersions and not needOneVersion:
							newVersion = sys.stdin.readline().strip()
						else:
							if newProductVersion:
								newVersion = newProductVersion
							elif keepVersions:
								newVersion = product.productVersion
							else:
								newVersion = sys.stdin.readline().strip()

						try:
							if newVersion:
								product.setProductVersion(newVersion)
								pcf.generate()
							break
						except Exception:
							print _(u"Bad product version: %s") % newVersion

					while True:
						print '\r%s' % _(u"Please specify new package version, press <ENTER> to keep current version (%s):") % product.packageVersion,
						newVersion = newPackageVersion
						if not keepVersions and not needOneVersion:
							newVersion = sys.stdin.readline().strip()
						else:
							if newPackageVersion:
								newVersion = newPackageVersion
							elif keepVersions:
								newVersion = product.packageVersion
							else:
								newVersion = sys.stdin.readline().strip()

						try:
							if newVersion:
								product.setPackageVersion(newVersion)
								pcf.generate()
							break
						except Exception:
							print _(u"Bad package version: %s") % newVersion

					keepVersions = True
					needOneVersion = False
					newProductVersion = newPackageVersion = None
					newVersion = None
					continue

			# Regenerating to fix encoding
			pcf.generate()

			progressSubject = None
			if not quiet:
				progressSubject = ProgressSubject('packing')
				progressSubject.attachObserver(ProgressNotifier())
				print _(u"Creating package file '%s'") % pps.getPackageFile()
			pps.pack(progressSubject=progressSubject)
			setRights(pps.getPackageFile())

			if not quiet:
				print "\n"
			if createMd5SumFile:
				md5sumFile = u'%s.md5' % pps.getPackageFile()
				if not quiet:
					print _(u"Creating md5sum file '%s'") % md5sumFile
				md5 = md5sum(pps.getPackageFile())
				with open(md5sumFile, 'w') as f:
					f.write(md5)
				setRights(md5sumFile)

			if createZsyncFile:
				zsyncFilePath = u'%s.zsync' % pps.getPackageFile()
				if not quiet:
					print _(u"Creating zsync file '%s'") % zsyncFilePath
				zsyncFile = ZsyncFile(zsyncFilePath)
				zsyncFile.generate(pps.getPackageFile())
				setRights(zsyncFilePath)
			break
	finally:
		if pps:
			if not quiet:
				print _(u"Cleaning up")
			pps.cleanup()
		if not quiet:
			print _(u"Unlocking package")
		unlockPackage(tempDir, pcf)
		if not quiet:
			print ""


def lockPackage(tempDir, packageControlFile):
	lockFile = os.path.join(tempDir, u'.opsi-makeproductfile.lock.%s' % packageControlFile.getProduct().id)
	# Test if other processes are accessing same product
	try:
		with open(lockFile, 'r') as lf:
			p = lf.read().strip()

		if p:
			for line in execute(u"ps -A"):
				line = line.strip()
				if not line:
					continue
				if p == line.split()[0].strip():
					pName = line.split()[-1].strip()
					# process is running
					raise RuntimeError(u"Product '%s' is currently locked by process %s (%s)."
									% (packageControlFile.getProduct().id, pName, p))

	except IOError:
		pass

	# Write lock-file
	with open(lockFile, 'w') as lf:
		lf.write(str(os.getpid()))


def unlockPackage(tempDir, packageControlFile):
	lockFile = os.path.join(tempDir, u'.opsi-makeproductfile.lock.%s' % packageControlFile.getProduct().id)
	if os.path.isfile(lockFile):
		os.unlink(lockFile)


if (__name__ == "__main__"):
	exception = None

	try:
		main(sys.argv[1:])
	except SystemExit:
		pass
	except Exception as exception:
		logger.setConsoleLevel(LOG_ERROR)
		logger.logException(exception)
		print >> sys.stderr, u"ERROR: %s" % exception
		sys.exit(1)

	sys.exit(0)