<?php

/**
 * Atmail WebDAV migrator
 *
 * This class' sole responsibility is the migration from a Darwin calendar 
 * server, to the new SabreDAV database layout.
 * 
 * @package Atmail
 * @subpackage Dav 
 * @copyright Copyright (C) 2011 Atmail. All rights reserved.
 * @author Evert Pot (http://www.rooftopsolutions.nl/) 
 */
class Atmail_Dav_Migrator
{
	const EXPORT_VERSION = '0.1';

	const LOG_STATUS = -1;
	const LOG_ERROR = 0;
	const LOG_INFO = 1;
	const LOG_DEBUG = 2;

	public $logLevel = self::LOG_ERROR;
	/**
	 * When set to true, nothing is changed, only simulated
	 */
	public $dryRun = false;
	protected $dataDir;
	protected $output;
	protected $accounts = array();
	protected $calendars = array();
	protected $delegates;
	protected $xattrBin;
	protected $ctag = 1;
	protected $total = 0;
	protected $completed = 0;
	protected $startTime = 0;
	protected $errors = array();
	/**
	 * Sets up the migrator
	 */
	public function __construct($dataDir = '/usr/local/atmail/calendarserver/server/twistedcaldav/atmail/data/calendars/__uids__/')
	{
		error_reporting(E_ALL);
		$this->dataDir = $dataDir;
		$this->output = fopen('php://stderr', 'w');
		$this->db = Zend_Registry::get('dbAdapter');

		$this->xattrBin = `which xattr 2>/dev/null`;
		if (!$this->xattrBin)
		{
			$this->xattrBin = `which attr 2>/dev/null`;
		}
		if ($this->xattrBin)
		{
			$this->xattrBin = trim($this->xattrBin, "\n\r ");
		}
		else
		{
			throw new Atmail_Exception('Could not find a binary to load extended attributes');
		}
		$this->info('Using ' . $this->xattrBin . ' to find extended attributes');
	}

	public function start()
	{
		$this->findTotal();
		$this->completed = 0;
		$this->startTime = time();
		$this->findDelegates();
		$this->findAccounts();
		$this->findCalendars();
		$this->createPrincipals();
		$this->createCalendars();
		if(count($this->errors) > 0)
		{
			$errmsg = "\n\nThe following errors were encounted during the migration :\n";
			foreach($this->errors as $k => $v)
			{
				$errmsg .= $k . $v . "\n";
			}
			$this->error($errmsg);
		}
	}

	protected function findTotal()
	{
		// total delegates
		$pdo = new PDO('sqlite:/usr/local/atmail/calendarserver/server/data/calendaruserproxy.sqlite');
		$this->total += count($pdo->query("SELECT GROUPNAME, MEMBER FROM GROUPS")->fetchAll());
		unset($pdo);
		
		// total data dirs to scan
		$this->total += count($directories = glob($this->dataDir . '/*/*/*'));

		// total accounts
		$this->db->getConnection();
		foreach ($directories as $directory)
		{
			$delegateAccount = false;
			$atmailAccount = str_replace('_', '@', basename($directory));
			$realAccount = $atmailAccount;
			if (preg_match('/^([^_-]+)(-[^_]+)@(.+)$/', $atmailAccount, $matches))
			{
				$realAccount = $matches[1] . '@' . $matches[3];
			}
			$members = array();
			$result = $this->db->query("SELECT Account FROM UserSession where Account = ?", array($realAccount))->fetchAll();
			if(!count($result))continue;
			$this->total += count($result) ? 1 : 0;
			$this->total += count($moreDirectories = glob($directory . '/*'));
			foreach ($moreDirectories as $moreDirectory)
			{
				$this->total += count(glob($moreDirectory . '/*.ics')) * 2; // read and db write
			}
		}
		$this->db->closeConnection();

		return $this->total;
	}

	protected function findDelegates()
	{
		$pdo = new PDO('sqlite:/usr/local/atmail/calendarserver/server/data/calendaruserproxy.sqlite');
		$result = $pdo->query("SELECT GROUPNAME, MEMBER FROM GROUPS")->fetchAll();
		
		foreach ($result as $row)
		{
			$this->completed++;
			$this->status();
			/* matches aaaa-bbbb_cccc@calendar-proxy(read-write)  -bbbb is
			 * optional */
			if (!preg_match('/^([^_-]+)((-[^_]+)?)_([^#]+)#calendar-proxy-(read|write)$/', $row['GROUPNAME'], $matches))
			{
				$this->errors[$row['GROUPNAME']] = " did not match the standard pattern and was skipped. : user(-delegationname)_hostname#calendar-proxy-(read|write).";
				continue;
			}

			list(, $userName, $calendarName,, $hostName, $accessMode) = $matches;

			$delegateName = $userName . $calendarName . '@' . $hostName;
			if (!isset($this->delegates[$delegateName]))
			{
				$this->delegates[$delegateName] = array(
					'mainAccount' => $userName . '@' . $hostName,
					'members' => array(
						array(
							'account' => str_replace('_', '@', $row['MEMBER']),
							'mode' => $accessMode
						),
					),
				);
			}
			else
			{
				$this->delegates[$delegateName]['members'][] = array(
					'account' => str_replace('_', '@', $row['MEMBER']),
					'mode' => $accessMode,
				);
			}
		}
		$this->info(count($this->delegates) . ' delegated calendars found, with ' . count($result) . ' members in total');
	}

