<?php namespace ProcessWire;

/**
 * ProcessWire Pages Export/Import Helpers
 * 
 * #pw-headline Pages Exporter/Importer
 * #pw-breadcrumb Pages
 * #pw-summary Methods for exporting and importing pages from one installation to another. 
 * #pw-var $porter
 * #pw-order-groups export,import,cleanup
 * #pw-body = 
 * This class is primarily used interactively through the core `ProcessPagesExportImport` module, but
 * can also be used from its API, described here. 
 * ~~~~~
 * $porter = new PagesExportImport(); // works in any version
 * $porter = $pages->porter(); // 3.0.253+ only
 * ~~~~~
 * 
 * #pw-body
 * 
 * ProcessWire 3.x, Copyright 2024 by Ryan Cramer
 * https://processwire.com
 *
 */

class PagesExportImport extends Wire {

	/**
	 * Get the path where ZIP exports are stored
	 * 
	 * #pw-group-export
	 * 
	 * @param string $subdir Specify a subdirectory name if you want it to create it. 
	 *   If it exists, it will create a numbered version of the subdir to ensure it is unique. 
	 * @return string
	 * 
	 */
	public function getExportPath($subdir = '') {
	
		$files = $this->wire()->files;
		$config = $this->wire()->config;
		$path = $config->paths->assets . 'backups/' . $this->className() . '/';
		
		$readmeText = "When this file is present, files and directories in here are auto-deleted after a short period of time.";
		$readmeFile = $this->className() . '.txt';
		$readmeFiles = array();
		
		if(!is_dir($path)) {
			$files->mkdir($path, true);
			$readmeFiles[] = $path . $readmeFile;
		}
		
		if($subdir) {
			$n = 0;
			do {
				$_path = $path . $subdir . ($n ? "-$n" : '') . '/';
			} while(++$n && is_dir($_path)); 
			$path = $_path;
			$files->mkdir($path, true);
			$readmeFiles[] = $path . $readmeFile;
		}
		
		foreach($readmeFiles as $file) {
			file_put_contents($file, $readmeText);
			$files->chmod($readmeFile); 
		}
		
		return $path; 
	}

	/**
	 * Remove files and directories in /site/assets/backups/PagesExportImport/ that are older than $maxAge
	 * 
	 * #pw-group-cleanup
	 * 
	 * @param int $maxAge Maximum age in seconds (default=3600)
	 * @return int Number of files/dirs removed
	 * 
	 */
	public function cleanupFiles($maxAge = 3600) {

		$files = $this->wire()->files;
		$path = $this->getExportPath();
		$qty = 0;
		
		foreach(new \DirectoryIterator($path) as $file) {
			
			if($file->isDot()) continue;
			if($file->getBasename() == $this->className() . '.txt') continue; // we want this file to stay
			if($file->getMTime() >= (time() - $maxAge)) continue; // not expired
			
			$pathname = $file->getPathname();
			
			if($file->isDir()) {
				$testFile = rtrim($pathname, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $this->className() . '.txt';
				if(!is_file($testFile)) continue; 
				if($files->rmdir($pathname, true)) {
					$this->message($this->_('Removed old directory') . " - $pathname", Notice::debug); 
					$qty++;
				}
			} else {
				if($files->unlink($pathname, true)) {
					$this->message($this->_('Removed old file') . " - $pathname", Notice::debug); 
					$qty++;
				}
			}
		}
		
		return $qty; 
	}

	/**
	 * Export given PageArray to a ZIP file
	 * 
	 * #pw-group-export
	 * 
	 * @param PageArray $items
	 * @param array $options
	 * @return string Path+filename to ZIP file 
	 * 
	 */
	public function exportZIP(PageArray $items, array $options = array()) {
		
		$files = $this->wire()->files;
		
		$options['exportTarget'] = 'zip';
		$zipPath = $this->getExportPath();
		if(!is_dir($zipPath)) $files->mkdir($zipPath, true); 
		
		$tempDir = new WireTempDir($this);
		$this->wire($tempDir);
		$tmpPath = $tempDir->get();
		$jsonFile = $tmpPath . "pages.json";
		$zipItems = array($jsonFile);
		$data = $this->pagesToArray($items, $options);
	
		// determine other files to add to ZIP
		foreach($data['pages'] as $key => $item) {
			if(!isset($item['_filesPath'])) continue;
			$zipItems[] = $item['_filesPath'];
			unset($data['pages'][$key]['_filesPath']);
		}
	
		// write out the pages.json file
		file_put_contents($jsonFile, wireEncodeJSON($data, true, true));

		$n = 0;
		do {
			$zipName = $zipPath . 'pages' . ($n ? "-$n" : '') . '.zip';
		} while(++$n && file_exists($zipName)); 
		
		// @todo report errors from zipInfo
		$zipInfo = $files->zip($zipName, $zipItems, array(
			'maxDepth' => 1, 
			'allowHidden' => false, 
			'allowEmptyDirs' => false
		)); 
		if($zipInfo) {} // ignore
		
		$files->unlink($jsonFile, true); 
		
		return $zipName;
	}

	/**
	 * Import ZIP file to create pages
	 * 
	 * #pw-group-import
	 * 
	 * @param string $filename Path+filename to ZIP file 
	 * @param array $options
	 *  - `commit` (bool): Commit/save the changes now? Specify false to perform a test import. (default=true).
	 *  - `update` (bool): Allow update of existing pages? (default=true)
	 *  - `create` (bool): Allow creation of new pages? (default=true)
	 *  - `parent` (Page|string|int): Parent Page, path or ID. Omit to use import data (default=0).
	 *  - `template` (Template|string|int): Template object, name or ID. Omit to use import data (default=0).
	 *  - `fieldNames` (array): Import only these field names, or omit to use all import data (default=[]).
	 *  - `changeStatus` (bool): Allow status to be changed aon existing pages? (default=true)
	 *  - `changeSort` (bool): Allow sort and sortfield to be changed on existing pages? (default=true)
	 * - Note: all the "change" prefix options require update=true.
	 * @return PageArray|bool
	 * 
	 */
	public function importZIP($filename, array $options = array()) {
		
		$tempDir = new WireTempDir($this);
		$this->wire($tempDir);
		$path = $tempDir->get();
		$options['filesPath'] = $path; 
		
		$zipFileItems = $this->wire()->files->unzip($filename, $path, [
			'requireFiles' => [ 'pages.json' ]
		]); 
		
		if(empty($zipFileItems)) return false;
		
		$jsonFile = $path . "pages.json";
		$jsonData = file_get_contents($jsonFile);
		$data = json_decode($jsonData, true);
		if($data === false) return false;
		
		$pageArray = $this->arrayToPages($data, $options);
		
		return $pageArray;	
	}

	/**
	 * Export a PageArray to JSON string
	 * 
	 * #pw-group-export
	 * 
	 * @param PageArray $items
	 * @param array $options
	 * @return string JSON string of pages 
	 * 
	 */
	public function exportJSON(PageArray $items, array $options = array()) {
		$defaults = array(
			'exportTarget' => 'json'
		);
		$options = array_merge($defaults, $options); 
		$data = $this->pagesToArray($items, $options); 
		$data = wireEncodeJSON($data, true, true); 
		return $data;
	}

	/**
	 * Import a PageArray from a JSON string 
	 * 
	 * Given JSON string must be one previously exported by the `exportJSON()` method in this class.
	 * 
	 * #pw-group-import
	 * 
	 * @param string $json JSON string of exported data to import
	 * @param array $options
	 *  - `commit` (bool): Commit/save the changes now? Specify false to perform a test import. (default=true).
	 *  - `update` (bool): Allow update of existing pages? (default=true)
	 *  - `create` (bool): Allow creation of new pages? (default=true)
	 *  - `parent` (Page|string|int): Parent Page, path or ID. Omit to use import data (default=0).
	 *  - `template` (Template|string|int): Template object, name or ID. Omit to use import data (default=0).
	 *  - `fieldNames` (array): Import only these field names, or omit to use all import data (default=[]).
	 *  - `changeStatus` (bool): Allow status to be changed aon existing pages? (default=true)
	 *  - `changeSort` (bool): Allow sort and sortfield to be changed on existing pages? (default=true)
	 * - Note: all the "change" prefix options require update=true.
	 * @return PageArray|bool 
	 * 
	 */
	public function importJSON($json, array $options = array()) {
		$data = json_decode($json, true); 
		if($data === false) return false;
		$pageArray = $this->arrayToPages($data, $options);
		return $pageArray;	
	}
	
	/**
	 * Given a PageArray export it to a portable PHP array
	 * 
	 * #pw-group-export
	 *
	 * @param PageArray $items
	 * @param array $options Additional options to modify behavior
	 *  - `fieldNames` (array): Export oly these field names, when specified. (default=[])
	 * @return array
	 *
	 */
	public function pagesToArray(PageArray $items, array $options = array()) {
	
		$config = $this->wire()->config;
		$modules = $this->wire()->modules;
		$fields = $this->wire()->fields;

		$defaults = array(
			'verbose' => false,
			'fieldNames' => array(), // export only these field names, when specified
		);

		$options = array_merge($defaults, $options);
		$options['verbose'] = false; // TMP option not yet supported

		$a = array(
			'type' => 'ProcessWire:PageArray',
			'created' => date('Y-m-d H:i:s'), 
			'version' => $config->version,
			'user' => $this->wire()->user->name,
			'host' => $config->httpHost,
			'pages' => array(),
			'fields' => array(),
			'urls' => array(
				'root' => $config->urls->root,
				'assets' => $config->urls->assets
			),
			'timer' => Debug::timer(), 
			// 'pagination' => array(),
		);
		
		if($items->getLimit()) {
			$pageNum = $this->wire()->input->pageNum;
			$a['pagination'] = array(
				'start' => $items->getStart(),
				'limit' => $items->getLimit(),
				'total' => $items->getTotal(),
				'this' => $pageNum, 
				'next' => ($items->getTotal() > $items->getStart() + $items->count() ? $pageNum+1 : false), 
				'prev' => ($pageNum > 1 ? $pageNum - 1 : false)
			);
		} else {
			unset($a['pagination']);
		}

		$languages = $this->wire()->languages;
		if($languages) $languages->setDefault();
		$templates = array();

		foreach($items as $item) {

			$exportItem = $this->pageToArray($item, $options);
			$a['pages'][$exportItem['path']] = $exportItem;

			// include information about field settings so that warnings can be generated at
			// import time if there are applicable differences in the field settings
			foreach($exportItem['data'] as $fieldName => $value) {
				$fieldNames = array($fieldName);
				if(is_array($value) && !empty($value['type']) && $value['type'] == 'ProcessWire:PageArray') {
					// nested PageArray, pull in fields from it as well
					foreach(array_keys($value['fields']) as $fieldName) $fieldNames[] = $fieldName;
				}
				foreach($fieldNames as $fieldName) {
					if(isset($a['fields'][$fieldName])) continue;
					$field = $fields->get($fieldName);
					if(!$field || !$field->type) continue;
					$moduleInfo = $modules->getModuleInfoVerbose($field->type);
					if($options['verbose']) {
						$fieldData = $field->getExportData();
						unset($fieldData['name']);
						$a['fields'][$fieldName] = $fieldData;
					} else {
						$a['fields'][$fieldName] = array(
							'type' => $field->type->className(),
							'label' => $field->label,
							'version' => $moduleInfo['versionStr'],
							'id' => $field->id
						);
					}
					$blankValue = $field->type->getBlankValue($item, $field);
					if(is_object($blankValue)) {
						if($blankValue instanceof Wire) {
							$blankValue = "class:" . $blankValue->className();
						} else {
							$blankValue = "class:" . get_class($blankValue);
						}
					}
					$a['fields'][$fieldName]['blankValue'] = $blankValue;
					foreach($field->type->getImportValueOptions($field) as $k => $v) {
						if(isset($a['fields'][$fieldName][$k])) continue;
						$a['fields'][$fieldName][$k] = $v;
					}
				}
			}

			// include information about template settings so that warnings can be generated
			// at import time if there are applicable differences in the template settings
			if($options['verbose']) {
				if(!isset($templates[$item->template->name])) {
					$templates[$item->template->name] = $item->template->getExportData();
				}
			}
		}
	
		// sort by path to ensure parents are created before their children
		ksort($a['pages']); 
		$a['pages'] = array_values($a['pages']); 
		$a['timer'] = Debug::timer($a['timer']); 

		if($options['verbose']) $a['templates'] = $templates;

		if($languages) $languages->unsetDefault();

		return $a;
	}
	
	/**
	 * Export Page object to an array
	 * 
	 * #pw-group-export
	 *
	 * @param Page $page
	 * @param array $options
	 *  - `exportTarget` (string): Export target of 'zip' or 'json' (default=json)
	 * @return array
	 *
	 */
	public function pageToArray(Page $page, array $options) {
		
		$defaults = array(
			'exportTarget' => '',
		);
		$options = array_merge($defaults, $options); 
		
		$of = $page->of();
		$page->of(false);

		/** @var Languages|Language[] $languages */
		$languages = $this->wire()->languages;
		if($languages) $languages->setDefault();
		$numFiles = 0;
	
		// standard page settings
		$settings = array(
			'id' => $page->id, // for connection to exported file directories only
			'name' => $page->name,
			'status' => $page->status,
			'sort' => $page->sort,
			'sortfield' => $page->sortfield,
			'created' => $page->createdStr,
			'modified' => $page->modifiedStr,
		);

		// verbose page settings
		if(!empty($options['verbose'])) {
			$settings = array_merge($settings, array(
				'parent_id' => $page->parent_id,
				'templates_id' => $page->templates_id,
				'created_user' => $page->createdUser->name,
				'modified_user' => $page->modifiedUser->name,
				'published' => $page->publishedStr,
			));
		}
		
		// include multi-language page names and statuses when applicable
		if($languages && $languages->hasPageNames()) {
			foreach($languages as $language) {
				if($language->isDefault()) continue;
				$settings["name_$language->name"] = $page->get("name$language->id");
				$settings["status_$language->name"] = $page->get("status$language->id");
			}
		}

		// array of export data
		$a = array(
			'type' => 'ProcessWire:Page',
			'path' => $page->path(),
			'class' => $page->className(true), 
			'template' => $page->template->name,
			'settings' => $settings, 
			'data' => array(),
			// 'warnings' => array(),
		);
		
		$exportValueOptions = array(
			'system' => true, 
			'caller' => $this, 
			'FieldtypeFile' => array(
				'noJSON' => true
			),
			'FieldtypeImage' => array(
				'variations' => true, 
			),				
		);
	
		// iterate all fields and export value from each
		foreach($page->template->fieldgroup as $field) {
			/** @var Field $field */
			
			if(!empty($options['fieldNames']) && !in_array($field->name, $options['fieldNames'])) continue;
			
			$info = $this->getFieldInfo($field); 
			if(!$info['exportable']) continue;

			$value = $page->getUnformatted($field->name);
			$exportValue = $field->type->exportValue($page, $field, $value, $exportValueOptions);
			
			$a['data'][$field->name] = $exportValue;
			
			if($field->type instanceof FieldtypeFile && $value) {
				$numFiles += count($value);
			}
		}
		
		if($numFiles && $options['exportTarget'] == 'zip') {
			$a['_filesPath'] = $page->filesManager()->path();
		}

		if($of) $page->of(true);
		if($languages) $languages->unsetDefault();

		return $a;
	}

	/**
	 * Import an array of page data to create or update pages
	 * 
	 * Provided array ($a) must originate from the pagesToArray() method format.
	 * 
	 * #pw-group-import
	 *
	 * @param array $a Array of import data
	 * @param array $options
	 *  - `count` (bool):  Return count of imported pages, rather than PageArray? Reduces memory requirements. (default=false)
	 *  - `pageArray` (PageArray): PageArray object to populate, or omit to return new PageArray (default=null)
	 * @return PageArray|int
	 * @throws WireException
	 *
	 */
	public function arrayToPages(array $a, array $options = array()) {
		
		if(empty($a['type']) || $a['type'] != 'ProcessWire:PageArray') {
			throw new WireException("Invalid array provided to arrayToPages() method");
		}

		$defaults = array(
			'count' => false,  // Return count of imported pages, rather than PageArray (reduced memory requirements)
			'pageArray' => null, 
		);
		
		$options = array_merge($defaults, $options);
		if(!empty($options['pageArray']) && $options['pageArray'] instanceof PageArray) {
			$pageArray = $options['pageArray'];
		} else {
			$pageArray = $this->wire()->pages->newPageArray();
		}
		$count = 0;
		
		// $a has: type (string), version (string), pagination (array), pages (array), fields (array)
		
		if(empty($a['pages'])) return $options['count'] ? 0 : $pageArray;

		// @todo generate warnings from this import info
		$info = $this->getImportInfo($a); 
		if($info) {}
		
		if(isset($a['url'])) $options['originalRootUrl'] = $a['url'];
		if(isset($a['host'])) $options['originalHost'] = $a['host'];
		
		foreach($a['pages'] as $item) {
			$page = $this->arrayToPage($item, $options);
			$id = $item['settings']['id'];
			$this->wire()->notices->move($page, $pageArray, array('prefix' => "Page $id: ")); 
			if(!$options['count']) $pageArray->add($page);
			$count++;
		}
	
		return $options['count'] ? $count : $pageArray;
	}

	/**
	 * Import an array of page data to a new Page (or update existing page)
	 * 
	 * Provided array ($a) must originate from the pageToArray() method format. 
	 * 
	 * Returns a Page on success or a NullPage on failure. Errors, warnings and messages related to the 
	 * import can be pulled from `$page->errors()`, `$page->warnings()` and `$page->messages()`. 
	 * 
	 * The following options may be used with the `$options` argument:
	 *  - `commit` (bool): Commit/save the changes now? (default=true). Specify false to perform a test run.
	 *  - `update` (bool): Allow update of existing pages? (default=true)
	 *  - `create` (bool): Allow creation of new pages? (default=true)
	 *  - `parent` (Page|string|int): Parent Page, path or ID. Omit to use import data (default=0).
	 *  - `template` (Template|string|int): Template object, name or ID. Omit to use import data (default=0).
	 *  - `fieldNames` (array): Import only these field names, or omit to use all import data (default=[]).
	 *  - `changeStatus` (bool): Allow status to be changed aon existing pages? (default=true)
	 *  - `changeSort` (bool): Allow sort and sortfield to be changed on existing pages? (default=true)
	 *  - `replaceTemplates` (array): Array of import-data template name to replacement template name (default=[])
	 *  - `replaceFields` (array): Array of import-data field name to replacement field name (default=[]) 
	 *  - `originalRootUrl` (string): Original root URL (not including hostname)
	 *  - `originalHost` (string): Original hostname 
	 * 
	 * The following options are for future use and not currently applicable:
	 *  - `changeTemplate` (bool): Allow template to be changed on existing pages? (default=false)
	 *  - `changeParent` (bool): Allow parent to be changed on existing pages? (default=false)
	 *  - `changeName` (bool): Allow name to be changed on existing pages? (default=false)
	 *  - `replaceParents` (array): Array of import-data parent path to replacement parent path (default=[])
	 * 
	 * #pw-group-import
	 * 
	 * @param array $a
	 * @param array $options Options to modify default behavior, see method description. 
	 * @return Page|NullPage
	 * @throws WireException
	 * 
	 */
	public function arrayToPage(array $a, array $options = array()) {
		
		if(empty($a['type']) || $a['type'] != 'ProcessWire:Page') {
			throw new WireException('Invalid array provided to arrayToPage() method');
		}
	
		$config = $this->wire()->config;
		$pages = $this->wire()->pages;
		$languages = $this->wire()->languages;
		$fields = $this->wire()->fields;

		$defaults = array(
			'id' => 0, // ID that new Page should use, or update, if it already exists. (0=create new). Sets update=true.
			'parent' => 0, // Parent Page, path or ID. (0=auto detect from imported page path)
			'template' => '', // Template object, name or ID. (0=auto detect from imported page template)
			'update' => true, // allow update of existing pages?
			'create' => true,  // allow creation of new pages?
			'delete' => false, // allow deletion of pages? (@todo)
			'changeTemplate' => false, // allow template to be changed on updated pages? (requires update=true)
			'changeParent' => false, 
			'changeName' => true, 
			'changeStatus' => true, 
			'changeSort' => true, 
			'saveOptions' => array('adjustName' => true, 'quiet' => true), // options passed to Pages::save
			'fieldNames' => array(),  // import only these field names, when specified
			'replaceFields' => array(), // array of import-data field name to replacement page field name
			'replaceTemplates' => array(), // array of import-data template name to replacement page template name
			'replaceParents' => array(), // array of import-data parent path to replacement parent path
			'filesPath' => '', // path where file field directories are located when importing from zip (internal use)
			'originalHost' => $config->httpHost, 
			'originalRootUrl' => $config->urls->root,
			'commit' => true, // commit the import? If false, changes aren't saved (dry run). 
			'debug' => false, 
		);
		
		$options = array_merge($defaults, $options); 
		$errors = array(); // fatal errors
		$warnings = array(); // non-fatal warnings
		$messages = array(); // informational
		$missingFields = array();
		
		if($options['id']) {
			$options['update'] = true;
			$options['create'] = false;
		}
		
		/** @var Languages $languages */
		if($languages) $languages->setDefault();

		// determine parent and template
		$page = $this->importGetPage($a, $options, $errors); 
		$parent = $page->id ? $page->parent : $this->importGetParent($a, $options, $errors); 
		$template = $page->id ? $page->template : $this->importGetTemplate($a, $options, $errors);
		
		$isNew = $page->id == 0 && !$page instanceof NullPage;
		$page->setTrackChanges(true); 	
		$page->setQuietly('_importPath', $a['path']); 
		$page->setQuietly('_importType', $isNew ? 'create' : 'update');
		$page->setQuietly('_importTemplate', $template); 
		$page->setQuietly('_importParent', $parent); 
		$page->setQuietly('_importOriginalID', $a['settings']['id']); // original/external ID
		
		// if any errors occurred above, abort
		if(count($errors) && !$page instanceof NullPage) $page = new NullPage(); 
	
		// if we were only able to create a NullPage, abort now
		if($page instanceof NullPage) {
			foreach($errors as $error) $page->error($error);
			if($languages) $languages->unsetDefault();
			return $page;
		}

		$page->of(false);
		$this->importPageSettings($page, $a['settings'], $options); 
		$changes = $page->getChanges();

		// save blank page now if it is new, so that it has an ID
		if($isNew && $options['commit']) {
			$pages->save($page, $options['saveOptions']);
		}

		// populate custom fields
		foreach($a['data'] as $name => $value) {
			
			if(count($options['fieldNames']) && !in_array($name, $options['fieldNames'])) continue;
			if(isset($options['replaceFields'][$name])) $name = $options['replaceFields'][$name];
			
			$field = $fields->get($name); 
			
			if(!$field) {
				if(is_array($value) && !count($value)) continue;
				if(!is_array($value) && !strlen($value)) continue;
				$missingFields[$name] = $name;
				continue;
			}
			
			$fieldInfo = $this->getFieldInfo($field);
			
			if(!$fieldInfo['exportable']) {
				// field cannot be imported
				$warnings[] = $fieldInfo['reason'];
			} else {
				// proceed with import of field
				try {
					$this->importFieldValue($page, $field, $value, $options);
				} catch(\Exception $e) {
					$warnings[] = $e->getMessage();
				}
			}
		}
		
		if(count($missingFields)) {
			$warnings[] = "Skipped fields (not found): " . implode(', ', $missingFields); 
		}
	
		$changes = array_unique(array_merge($changes, $page->getChanges())); 
	
		if($options['commit']) {
			$pages->save($page, $options['saveOptions']);
		}

		if($languages) $languages->unsetDefault();
		
		foreach($errors as $error) $page->error($error); 
		foreach($warnings as $warning) $page->warning($warning);
		foreach($messages as $message) $page->message($message); 
		
		$page->setQuietly('_importChanges', $changes);
		$page->setQuietly('_importMissingFields', $missingFields); 
		
		return $page;
	}

	/**
	 * Get the page to import to
	 * 
	 * @param array $a Import data
	 * @param array $options Import settings
	 * @param array $errors Errors array
	 * @return NullPage|Page
	 * 
	 */
	protected function importGetPage(array &$a, array &$options, array &$errors) {
		
		$pages = $this->wire()->pages;
		$path = $a['path'];
		
		/** @var Page|NullPage $page */
		
		if(!empty($options['id'])) {
			$page = $pages->get((int) $options['id']);
			if(!$page->id) {
				$errors[] = "Unable to find specified page to update by ID: $options[id]";
			}
			
		} else {
			if(isset($a['_importToID'])) {
				// if provided with ID added by getImportInfo() method
				$id = (int) $a['_importToID'];
				$page = $id ? $pages->get($id) : new NullPage();
			} else {
				$page = $pages->get($path);
			}
			if($page->id && !$options['update']) {
				// create new page rather than updating existing page
				$errors[] = "Skipped update to existing page because update option is disabled";
			} else if($page->id) {
				// update of existing page allowed
			} else if(!$options['create']) {
				// creation of new pages is not allowed
				$errors[] = "Skipped create of new page because create option is disabled";
			} else if(wireClassExists($a['class'])) {
				// use specified class
				$page = new $a['class']();
			} else {
				// requested page class does not exist (warning?)
				$warnings[] = "Unable to locate Page class '$a[class]', using Page class instead";
				$page = new Page();
			}
		}
		
		return $page;
	}

	/**
	 * Get the Page Template to use for import
	 * 
	 * @param array $a Import data
	 * @param array $options Import options
	 * @param array $errors Errors array
	 * @return Template|null
	 * 
	 */
	protected function importGetTemplate(array &$a, array &$options, array &$errors) {
		$template = empty($options['template']) ? $a['template'] : $options['template'];
		$name = is_object($template) ? $template->name : $template;
		if(isset($options['replaceTemplates'][$name])) $template = $options['replaceTemplates'][$name];
		$_template = $template;
		if(is_object($template)) {
			// ok
		} else {
			$template = $this->wire()->templates->get($template);
		}
		if($template) {
			$options['template'] = $template;
			$a['template'] = (string) $template;
		} else {
			$errors[] = "Unable to locate template: $_template";
		}
		return $template; 
	}

	/**
	 * Get the parent of the page being imported
	 * 
	 * @param array $a Import data
	 * @param array $options Import options
	 * @param array $errors Errors array
	 * @return Page|NullPage
	 * 
	 */
	protected function importGetParent(array &$a, array &$options, array &$errors) {
		// determine parent
		static $previousPaths = array();
		$usePrevious = true;
		$pages = $this->wire()->pages; 
		$path = $a['path'];
		
		if($options['parent']) {
			// parent specified in options
			if($options['parent'] instanceof Page) {
				$parent = $options['parent'];
			} else if(ctype_digit("$options[parent]")) {
				$parent = $pages->get((int) $options['parent']);
			} else {
				$parent = $pages->get('/' . ltrim($options['parent'], '/'));
			}
			if($parent->id) {
				$options['changeParent'] = true;
				$path = $parent->path . $a['settings']['name'] . '/';
				$a['path'] = $path;
			} else {
				$errors[] = "Specified parent does not exist: $options[parent]";
			}
		} else if(strrpos($path, '/')) {
			// determine parent from imported page path
			$parts = explode('/', trim($path, '/'));
			array_pop($parts); // pop off name
			$parentPath = '/' . implode('/', $parts);
			if(strlen($parentPath) > 1) $parentPath .= '/';
			if(isset($options['replaceParents'][$parentPath])) {
				$parentPath = $options['replaceParents'][$parentPath];
			}
			$parent = $pages->get($parentPath);
			if(!$parent->id) {
				$foundParent = false;
				if(!$options['commit']) {
					// check if the parent will be created by the import
					if(isset($previousPaths[$parentPath])) {
						$foundParent = true; 
					}
				}
				if(!$foundParent) {
					$errors[] = "Unable to locate parent page: $parentPath";
					$usePrevious = false;
				}
			}
		} else if($path === '/') {
			// homepage, parent is not applicable
			$parent = new NullPage();
		} else {
			// parent cannot be determined
			$parent = new NullPage();
			$errors[] = "Unable to determine parent";
		}
		
		if($parent->id) {
			$options['parent'] = $parent;
		}

		if($usePrevious){
			$key = rtrim($path, '/');
			if($key) $previousPaths[$path] = true;
		}
		
		return $parent;
	}

	/**
	 * Import native page settings
	 * 
	 * @param Page $page
	 * @param array $settings Contents of the import data 'settings' array
	 * @param array $options
	 * 
	 */
	protected function importPageSettings(Page $page, array $settings, array $options) {
		
		$isNew = $page->get('_importType') == 'create';
		
		// we don't currently allow template changes on existing pages	
		if(!$isNew) $options['changeTemplate'] = false;
		$template = $options['template'];
		$parent = $options['parent'];
		$languages = $this->wire()->languages;
		$langProperties = array();

		// populate page base settings
		if($options['changeTemplate'] || $isNew) {
			if(!$page->template || $page->template->name != $template->name) $page->template = $template;
		}
		if($options['changeParent'] || $isNew) {
			if($parent && $page->parent->id != $parent->id) $page->parent = $parent;
		}
		if($options['changeStatus'] || $isNew) {
			if($page->status != $settings['status']) $page->status = $settings['status'];
			$langProperties[] = 'status';
		}
		if($options['changeName'] || $isNew) {
			if($page->name != $settings['name']) $page->name = $settings['name'];
			$langProperties[] = 'name';
		}
		if($options['changeSort'] || $isNew) {
			if($page->sort != $settings['sort']) $page->sort = $settings['sort'];
			if($page->sortfield != $settings['sortfield']) $page->sortfield = $settings['sortfield'];
		}
		
		foreach(array('created', 'modified', 'published') as $dateType) {
			if(isset($settings[$dateType])) {
				$page->set($dateType, strtotime($settings[$dateType]));
			}
		}

		if($languages && count($langProperties)) {
			foreach($langProperties as $property) {
				foreach($languages as $language) {
					if($language->isDefault()) continue;
					$remoteKey = "{$property}_$language->name";
					$localKey = "{$property}$language->id";
					if(!isset($settings[$remoteKey])) continue;
					if($settings[$remoteKey] != $page->get($localKey)) {
						$page->set($localKey, $settings[$remoteKey]);
					}
				}
			}
		}
	}

	/**
	 * Import value for a single field
	 * 
	 * @param Page $page
	 * @param Field $field
	 * @param array|string|int|float $importValue
	 * @param array $options Looks only at 'commit' option to determine when testing 
	 * 
	 */
	protected function importFieldValue(Page $page, Field $field, $importValue, array $options) {
		
		if($field->type instanceof FieldtypeFile) {
			// file fields (cannot be accessed until page exists)
			if($page->id) {
				$this->importFileFieldValue($page, $field, $importValue, $options);
				return;
			} else if(!empty($importValue)) {
				$page->trackChange($field->name);
			}
		}
		
		$fieldtypeImportDefaults = array(
			// supports testing before commit (populates notices to returned Wire).
			'test' => false, 
			// returns the value that should set back to Page? (false=return value for notices only).
			// when false, it also indicates the Fieldtype::importValue() handles the actual commit to DB of import data.
			'returnsPageValue' => true, 
			// indicates Fieldtype::importValue() would like an 'exportValue' of the current value from Page in $options
			'requiresExportValue' => false, 
		);
		
		$fieldtypeImportOptions = array_merge($fieldtypeImportDefaults, $field->type->getImportValueOptions($field));
		
		$o = array(
			'importType' => $page->get('_importType'), 
			'system' => true,
			'caller' => $this, 
			'commit' => $options['commit'], 
			'test' => !$options['commit'],
			'originalHost' => $options['originalHost'],
			'originalRootUrl' => $options['originalRootUrl'],
		);
		
		// fake-commit for more verbose testing of certain fieldtypes
		$fakeCommit = $options['commit'] || !empty($fieldtypeImportOptions['test']);
		
		if($page->get('_importType') == 'create' && !$options['commit'] && !$fakeCommit) {
			// test import on a new page, so value will always be used
			$page->trackChange($field->name);
			return;
		}
		
		$pageValue = $page->getUnformatted($field->name);
		$exportValue = $pageValue === null || !$page->id ? null : $field->type->exportValue($page, $field, $pageValue, $o);
		
		if(is_array($importValue) && is_array($exportValue)) {
			// use regular '==' only for array comparisons
			if($exportValue == $importValue) return;
		} else {
			// use '===' for all other value comparisons
			if($exportValue === $importValue) return;
		}

		// at this point, values appear to be different
		if($fieldtypeImportOptions['requiresExportValue']) $o['exportValue'] = $exportValue;
		
		if($options['commit'] || $fakeCommit) {
			$commitException = false;
			try {
				$pageValue = $field->type->importValue($page, $field, $importValue, $o);
			} catch(\Exception $e) {
				$warning = $e->getMessage();
				$page->warning((strpos($warning, "$field:") === 0 ? '' : "$field: ") . $warning);
				if($options['commit'] && $fieldtypeImportOptions['restoreOnException'] && $page->id) {
					$commitException = true;
					try {
						$pageValue = $field->type->importValue($page, $field, $exportValue, $o);
						$page->warning("$field: Attempted to restore previous value");
					} catch(\Exception $e) {
						$commitException = true;
					}
				}
			}
			if(!$commitException) {
				if($pageValue !== null && $fieldtypeImportOptions['returnsPageValue']) {
					// @todo debug why FieldtypeTextLanguage requires a setAndSave() at this point.
					//if($field->type == 'FieldtypePageTitleLanguage' || $field->type == 'FieldtypeTextLanguage') {
					//	$page->setAndSave($field->name, $pageValue);
					// } else {
						$page->set($field->name, $pageValue);
					// }
				} else if(!$fieldtypeImportOptions['returnsPageValue']) {
					$page->trackChange("{$field->name}__");
				}
			}
			if($pageValue instanceof Wire) {
				// movie notices from the pageValue to the page
				$this->wire()->notices->move($pageValue, $page); 
			}
		} else {
			// test import on existing page, avoids actually setting value to the page
			$page->trackChange($field->name); 
		}
		
		if($options['debug']) {
			if(is_string($exportValue)) $exportValue = strlen($exportValue) . " bytes\n" . $exportValue;
			if(is_string($importValue)) $importValue = strlen($importValue) . " bytes\n" . $importValue;
			$this->message("$field->name OLD: <pre>" . htmlentities(print_r($exportValue, true)) . "</pre>", Notice::allowMarkup);
			$this->message("$field->name NEW: <pre>" . htmlentities(print_r($importValue, true)) . "</pre>", Notice::allowMarkup);
		}
	}

	/**
	 * Import a files/images field and populate to given $page
	 * 
	 * @param Page $page
	 * @param Field $field
	 * @param array $data Export value of file field
	 * @param array $options
	 * 
	 */
	protected function importFileFieldValue(Page $page, Field $field, array $data, array $options = array()) {
		
		// Expected format of given $data argument: 
		// $data = [
		//      'file1.jpg' => [
		//          'url' => 'http://domain.com/site/assets/files/123/file1.jpg',
		//          'description' => 'file description',
		//          'tags' => 'file tags',
		//          'variations' => [ 'file1.260x0.jpg' => 'http://domain.com/site/assets/files/123/file1.260x0.jpg' ]
		//          'filedata' => [ ... ]
		//      ],
		//      'file2.png' => [ ... see above ... ],
		//      'file3.gif' => [ ... see above ... ],
		// ];

		/** @var FieldtypeFile|FieldtypeImage $fieldtype */
		$fieldtype = $field->type;
		$pagefiles = $page->get($field->name); 
		if(!$pagefiles instanceof Pagefiles) {
			$page->warning("Unable to import files to field '$field->name' because it is not a files field"); 
			return;
		}
		
		$filesAdded = array();
		$filesUpdated = array();
		$filesRemoved = array();
		$variationsAdded = array();
		
		$maxFiles = (int) $field->get('maxFiles'); 
		$languages = $this->wire()->languages; 
		$filesPath = $pagefiles->path();
		/** @var null|WireHttp $http */
		$http = null; 
		$pageID = $page->get('_importOriginalID'); 
		
		foreach($data as $fileName => $fileInfo) {
		
			/** @var Pagefile $pagefile */
			$pagefile = $pagefiles->get($fileName); 
			$isNew = false;
			
			if(!$pagefile) {
				// new file, needs to be added
				$isNew = true;
				try {
					if($options['commit']) {
						if(empty($options['filesPath'])) {
							// importing from ZIP where files are located under filesPath option
							$pagefiles->add($fileInfo['url']);
						} else {
							// importing from URL
							$pagefiles->add("$options[filesPath]$pageID/$fileName");
						}
						$pagefile = $pagefiles->last();
						if(!$pagefile) throw new WireException("Unable to add file $fileInfo[url]");
						if($maxFiles === 1 && $pagefiles->count() > 1) {
							$pagefiles->remove($pagefiles->first()); // file replacement
						}
					} else {
						$pagefile = null;
					}
					$filesAdded[] = $fileName;
				} catch(\Exception $e) {
					$page->warning($e->getMessage()); 	
					$pagefile = null;
				}
				if(!$pagefile) continue;
			}
			
			$pagefile->setTrackChanges(true);
			$variations = array();
			
			// description, tags, etc. 
			foreach($fileInfo as $key => $value) {
				if($key == 'url') continue;
				if($key == 'size') continue;
				if($key == 'variations') {
					$variations = $value;
					continue;
				}
				if($key == 'description') {
					$oldValue = $languages ? $pagefile->description(true, true) : $pagefile->get('description');
				} else {
					$oldValue = $pagefile->get($key);
				}
				if($value == $oldValue) {
					continue; // no differences
				}
				if(empty($value) && empty($oldValue)) {
					continue; // no differences
				}
				if($key == 'description') {
					$pagefile->description($value);
					if(!$pagefile->isChanged($key)) continue;
				} else if($key === 'filedata') {
					$pagefile->filedata($fieldtype->importFiledata($page, $value, $options));
				} else if($options['commit']) {
					$pagefile->set($key, $value);
					if(!$pagefile->isChanged($key)) continue;
				}
				if(!isset($filesUpdated[$key])) $filesUpdated[$key] = array();
				if(!$isNew) {
					$filesUpdated[$key][] = $fileName;
					if($options['debug']) {
						$this->message("$field->name: $pagefile->name ($key) OLD: <pre>" . 
							print_r($oldValue, true) . "</pre>", Notice::allowMarkup);
						$this->message("$field->name: $pagefile->name ($key) NEW: <pre>" . 
							print_r($value, true) . "</pre>", Notice::allowMarkup); 
					}
				}
			}
		
			// image variations
			foreach($variations as $name => $url) {
				
				$targetFile = $filesPath . $name;
				$sourceFile = empty($options['filesPath']) ? '' : "$options[filesPath]$pageID/$name";
				$targetExists = file_exists($targetFile); 
				$sourceExists = $sourceFile ? file_exists($sourceFile) : false;
				
				if($sourceExists && $targetExists) {
					// skip because they are likely the same
					if(filesize($sourceFile) == filesize($targetFile)) continue; 
				} else if($targetExists) {
					// target already exists so skip it (since we don't have a way to check size)
					continue; 	
				}
				
				if(!$options['commit']) {
					$variationsAdded[] = $name;
					continue; 
				}
				
				if($sourceExists) {
					// copy variation from options[filesPath]
					if($this->wire()->files->copy($sourceFile, $targetFile)) {
						$variationsAdded[] = $name;
					} else {
						$page->warning("Unable to copy file (image variation): $sourceFile");
					}
				} else {
					// download variation via http
					try {
						if(is_null($http)) $http = $this->wire(new WireHttp());
						$http->download($url, $targetFile);
						$variationsAdded[] = $name;
					} catch(\Exception $e) {
						$page->warning("Error downloading file (image variation): $url - " . $e->getMessage());
					}
				}
			}
		}
	
		// determine removed files
		foreach($pagefiles as $pagefile) {
			if(isset($data[$pagefile->name])) continue; 
			$filesRemoved[] = $pagefile->name; 
			if($options['commit']) $pagefiles->remove($pagefile); 
		}
	
		// summarize all of the above
		$numAdded = count($filesAdded);
		$numUpdated = count($filesUpdated);
		$numRemoved = count($filesRemoved); 
		$numVariations = count($variationsAdded); 
		$numTotal = $numAdded + $numUpdated + $numRemoved; // intentionally excludes numVariations
		
		if($numTotal > 0) {
			$pagefiles->trackChange('value');
			if($options['commit']) $page->set($field->name, $pagefiles); 
			$page->trackChange($field->name);
			if($numAdded) $page->message("$field->name: " . 
				sprintf($this->_n('Added %d file', 'Added %d files', $numAdded), $numAdded) . ": " . 
				implode(', ', $filesAdded)
			); 
			if($numUpdated) {
				foreach($filesUpdated as $property => $files) {
					$numFiles = count($files); 
					$page->message("$field->name: " . 
						sprintf($this->_n('Updated %s for %d file', 'Updated %s for %d files', $numFiles), $property, $numFiles) . ': ' . 
						implode(', ', $files)
					);
				}
			}
			if($numRemoved) $page->message("$field->name: " . 
				sprintf($this->_n('Removed %d file', 'Removed %d files', $numRemoved), $numRemoved) . ": " . 
				implode(', ', $filesRemoved)
			); 
		}
		
		if($numVariations) {
			$addedType = $http === null ? 'ZIP copy' : 'HTTP download'; 
			$page->trackChange($field->name); 
			$page->message("$field->name (variation): " .
				sprintf(
					$this->_n('Added %d file via %s', 'Added %d files via %s', $numVariations), 
					$numVariations, $addedType
				) . ": " . implode(', ', $variationsAdded)
			); 
		}
	}
	/**
	 * Return array of info about the given import data array
	 * 
	 * This also populates the given import data ($a) with an '_info' property, which is an array containing 
	 * all of the import info returned by this method. For each item in the 'pages' index it also populates
	 * an '_importToID' property containing the ID of the existing local page to update, or 0 if it should be
	 * a newly created page. 
	 *
	 * Return value:
	 * ~~~~~
	 * array(
	 *   'numNew' => 0,
	 *   'numExisting' => 0,
	 *   'missingParents' => [ '/path/to/parent/' ],
	 *   'missingTemplates' => [ 'basic-page-hello' ],
	 *   'missingFields' => [ 'some_field', 'another_field' ],
	 *   'missingFieldsTypes' => [ 'some_field' => 'FieldtypeText', 'another_field' => 'FieldtypeTextarea' ]
	 *   'mismatchedFields' => [ 'some_field' => 'FieldtypeText' ] // field name => expected type
	 *   'missingTemplateFields' => [ 'template_name' => [ 'field1', 'field2', etc ] ]
	 * );
	 * ~~~~~
	 * 
	 * #pw-group-import
	 *
	 * @param array $a Import data array
	 * @return array
	 *
	 */
	public function getImportInfo(array &$a) {

		$missingTemplateFields = array();
		$missingFieldsTypes = array();
		$missingTemplates = array();
		$mismatchedFields = array();
		$missingParents = array();
		$missingFields = array();
		$templateNames = array();
		$parentPaths = array();
		$pagePaths = array();
		$numExisting = 0;
		$numNew = 0;

		$pages = $this->wire()->pages;
		$fields = $this->wire()->fields;
		$sanitizer = $this->wire()->sanitizer;
		$pageFinder = $this->wire(new PageFinder());
		
		// Identify missing fields
		foreach($a['fields'] as $fieldName => $fieldInfo) {
			// Note: $fieldInfo [ 'type' => 'FieldtypeText', 'version' => '1.0.0', 'blankValue' => '' ]
			$field = $fields->get($fieldName);
			if(!$field) {
				$missingFields[] = $fieldName;
				$missingFieldsTypes[$fieldName] = $fieldInfo['type'];
			} else if($fieldInfo['type'] != $field->type->className()) {
				$mismatchedFields[$fieldName] = $fieldInfo['type'];
			}
		}

		// Determine which pages are new and which are existing
		foreach($a['pages'] as $key => $item) {
			$path = $sanitizer->pagePathNameUTF8($item['path']);
			if($item['path'] !== $path) continue; 
			$pagePaths[$path] = $item['settings']['id'];
			if($path != '/') {
				$parts = explode('/', trim($path, '/'));
				array_pop($parts);
				$parentPath = '/' . implode('/', $parts);
				if(count($parts)) $parentPath .= '/';
				$parentPaths[$parentPath] = $parentPath;
			}
			$templateName = $item['template'];
			if(!isset($templateNames[$templateName])) {
				$templateNames[$templateName] = array_keys($item['data']);
			}
			
			$pageIDs = $pageFinder->findIDs(new Selectors("path=$path, include=all")); 
			
			if(!count($pageIDs)) {
				// no match
				$pageID = 0;
			} else if(count($pageIDs) > 1) {
				// more than one match, use another method
				$pageID = $pages->get($path)->id;
			} else {
				// found
				$pageID = reset($pageIDs); 
			}
			
			$a['pages'][$key]['_importToID'] = $pageID; // populate local ID 
			$pageID ? $numExisting++ : $numNew++;
		}

		// determine which templates are missing, and which fields are missing from templates
		foreach($templateNames as $templateName => $fieldNames) {
			$template = $this->wire()->templates->get($templateName);
			if($template) {
				// template exists
				$missingTemplateFields[$templateName] = array();
				foreach($fieldNames as $fieldName) {
					if(isset($missingFields[$fieldName]) || !$template->hasField($fieldName)) {
						$missingTemplateFields[$templateName][] = $fieldName;
					}
				}
			} else {
				// template does not exist
				$missingTemplates[] = $templateName;
			}
		}

		// determine which parents are missing
		foreach($parentPaths as /* $key => */ $path) {
			if(isset($pagePaths[$path])) {
				// this parent already exists or will be created during import
			} else {
				$parentID = $pages->getByPath($path, array('getID' => true));
				if(!$parentID) $missingParents[] = $path;
			}
		}
		/*
		foreach($missingParents as $key => $path) {
			// remove parents that are children of another missing parent
			foreach($missingParents as $k => $p) {
				if($key === $k) continue;
				if(strlen($path) > strlen($p)) {
					if(strpos($path, $p) === 0) unset($missingParents[$key]); 
				} else {
					if(strpos($p, $path) === 0) unset($missingParents[$k]);
				}
				
			}
		}
		*/

		$info = array(
			'numNew' => $numNew,
			'numExisting' => $numExisting,
			'missingParents' => $missingParents,
			'missingFields' => $missingFields,
			'missingFieldsTypes' => $missingFieldsTypes,
			'mismatchedFields' => array(),
			'missingTemplates' => $missingTemplates,
			'missingTemplateFields' => $missingTemplateFields
		);
		
		$a['_info'] = $info;
		
		return $info;
	}

	/**
	 * Returns array of information about given Field
	 * 
	 * Populates the following indexes in the returned array:
	 * 
	 *  - `exportable` (bool): True if field is exportable, false if not. 
	 *  - `reason` (string): Reason why field is not exportable (when exportable==false).
	 * 
	 * #pw-group-export
	 * 
	 * @param Field $field
	 * @return array
	 * 
	 */
	public function getFieldInfo(Field $field) {
		
		static $cache = array();
		
		if(isset($cache[$field->id])) return $cache[$field->id];
		
		$fieldtype = $field->type;
		$exportable = true;
		$reason = '';
		
		$extraType = wireInstanceOf($fieldtype, array(
			'FieldtypeFile',
			'FieldtypeRepeater',
			'FieldtypeComments',
		));
		
		if($extraType) {
			// extra identified types are allowed
			
		} else if($fieldtype instanceof FieldtypeFieldsetOpen || $fieldtype instanceof FieldtypeFieldsetClose) {
			// fieldsets not exportable
			$reason = 'Nothing to export/import for fieldsets';
			$exportable = false;
			
		} else {
			// test to see if exportable
			try {
				$importInfo = $fieldtype->getImportValueOptions($field); 
			} catch(\Exception $e) {
				$exportable = false;
				$reason = $e->getMessage();
				$importInfo = false;
			}

			if($exportable && $importInfo && !$importInfo['importable']) {
				// this fieldtype is storing data outside of the DB or in other unknown tables
				// there's a good chance we won't be able to export/import this into an array
				// @todo check if fieldtype implements its own exportValue/importValue, and if
				// it does then allow the value to be exported
				$exportable = false;
				$reason = "Field '$field' cannot be used because $field->type indicates imports are not supported";
			}
		}
		
		if(!$exportable && empty($reason)) $reason = 'Export/import not supported';

		$info = array(
			'exportable' => $exportable,
			'reason' => $reason,
		);

		$cache[$field->id] = $info;
		
		return $info;
	}

}