	protected function findAccounts()
	{
		$directories = glob($this->dataDir . '/*/*/*');

		$this->db->getConnection();
		foreach ($directories as $directory)
		{
			$this->completed++;
			$this->status();

			$this->debug("Scanning: " . $directory);
			$delegateAccount = false;
			$atmailAccount = str_replace('_', '@', basename($directory));
			$realAccount = $atmailAccount;
			$members = array();

			if (isset($this->delegates[$atmailAccount]))
			{
				$realAccount = $this->delegates[$atmailAccount]['mainAccount'];
				if ($realAccount !== $atmailAccount)
				{
					$this->info($atmailAccount . ' is actually a fake delegate account from : ' . $realAccount);
					$delegateAccount = true;
				}
				$members = $this->delegates[$atmailAccount]['members'];
			}
			elseif (preg_match('/^([^_-]+)(-[^_]+)@(.+)$/', $atmailAccount, $matches))
			{
				$realAccount = $matches[1] . '@' . $matches[3];
				$this->info($atmailAccount . ' is a broken delegate account for ' . $realAccount . ', so we\'re repairing this');
			}

			$stmt = $this->db->query("SELECT Account FROM UserSession where Account = ?", array($realAccount));
			$result = $stmt->fetchAll();

			if (!count($result))
			{
				
				$this->errors[$realAccount] = ' Could not be matched up with any accounts in the Users table. This account was skipped.';
				$stmt = null;
				$result = null;
				continue;
			}

			$this->info('Found match for atmail account: ' . $result[0]['Account']);
			$this->accounts[] = array(
				'account' => $result[0]['Account'],
				'delegateAccount' => $atmailAccount,
				'directory' => $directory,
				'principal' => 'principals/users/' . $result[0]['Account'],
				'members' => $members,
				'delegate' => !($result[0]['Account'] == $atmailAccount)
			);
			$this->total++;
			$stmt = null;
			$result = null;
		}
		$this->db->closeConnection();
		$this->debug("Found accounts = " . print_r($this->accounts, true) . "\n");
	}

	protected function findCalendars()
	{
		foreach ($this->accounts as $account)
		{
			$this->completed++;
			$this->status();

			$resultsRaw = glob($account['directory'] . '/*');
			$result = array();
			foreach ($resultsRaw as $r)
			{
				// Skipping 'inbox', 'outbox', as these will never be a real calendar
				if (basename($r) === 'inbox' || basename($r) === 'outbox')
				{
					$this->completed++;
					$this->status();

					continue;
				}
				$result[] = $r;
			}

			$this->debug('Checking ' . count($result) . ' possible calendars for ' . $account['account']);
			$count = 0;

			foreach ($result as $r)
			{
				$this->completed++;
				$this->status();

				$this->info('Checking ' . $r . "\n");

				// This is accurate enough for our needs
				if (stripos($this->getAttribute($r, '{DAV:}resourcetype'), 'calendar') === false)
				{
					$this->debug($r . " is not a calendar. Skipping.");
					continue;
				}

				$calendar = array(
					'uri' => $account['delegate'] ? Sabre_DAV_UUIDUtil::getUUID() : basename($r),
					'directory' => $r,
					'account' => $account['account'],
					'principal' => $account['principal'],
					'ctag' => $this->ctag++,
					'color' => $this->getXMLAttribute($r, '{http://apple.com/ns/ical/}calendar-color'),
					'order' => $this->getXMLAttribute($r, '{http://apple.com/ns/ical/}calendar-order'),
					'description' => $this->getXMLAttribute($r, '{urn:ietf:params:xml:ns:caldav}calendar-description'),
					'displayname' => $this->getXMLAttribute($r, '{DAV:}displayname'),
					'members' => $account['members'],
				);

				$calendar['objects'] = $this->findCalendarObjects($calendar);

				$this->calendars[] = $calendar;
				$this->total+= count($account['members']) + 1;
				$count++;
			}
			$this->info($count . ' calendars found for ' . $account['account']);
		}
		$this->debug("Total calendars found = " . print_r($this->calendars, true) . "\n");
	}

	protected function findCalendarObjects($calendarInfo)
	{
		$objects = glob($calendarInfo['directory'] . '/*.ics');
		$result = array();

		foreach ($objects as $obj)
		{
			$this->completed++;
			$this->status();

			$result[] = array(
				'uri' => basename($obj),
				'data' => file_get_contents($obj),
			);
		}
		
		$this->debug("Found calendar objects = " . print_r($result, true) . "\n");

		return $result;
	}

	protected function createPrincipals()
	{

		$uniques = array();
		$this->db->getConnection();

		// Making sure we're not creating any accounts twice.
		foreach ($this->accounts as $account)
		{
			if(isset($uniques[$account['account']]))
			{
				$this->completed++;
				$this->status();
			}
			$uniques[$account['account']] = $account['principal'];
		}

		foreach ($uniques as $account => $principal)
		{
			$this->completed++;
			$this->status();
			$this->info("Creating principals for: " . $account);
			if (!$this->dryRun)
			{
				$this->db->query(
						"INSERT IGNORE INTO principals (uri, email, displayname) VALUES (?, ?, ?)", array($principal, $account, $account)
				);
			}
		}
		$this->db->closeConnection();

	}

	protected function createCalendars()
	{
		$this->db->getConnection();
		$this->debug('calendars = ' . print_r($this->calendars,true) . "\n");
		foreach ($this->calendars as $calendar)
		{
			$this->completed++;
			$this->status();
			$this->info("Creating calendar: " . $calendar['account'] . '/' . ($calendar['displayname'] ? $calendar['displayname'] : $calendar['uri']) . ' (' . count($calendar['objects']) . ' objects)');
			$calendarId = -1;
			if (!$this->dryRun)
			{
				$this->db->query(
						"INSERT INTO calendars (uri, principaluri, calendarcolor, calendarorder, description, displayname, ctag, components) VALUES (?, ?, ?, ?, ?, ?, ?, 'VEVENT,VTODO')", array($calendar['uri'], $calendar['principal'], $calendar['color'], $calendar['order'] ? $calendar['order'] : 0, $calendar['description'], $calendar['displayname'], $calendar['ctag'])
				);
			
				$calendarId = $this->db->lastInsertId();
			}
			foreach ($calendar['objects'] as $obj)
			{
				$this->completed++;
				$this->status();

				if(!$this->dryRun)
				{
					$this->db->query(
							'INSERT INTO calendarObjects (calendardata, uri, calendarid, lastmodified) VALUES (?, ?, ?, ?)', array($obj['data'], $obj['uri'], $calendarId, time())
					);
				}
			}
			
			foreach ($calendar['members'] as $member)
			{
				$this->completed++;
				$this->status();
				if ($member['account'] === $calendar['account'])
				{
					continue;
				}
				$this->info('Adding ' . $member['mode'] . ' permissions for ' . $member['account']);

				if (!$this->dryRun)
				{
					$this->db->query(
							"INSERT INTO calendarDelegates (calendarid, principalid, mode) SELECT ?, id, ? FROM principals WHERE email = ?", array($calendarId, $member['mode'] === 'read' ? 1 : 2, $member['account'])
					);
				}
			}
		}
		$this->db->closeConnection();
	}

	protected function getAttribute($path, $attribute)
	{

		// This was the only escaped character we've seen
		$attribute = str_replace('/', '%2F', $attribute);

		if (basename($this->xattrBin) === 'attr')
		{
			$result = shell_exec($this->xattrBin . ' -qg ' . escapeshellarg($attribute) . ' ' . $path . ' 2>/dev/null');
		}
		else
		{
			$result = shell_exec($this->xattrBin . ' -p ' . escapeshellarg($attribute) . ' ' . $path . ' 2>/dev/null');
		}
		if ($result == "")
		{
			return null;
		}
		$result2 = gzuncompress($result);
		return $result2 ? $result2 : $result;
	}

	protected function getXMLAttribute($path, $attribute)
	{

		$value = $this->getAttribute($path, $attribute);
		if (!$value)
			return null;

		$dom = Sabre_DAV_XMLUtil::loadDOMDocument($value);
		return $dom->firstChild->nodeValue;
	}

	public function log($level, $message)
	{
		if (($level > $this->logLevel) || ($this->logLevel != self::LOG_ERROR && $level == self::LOG_STATUS))
		{
			return;
		}
		ob_start();

		switch ($level)
		{
			case self::LOG_STATUS :
				break;
			case self::LOG_ERROR :
				echo "\x1B[31m";
				break;
			default:
				$message .= "\n";
				echo date('[H:i]') . ' ';
				break;
		}

		switch (gettype($message))
		{
			case 'string' :
				echo $message;
				break;
			default :
				var_dump($message);
				break;
		}
		echo "\x1B[0m";
		$buffer = ob_get_clean();
		fwrite($this->output, $buffer);
	}

	public function debug($message)
	{
		$this->log(self::LOG_DEBUG, $message . "\n");
	}

	public function info($message)
	{
		$this->log(self::LOG_INFO, $message ."\n");
	}

	public function error($message)
	{
		$this->log(self::LOG_ERROR, $message . "\n");
	}

	public function status($message = '')
	{
		if($message == '')
		{
			$message = "\r" . $this->completed . "/" . $this->total . " in " . (time() - $this->startTime) . " seconds" ;
		}
		$this->log(self::LOG_STATUS, $message);
	}

}
