<?php
/**
 * @author Stephan Gambke
 * @file
 * @ingroup PageForms
 */

/**
 * @ingroup PageForms
 */
class PFAutoeditAPI extends ApiBase {

	const ACTION_FORMEDIT = 0;
	const ACTION_SAVE = 1;
	const ACTION_PREVIEW = 2;
	const ACTION_DIFF = 3;

	/**
	 * Error level used when a non-recoverable error occurred.
	 */
	const ERROR = 0;

	/**
	 * Error level used when a recoverable error occurred.
	 */
	const WARNING = 1;

	/**
	 * Error level used to give information that might be of interest to the user.
	 */
	const NOTICE = 2;

	/**
	 * Error level used for debug messages.
	 */
	const DEBUG = 3;

	private $mOptions = array();
	private $mAction;
	private $mStatus;

	/**
	 * Converts an options string into an options array and stores it
	 *
	 * @param string $options
	 * @return the options array
	 */
	function addOptionsFromString( $options ) {
		return $this->parseDataFromQueryString( $this->mOptions, $options );
	}

	/**
	 * @return array
	 */
	function getOptions() {
		return $this->mOptions;
	}

	/**
	 * Returns the action performed by the module.
	 *
	 * Return value is either null or one of ACTION_SAVE, ACTION_PREVIEW,
	 * ACTION_FORMEDIT
	 *
	 * @return null|number
	 */
	function getAction() {
		return $this->mAction;
	}

	/**
	 * @param array $options
	 */
	function setOptions( $options ) {
		$this->mOptions = $options;
	}

	/**
	 * @param string $option
	 * @param mixed $value
	 */
	function setOption( $option, $value ) {
		$this->mOptions[$option] = $value;
	}

	/**
	 * Returns the HTTP status
	 *
	 * 200 - ok
	 * 400 - error
	 *
	 * @return number
	 */
	function getStatus() {
		return $this->mStatus;
	}

	/**
	 * Evaluates the parameters, performs the requested API query, and sets up
	 * the result.
	 *
	 * The execute() method will be invoked when an API call is processed.
	 *
	 * The result data is stored in the ApiResult object available through
	 * getResult().
	 */
	function execute() {
		$this->prepareAction();

		try {
			$this->doAction();
		} catch ( MWException $e ) {
			$this->logMessage( $e->getMessage(), $e->getCode() );
		}

		$this->finalizeResults();
		$this->setHeaders();
	}

	/**
	 *
	 */
	function prepareAction() {
		// Get options from the request, but keep the explicitly set options.
		$data = $this->getRequest()->getValues();
		$this->mOptions = PFUtils::array_merge_recursive_distinct( $data, $this->mOptions );

		global $wgParser;
		if ( $wgParser === null ) {
			$wgParser = new Parser();
		}

		$wgParser->startExternalParse(
			null,
			ParserOptions::newFromUser( $this->getUser() ),
			Parser::OT_WIKI
		);

		// MW uses the parameter 'title' instead of 'target' when submitting
		// data for formedit action => use that
		if ( !array_key_exists( 'target', $this->mOptions ) && array_key_exists( 'title', $this->mOptions ) ) {
			$this->mOptions['target'] = $this->mOptions['title'];
			unset( $this->mOptions['title'] );
		}

		// if the 'query' parameter was used, unpack the param string
		if ( array_key_exists( 'query', $this->mOptions ) ) {
			$this->addOptionsFromString( $this->mOptions['query'] );
			unset( $this->mOptions['query'] );
		}

		// if an action is explicitly set in the form data, use that
		if ( array_key_exists( 'wpSave', $this->mOptions ) ) {
			// set action to 'save' if requested
			$this->mAction = self::ACTION_SAVE;
			unset( $this->mOptions['wpSave'] );
		} elseif ( array_key_exists( 'wpPreview', $this->mOptions ) ) {
			// set action to 'preview' if requested
			$this->mAction = self::ACTION_PREVIEW;
			unset( $this->mOptions['wpPreview'] );
		} elseif ( array_key_exists( 'wpDiff', $this->mOptions ) ) {
			// set action to 'preview' if requested
			$this->mAction = self::ACTION_DIFF;
			unset( $this->mOptions['wpDiff'] );
		} elseif ( array_key_exists( 'action', $this->mOptions ) ) {
			switch ( $this->mOptions['action'] ) {
				case 'pfautoedit' :
					$this->mAction = self::ACTION_SAVE;
					break;
				case 'preview' :
					$this->mAction = self::ACTION_PREVIEW;
					break;
				default :
					$this->mAction = self::ACTION_FORMEDIT;
			}
		} else {
			// set default action
			$this->mAction = self::ACTION_FORMEDIT;
		}

		$hookQuery = null;

		// ensure 'form' key exists
		if ( array_key_exists( 'form', $this->mOptions ) ) {
			$hookQuery = $this->mOptions['form'];
		} else {
			$this->mOptions['form'] = '';
		}

		// ensure 'target' key exists
		if ( array_key_exists( 'target', $this->mOptions ) ) {
			if ( $hookQuery !== null ) {
				$hookQuery .= '/' . $this->mOptions['target'];
			}
		} else {
			$this->mOptions['target'] = '';
		}

		// Normalize form and target names

		$form = Title::newFromText( $this->mOptions['form'] );
		if ( $form !== null ) {
			$this->mOptions['form'] = $form->getPrefixedText();
		}

		$target = Title::newFromText( $this->mOptions['target'] );
		if ( $target !== null ) {
			$this->mOptions['target'] = $target->getPrefixedText();
		}

		Hooks::run( 'PageForms::SetTargetName', array( &$this->mOptions['target'], $hookQuery ) );

		// set html return status. If all goes well, this will not be changed
		$this->mStatus = 200;
	}

	/**
	 * Get the Title object of a form suitable for editing the target page.
	 *
	 * @return Title
	 * @throws MWException
	 */
	protected function getFormTitle() {
		// if no form was explicitly specified, try for explicitly set alternate forms
		if ( $this->mOptions['form'] === '' ) {
			$this->logMessage( 'No form specified. Will try to find the default form for the target page.', self::DEBUG );

			$formNames = array();

			// try explicitly set alternative forms
			if ( array_key_exists( 'alt_form', $this->mOptions ) ) {
				$formNames = (array)$this->mOptions['alt_form']; // cast to array to make sure we get an array, even if only a string was sent

			}

			// if no alternate forms were explicitly set, try finding a default form for the target page
			if ( count( $formNames ) === 0 ) {
				// if no form and and no alt forms and no target page was specified, give up
				if ( $this->mOptions['target'] === '' ) {
					throw new MWException( wfMessage( 'pf_autoedit_notargetspecified' )->parse() );
				}

				$targetTitle = Title::newFromText( $this->mOptions['target'] );

				// if the specified target title is invalid, give up
				if ( !$targetTitle instanceof Title ) {
					throw new MWException( wfMessage( 'pf_autoedit_invalidtargetspecified', $this->mOptions['target'] )->parse() );
				}

				$formNames = PFFormLinker::getDefaultFormsForPage( $targetTitle );
				if ( count( $formNames ) === 0 ) {
					throw new MWException( wfMessage( 'pf_autoedit_noformfound' )->parse() );
				}

			}

			// if more than one form was found, issue a notice and give up
			// this happens if no default form but several alternate forms are defined
			if ( count( $formNames ) > 1 ) {
				throw new MWException( wfMessage( 'pf_autoedit_toomanyformsfound' )->parse(), self::DEBUG );
			}

			$this->mOptions['form'] = $formNames[0];

			$this->logMessage( 'Using ' . $this->mOptions['form'] . ' as default form.', self::DEBUG );
		}

		$formTitle = Title::makeTitleSafe( PF_NS_FORM, $this->mOptions['form'] );

		// If the given form is not a valid title, give up.
		if ( !( $formTitle instanceof Title ) ) {
			throw new MWException( wfMessage( 'pf_autoedit_invalidform', $this->mOptions['form'] )->parse() );
		}

		// If the form page is a redirect, follow the redirect.
		if ( $formTitle->isRedirect() ) {
			$this->logMessage( 'Form ' . $this->mOptions['form'] . ' is a redirect. Finding target.', self::DEBUG );

			$formWikiPage = WikiPage::factory( $formTitle );
			$formTitle = $formWikiPage->getContent( Revision::RAW )->getUltimateRedirectTarget();

			// if we exeeded $wgMaxRedirects or encountered an invalid redirect target, give up
			if ( $formTitle->isRedirect() ) {
				$newTitle = WikiPage::factory( $formTitle )->getRedirectTarget();

				if ( $newTitle instanceof Title && $newTitle->isValidRedirectTarget() ) {
					throw new MWException( wfMessage( 'pf_autoedit_redirectlimitexeeded', $this->mOptions['form'] )->parse() );
				} else {
					throw new MWException( wfMessage( 'pf_autoedit_invalidredirecttarget', $newTitle->getFullText(), $this->mOptions['form'] )->parse() );
				}
			}
		}

		// if specified or found form does not exist (e.g. is a red link), give up
		// FIXME: Throw specialized error message, so a list of alternative forms can be shown
		if ( !$formTitle->exists() ) {
			throw new MWException( wfMessage( 'pf_autoedit_invalidform', $this->mOptions['form'] )->parse() );
		}

		return $formTitle;
	}

	protected function setupEditPage( $targetContent ) {
		// Find existing target article if it exists, or create a new one.
		$targetTitle = Title::newFromText( $this->mOptions['target'] );

		// If the specified target title is invalid, give up.
		if ( !$targetTitle instanceof Title ) {
			throw new MWException( wfMessage( 'pf_autoedit_invalidtargetspecified', $this->mOptions['target'] )->parse() );
		}

		$article = new Article( $targetTitle );

		// set up a normal edit page
		// we'll feed it our data to simulate a normal edit
		$editor = new EditPage( $article );

		// set up form data:
		// merge data coming from the web request on top of some defaults
		$data = array_merge(
				array(
					'wpTextbox1' => $targetContent,
					'wpSummary' => '',
					'wpStarttime' => wfTimestampNow(),
					'wpEdittime' => '',
					'wpEditToken' => isset( $this->mOptions[ 'token' ] ) ? $this->mOptions[ 'token' ] : $this->getUser()->getEditToken(),
					'action' => 'submit',
				),
				$this->mOptions
		);

		if ( array_key_exists( 'format', $data ) ) {
			unset( $data['format'] );
		}

		// set up a faux request with the simulated data
		$request = new FauxRequest( $data, true );

		// and import it into the edit page
		$editor->importFormData( $request );
		$editor->pfFauxRequest = $request;

		return $editor;
	}

	/**
	 * Sets the output HTML of wgOut as the module's result
	 */
	protected function setResultFromOutput() {
		// turn on output buffering
		ob_start();

		// generate preview document and write it to output buffer
		$this->getOutput()->output();

		// retrieve the preview document from output buffer
		$targetHtml = ob_get_contents();

		// clean output buffer, so MW can use it again
		ob_clean();

		// store the document as result
		$this->getResult()->addValue( null, 'result', $targetHtml );
	}

	protected function doPreview( $editor ) {
		global $wgOut;

		$previewOutput = $editor->getPreviewText();

		Hooks::run( 'EditPage::showEditForm:initial', array( &$editor, &$wgOut ) );

		$this->getOutput()->addStyle( 'common/IE80Fixes.css', 'screen', 'IE 8' );
		$this->getOutput()->setRobotPolicy( 'noindex,nofollow' );

		// This hook seems slightly odd here, but makes things more
		// consistent for extensions.
		Hooks::run( 'OutputPageBeforeHTML', array( &$wgOut, &$previewOutput ) );

		$this->getOutput()->addHTML( Html::rawElement( 'div', array( 'id' => 'wikiPreview' ), $previewOutput ) );

		$this->setResultFromOutput();
	}

	protected function doDiff( $editor ) {
		$editor->showDiff();
		$this->setResultFromOutput();
	}

	protected function doStore( EditPage $editor ) {
		$title = $editor->getTitle();

		// If they used redlink=1 and the page exists, redirect to the main article and send notice
		if ( $this->getRequest()->getBool( 'redlink' ) && $title->exists() ) {
			$this->logMessage( wfMessage( 'pf_autoedit_redlinkexists' )->parse(), self::WARNING );
		}

		$permErrors = $title->getUserPermissionsErrors( 'edit', $this->getUser() );

		// if this title needs to be created, user needs create rights
		if ( !$title->exists() ) {
			$permErrors = array_merge( $permErrors, wfArrayDiff2( $title->getUserPermissionsErrors( 'create', $this->getUser() ), $permErrors ) );
		}

		if ( $permErrors ) {
			// Auto-block user's IP if the account was "hard" blocked
			$this->getUser()->spreadAnyEditBlock();

			foreach ( $permErrors as $error ) {
				$this->logMessage( call_user_func_array( 'wfMessage', $error )->parse() );
			}

			return;
		}

		$resultDetails = false;
		# Allow bots to exempt some edits from bot flagging
		$bot = $this->getUser()->isAllowed( 'bot' ) && $editor->bot;

		$request = $editor->pfFauxRequest;
		if ( $editor->tokenOk( $request ) ) {
			$ctx = RequestContext::getMain();
			$tempTitle = $ctx->getTitle();
			$ctx->setTitle( $title );
			$status = $editor->internalAttemptSave( $resultDetails, $bot );
			$ctx->setTitle( $tempTitle );
		} else {
			throw new MWException( wfMessage( 'session_fail_preview' )->parse() );
		}

		switch ( $status->value ) {
			case EditPage::AS_HOOK_ERROR_EXPECTED: // A hook function returned an error

				// show normal Edit page

				// remove Preview and Diff standard buttons from editor page
				Hooks::register( 'EditPageBeforeEditButtons', function ( &$editor, &$buttons, &$tabindex ){
					foreach ( array_keys( $buttons ) as $key ) {
						if ( $key !== 'save' ) {
							unset( $buttons[$key] );
						}
					}
				} );

				// Context title needed for correct Cancel link
				$editor->setContextTitle( $title );

				$editor->showEditForm();
				return false; // success

			case EditPage::AS_CONTENT_TOO_BIG: // Content too big (> $wgMaxArticleSize)
			case EditPage::AS_ARTICLE_WAS_DELETED: // article was deleted while editing and param wpRecreate == false or form was not posted
			case EditPage::AS_CONFLICT_DETECTED: // (non-resolvable) edit conflict
			case EditPage::AS_SUMMARY_NEEDED: // no edit summary given and the user has forceeditsummary set and the user is not editting in his own userspace or talkspace and wpIgnoreBlankSummary == false
			case EditPage::AS_TEXTBOX_EMPTY: // user tried to create a new section without content
			case EditPage::AS_MAX_ARTICLE_SIZE_EXCEEDED: // article is too big (> $wgMaxArticleSize), after merging in the new section
			case EditPage::AS_END: // WikiPage::doEdit() was unsuccessfull

				throw new MWException( wfMessage( 'pf_autoedit_fail', $this->mOptions['target'] )->parse() );

			case EditPage::AS_HOOK_ERROR: // Article update aborted by a hook function

				$this->logMessage( 'Article update aborted by a hook function', self::DEBUG );
				return false; // success

			case EditPage::AS_PARSE_ERROR: // Can't parse content

				throw new MWException( $status->getHTML() );
				return true; // fail

			case EditPage::AS_SUCCESS_NEW_ARTICLE: // Article successfully created

				$query = $resultDetails['redirect'] ? 'redirect=no' : '';
				$anchor = isset( $resultDetails['sectionanchor'] ) ? $resultDetails['sectionanchor'] : '';

				$redirect = $title->getFullURL( $query ) . $anchor;

				$returnto = Title::newFromText( $this->getRequest()->getText( 'returnto' ) );
				if ( $returnto !== null ) {
					$redirect = $returnto->getFullURL();
				}

				$this->getOutput()->redirect( $redirect );
				$this->getResult()->addValue( null, 'redirect', $redirect );
				return false; // success

			case EditPage::AS_SUCCESS_UPDATE: // Article successfully updated

				$extraQuery = '';
				$sectionanchor = $resultDetails['sectionanchor'];

				// Give extensions a chance to modify URL query on update
				Hooks::run( 'ArticleUpdateBeforeRedirect', array( $editor->getArticle(), &$sectionanchor, &$extraQuery ) );

				if ( $resultDetails['redirect'] ) {
					if ( $extraQuery == '' ) {
						$extraQuery = 'redirect=no';
					} else {
						$extraQuery = 'redirect=no&' . $extraQuery;
					}
				}

				$redirect = $title->getFullURL( $extraQuery ) . $sectionanchor;

				$returnto = Title::newFromText( $this->getRequest()->getText( 'returnto' ) );
				if ( $returnto !== null ) {
					$redirect = $returnto->getFullURL();
				}

				$this->getOutput()->redirect( $redirect );
				$this->getResult()->addValue( null, 'redirect', $redirect );

				return false; // success

			case EditPage::AS_BLANK_ARTICLE: // user tried to create a blank page

				$this->logMessage( 'User tried to create a blank page', self::DEBUG );

				$this->getOutput()->redirect( $editor->getContextTitle()->getFullURL() );
				$this->getResult()->addValue( null, 'redirect', $editor->getContextTitle()->getFullURL() );

				return false; // success

			case EditPage::AS_SPAM_ERROR: // summary contained spam according to one of the regexes in $wgSummarySpamRegex

				$match = $resultDetails['spam'];
				if ( is_array( $match ) ) {
					$match = $this->getLanguage()->listToText( $match );
				}

				throw new MWException( wfMessage( 'spamprotectionmatch', wfEscapeWikiText( $match ) )->parse() ); // FIXME: Include better error message

			case EditPage::AS_BLOCKED_PAGE_FOR_USER: // User is blocked from editing editor page
				throw new UserBlockedError( $this->getUser()->getBlock() );

			case EditPage::AS_IMAGE_REDIRECT_ANON: // anonymous user is not allowed to upload (User::isAllowed('upload') == false)
			case EditPage::AS_IMAGE_REDIRECT_LOGGED: // logged in user is not allowed to upload (User::isAllowed('upload') == false)
				throw new PermissionsError( 'upload' );

			case EditPage::AS_READ_ONLY_PAGE_ANON: // editor anonymous user is not allowed to edit editor page
			case EditPage::AS_READ_ONLY_PAGE_LOGGED: // editor logged in user is not allowed to edit editor page
				throw new PermissionsError( 'edit' );

			case EditPage::AS_READ_ONLY_PAGE: // wiki is in readonly mode (wfReadOnly() == true)
				throw new ReadOnlyError;

			case EditPage::AS_RATE_LIMITED: // rate limiter for action 'edit' was tripped
				throw new ThrottledError();

			case EditPage::AS_NO_CREATE_PERMISSION: // user tried to create editor page, but is not allowed to do that ( Title->usercan('create') == false )
				$permission = $title->isTalkPage() ? 'createtalk' : 'createpage';
				throw new PermissionsError( $permission );

			default:
				// We don't recognize $status->value. The only way that can happen
				// is if an extension hook aborted from inside ArticleSave.
				// Render the status object into $editor->hookError
				$editor->hookError = '<div class="error">' . $status->getWikitext() . '</div>';
				throw new MWException( $status->getHTML() );
		}
	}

	protected function finalizeResults() {
		// set response text depending on the status and the requested action
		if ( $this->mStatus === 200 ) {
			if ( array_key_exists( 'ok text', $this->mOptions ) ) {
				$responseText = MessageCache::singleton()->parse( $this->mOptions['ok text'], Title::newFromText( $this->mOptions['target'] ) )->getText();
			} elseif ( $this->mAction === self::ACTION_SAVE ) {
				$responseText = wfMessage( 'pf_autoedit_success', $this->mOptions['target'], $this->mOptions['form'] )->parse();
			} else {
				$responseText = null;
			}
		} else {
			// get errortext (or use default)
			if ( array_key_exists( 'error text', $this->mOptions ) ) {
				$responseText = MessageCache::singleton()->parse( $this->mOptions['error text'], Title::newFromText( $this->mOptions['target'] ) )->getText();
			} elseif ( $this->mAction === self::ACTION_SAVE ) {
				$responseText = wfMessage( 'pf_autoedit_fail', $this->mOptions['target'] )->parse();
			} else {
				$responseText = null;
			}
		}

		$result = $this->getResult();

		if ( $responseText !== null ) {
			$result->addValue( null, 'responseText', $responseText );
		}

		$result->addValue( null, 'status', $this->mStatus, true );
		$result->addValue( array( 'form' ), 'title', $this->mOptions['form'] );
		$result->addValue( null, 'target', $this->mOptions['target'], true );
	}

	/**
	 * Set custom headers to attach to the answer
	 */
	protected function setHeaders() {
		if ( !headers_sent() ) {
			header( 'X-Status: ' . $this->mStatus, true, $this->mStatus );
			header( 'X-Form: ' . $this->mOptions['form'] );
			header( 'X-Target: ' . $this->mOptions['target'] );

			$redirect = $this->getOutput()->getRedirect();
			if ( $redirect ) {
				header( 'X-Location: ' . $redirect );
			}
		}
	}

	/**
	 * Generates a target name from the given target name formula
	 *
	 * This parses the formula and replaces &lt;unique number&gt; tags
	 *
	 * @param type $targetNameFormula
	 *
	 * @throws MWException
	 * @return type
	 */
	protected function generateTargetName( $targetNameFormula ) {
		$targetName = $targetNameFormula;

		// Prepend a super-page, if one was specified.
		if ( $this->getRequest()->getCheck( 'super_page' ) ) {
			$targetName = $this->getRequest()->getVal( 'super_page' ) . '/' . $targetName;
		}

		// Prepend a namespace, if one was specified.
		if ( $this->getRequest()->getCheck( 'namespace' ) ) {
			$targetName = $this->getRequest()->getVal( 'namespace' ) . ':' . $targetName;
		}

		// replace "unique number" tag with one that won't get erased by the next line
		$targetName = preg_replace( '/<unique number(.*)>/', '{num\1}', $targetName, 1 );

		// If any formula stuff is still in the name after the parsing,
		// just remove it.
		// FIXME: This is wrong. If anything is still left, something
		// should have been present in the form and wasn't. An error
		// should be raised.
		// $targetName = StringUtils::delimiterReplace( '<', '>', '', $targetName );

		// Replace spaces back with underlines, in case a magic word or
		// parser function name contains underlines - hopefully this
		// won't cause problems of its own.
		$targetName = str_replace( ' ', '_', $targetName );

		// now run the parser on it
		global $wgParser, $wgTitle;
		$targetName = $wgParser->transformMsg( $targetName, new ParserOptions(), $wgTitle );

		$titleNumber = '';
		$isRandom = false;
		$randomNumHasPadding = false;
		$randomNumDigits = 6;

		if ( preg_match( '/{num.*}/', $targetName, $matches ) && strpos( $targetName, '{num' ) !== false ) {
			// Random number
			if ( preg_match( '/{num;random(;(0)?([1-9][0-9]*))?}/', $targetName, $matches ) ) {
				$isRandom = true;
				$randomNumHasPadding = array_key_exists( 2, $matches );
				$randomNumDigits = ( array_key_exists( 3, $matches ) ? $matches[3] : $randomNumDigits );
				$titleNumber = self::makeRandomNumber( $randomNumDigits, $randomNumHasPadding );
			} elseif ( preg_match( '/{num.*start[_]*=[_]*([^;]*).*}/', $targetName, $matches ) ) {;				// get unique number start value
				// from target name; if it's not
				// there, or it's not a positive
				// number, start it out as blank
				if ( count( $matches ) == 2 && is_numeric( $matches[1] ) && $matches[1] >= 0 ) {
					// the "start" value"
					$titleNumber = $matches[1];
				}
			} elseif ( preg_match( '/^(_?{num.*}?)*$/', $targetName, $matches ) ) {
				// the target name contains only underscores and number fields,
				// i.e. would result in an empty title without the number set
				$titleNumber = '1';
			} else {
				$titleNumber = '';
			}

			// set target title
			$targetTitle = Title::newFromText( preg_replace( '/{num.*}/', $titleNumber, $targetName ) );

			// if the specified target title is invalid, give up
			if ( !$targetTitle instanceof Title ) {
				throw new MWException( wfMessage( 'pf_autoedit_invalidtargetspecified', trim( preg_replace( '/<unique number(.*)>/', $titleNumber, $targetNameFormula ) ) )->parse() );
			}

			// If title exists already, cycle through numbers for
			// this tag until we find one that gives a nonexistent
			// page title.
			// We cannot use $targetTitle->exists(); it does not use
			// Title::GAID_FOR_UPDATE, which is needed to get
			// correct data from cache; use
			// $targetTitle->getArticleID() instead.
			$numAttemptsAtTitle = 0;
			while ( $targetTitle->getArticleID( Title::GAID_FOR_UPDATE ) !== 0 ) {
				$numAttemptsAtTitle++;

				if ( $isRandom ) {
					// If the set of pages is "crowded"
					// already, go one digit higher.
					if ( $numAttemptsAtTitle > 20 ) {
						$randomNumDigits++;
					}
					$titleNumber = self::makeRandomNumber( $randomNumDigits, $randomNumHasPadding );
				} elseif ( $titleNumber == "" ) {
					// If title number is blank, change it to 2;
					// otherwise, increment it, and if necessary
					// pad it with leading 0s as well.
					$titleNumber = 2;
				} else {
					$titleNumber = str_pad( $titleNumber + 1, strlen( $titleNumber ), '0', STR_PAD_LEFT );
				}

				$targetTitle = Title::newFromText( preg_replace( '/{num.*}/', $titleNumber, $targetName ) );
			}

			$targetName = $targetTitle->getPrefixedText();
		}

		return $targetName;
	}

	/**
	 * Helper function..
	 * @param Title $title
	 * @return string
	 */
	function getTextForPage( $title ) {
		$wikiPage = WikiPage::factory( $title );
		return $wikiPage->getContent( Revision::RAW )->getNativeData();
	}

	/**
	 * Returns a formatted (pseudo) random number
	 *
	 * @param number $numDigits the min width of the random number
	 * @param bool $hasPadding should the number should be padded with zeros instead of spaces?
	 * @return number
	 */
	static function makeRandomNumber( $numDigits = 1, $hasPadding = false ) {
		$maxValue = pow( 10, $numDigits ) - 1;
		if ( $maxValue > getrandmax() ) {
			$maxValue = getrandmax();
		}
		$value = rand( 0, $maxValue );
		$format = '%' . ( $hasPadding ? '0' : '' ) . $numDigits . 'd';
		return trim( sprintf( $format, $value ) ); // trim needed, when $hasPadding == false
	}

	/**
	 * Depending on the requested action this method will try to
	 * store/preview the data in mOptions or retrieve the edit form.
	 *
	 * The form and target page will be available in mOptions after
	 * execution of the method.
	 *
	 * Errors and warnings are logged in the API result under the 'errors'
	 * key. The general request status is maintained in mStatus.
	 *
	 * @global $wgRequest
	 * @global $wgOut
	 * @global PFFormPrinter $wgPageFormsFormPrinter
	 * @throws MWException
	 */
	public function doAction() {
		global $wgOut, $wgParser, $wgRequest, $wgPageFormsFormPrinter;

		// If the wiki is read-only, do not save.
		if ( wfReadOnly() ) {
			if ( $this->mAction === self::ACTION_SAVE ) {
				throw new MWException( wfMessage( 'pf_autoedit_readonly', wfReadOnlyReason() )->parse() );
			}

			// even if not saving notify client anyway. Might want to dislay a notice
			$this->logMessage( wfMessage( 'pf_autoedit_readonly', wfReadOnlyReason() )->parse(), self::NOTICE );
		}

		// find the title of the form to be used
		$formTitle = $this->getFormTitle();

		// get the form content
		$formContent = StringUtils::delimiterReplace(
			'<noinclude>', // start delimiter
			'</noinclude>', // end delimiter
			'', // replace by
			$this->getTextForPage( $formTitle ) // subject
		);

		// signals that the form was submitted
		// always true, else we would not be here
		$isFormSubmitted = $this->mAction === self::ACTION_SAVE || $this->mAction === self::ACTION_PREVIEW || $this->mAction === self::ACTION_DIFF;

		// the article id of the form to be used
		$formArticleId = $formTitle->getArticleID();

		// the name of the target page; might be empty when using the one-step-process
		$targetName = $this->mOptions['target'];

		// if the target page was not specified, try finding the page name formula
		// (Why is this not done in PFFormPrinter::formHTML?)
		if ( $targetName === '' ) {
			// Parse the form to see if it has a 'page name' value set.
			if ( preg_match( '/{{{\s*info.*page name\s*=\s*(.*)}}}/msU', $formContent, $matches ) ) {
				$pageNameElements = PFUtils::getFormTagComponents( trim( $matches[1] ) );
				$targetNameFormula = $pageNameElements[0];
			} else {
				throw new MWException( wfMessage( 'pf_autoedit_notargetspecified' )->parse() );
			}

			$targetTitle = null;
		} else {
			$targetNameFormula = null;
			$targetTitle = Title::newFromText( $targetName );
		}

		$preloadContent = '';

		// save $wgRequest for later restoration
		$oldRequest = $wgRequest;
		$pageExists = false;

		// preload data if not explicitly excluded and if the preload page exists
		if ( !isset( $this->mOptions['preload'] ) || $this->mOptions['preload'] !== false ) {
			if ( isset( $this->mOptions['preload'] ) && is_string( $this->mOptions['preload'] ) ) {
				$preloadTitle = Title::newFromText( $this->mOptions['preload'] );
			} else {
				$preloadTitle = Title::newFromText( $targetName );
			}

			if ( $preloadTitle !== null && $preloadTitle->exists() ) {
				// the content of the page that was specified to be used for preloading
				$preloadContent = $this->getTextForPage( $preloadTitle );

				$pageExists = true;

			} else {
				if ( isset( $this->mOptions['preload'] ) ) {
					$this->logMessage( wfMessage( 'pf_autoedit_invalidpreloadspecified', $this->mOptions['preload'] )->parse(), self::WARNING );
				}
			}
		}

		// Allow extensions to set/change the preload text, for new
		// pages.
		if ( !$pageExists ) {
			Hooks::run( 'PageForms::EditFormPreloadText', array( &$preloadContent, $targetTitle, $formTitle ) );
		}

		// Flag to keep track of formHTML() runs.
		$formHtmlHasRun = false;

		if ( $preloadContent !== '' ) {
			// @HACK - we need to set this for the preload to take
			// effect in the form.
			$pageExists = true;

			// Spoof $wgRequest for PFFormPrinter::formHTML().
			if ( isset( $_SESSION ) ) {
				$wgRequest = new FauxRequest( $this->mOptions, true, $_SESSION );
			} else {
				$wgRequest = new FauxRequest( $this->mOptions, true );
			}
			// Call PFFormPrinter::formHTML() to get at the form
			// HTML of the existing page.
			list( $formHTML, $targetContent, $form_page_title, $generatedTargetNameFormula ) =
				$wgPageFormsFormPrinter->formHTML(
					$formContent, $isFormSubmitted, $pageExists, $formArticleId, $preloadContent, $targetName, $targetNameFormula
				);
			$formHtmlHasRun = true;

			// Parse the data to be preloaded from the form HTML of
			// the existing page.
			$data = $this->parseDataFromHTMLFrag( $formHTML );

			// ...and merge/overwrite it with the new data.
			$this->mOptions = PFUtils::array_merge_recursive_distinct( $data, $this->mOptions );
		}

		// We already preloaded stuff for saving/previewing -
		// do not do this again.
		if ( $isFormSubmitted && !$wgRequest->getCheck( 'partial' ) ) {
			$preloadContent = '';
			$pageExists = false;
		} else {
			// Source of the data is a page.
			$pageExists = ( is_a( $targetTitle, 'Title' ) && $targetTitle->exists() );
		}

		// Spoof $wgRequest for PFFormPrinter::formHTML().
		if ( isset( $_SESSION ) ) {
			$wgRequest = new FauxRequest( $this->mOptions, true, $_SESSION );
		} else {
			$wgRequest = new FauxRequest( $this->mOptions, true );
		}

		// Get wikitext for submitted data and form - call formHTML(),
		// if we haven't called it already.
		if ( $preloadContent == '' ) {
			list( $formHTML, $targetContent, $generatedFormName, $generatedTargetNameFormula ) =
				$wgPageFormsFormPrinter->formHTML( $formContent, $isFormSubmitted, $pageExists, $formArticleId, $preloadContent, $targetName, $targetNameFormula );
		} else {
			$generatedFormName = $form_page_title;
		}

		// Restore original request.
		$wgRequest = $oldRequest;

		if ( $generatedFormName !== '' ) {
			$this->mOptions['formtitle'] = $generatedFormName;
		}

		$this->mOptions['formHTML'] = $formHTML;

		if ( $isFormSubmitted ) {
			// If the target page was not specified, see if
			// something was generated from the target name formula.
			if ( $this->mOptions['target'] === '' ) {
				// If no name was generated, we cannot save => give up
				if ( $generatedTargetNameFormula === '' ) {
					throw new MWException( wfMessage( 'pf_autoedit_notargetspecified' )->parse() );
				}

				$this->mOptions['target'] = $this->generateTargetName( $generatedTargetNameFormula );
			}

			// Lets other code process additional form-definition syntax
			Hooks::run( 'PageForms::WritePageData', array( $this->mOptions['form'], Title::newFromText( $this->mOptions['target'] ), &$targetContent ) );

			$editor = $this->setupEditPage( $targetContent );

			// Perform the requested action.
			if ( $this->mAction === self::ACTION_PREVIEW ) {
				$this->doPreview( $editor );
			} elseif ( $this->mAction === self::ACTION_DIFF ) {
				$this->doDiff( $editor );
			} else {
				$this->doStore( $editor );
			}
		} elseif ( $this->mAction === self::ACTION_FORMEDIT ) {
			$parserOutput = $wgParser->getOutput();
			if ( method_exists( $wgOut, 'addParserOutputMetadata' ) ) {
				$wgOut->addParserOutputMetadata( $parserOutput );
			} else {
				$wgOut->addParserOutputNoText( $parserOutput );
			}

			$this->getResult()->addValue( array( 'form' ), 'HTML', $formHTML );
		}
	}

	private function parseDataFromHTMLFrag( $html ) {
		$data = array();
		$doc = new DOMDocument();
		$oldVal = libxml_disable_entity_loader( true );
		@$doc->loadHTML(
			'<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd"><html><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/></head><body>'
			. $html
			. '</body></html>'
		);
		libxml_disable_entity_loader( $oldVal );

		// Process input tags.
		$inputs = $doc->getElementsByTagName( 'input' );

		for ( $i = 0; $i < $inputs->length; $i++ ) {
			$input = $inputs->item( $i );
			$type = $input->getAttribute( 'type' );
			$name = trim( $input->getAttribute( 'name' ) );

			if ( !$name || $input->hasAttribute( 'disabled' ) ) {
				continue;
			}

			if ( $type === '' ) {
				$type = 'text';
			}

			switch ( $type ) {
				case 'checkbox':
				case 'radio':
					if ( $input->hasAttribute( 'checked' ) ) {
						self::addToArray( $data, $name, $input->getAttribute( 'value' ) );
					}
					break;

				// case 'button':
				case 'hidden':
				case 'image':
				case 'password':
				// case 'reset':
				// case 'submit':
				case 'text':
					self::addToArray( $data, $name, $input->getAttribute( 'value' ) );
					break;
			}
		}

		// Process select tags
		$selects = $doc->getElementsByTagName( 'select' );

		for ( $i = 0; $i < $selects->length; $i++ ) {
			$select = $selects->item( $i );
			$name = trim( $select->getAttribute( 'name' ) );

			if ( !$name || $select->hasAttribute( 'disabled' ) ) {
				continue;
			}

			$options = $select->getElementsByTagName( 'option' );

			// If the current $select is a radio button select
			// (i.e. not multiple) set the first option to selected
			// as default. This may be overwritten in the loop below.
			if ( $options->length > 0 && ( !$select->hasAttribute( 'multiple' ) ) ) {
				self::addToArray( $data, $name, $options->item( 0 )->getAttribute( 'value' ) );
			}

			for ( $o = 0; $o < $options->length; $o++ ) {
				if ( $options->item( $o )->hasAttribute( 'selected' ) ) {
					if ( $options->item( $o )->getAttribute( 'value' ) ) {
						self::addToArray( $data, $name, $options->item( $o )->getAttribute( 'value' ) );
					} else {
						self::addToArray( $data, $name, $options->item( $o )->nodeValue );
					}
				}
			}
		}

		// Process textarea tags
		$textareas = $doc->getElementsByTagName( 'textarea' );

		for ( $i = 0; $i < $textareas->length; $i++ ) {
			$textarea = $textareas->item( $i );
			$name = trim( $textarea->getAttribute( 'name' ) );

			if ( !$name ) {
				continue;
			}

			self::addToArray( $data, $name, $textarea->textContent );
		}

		return $data;
	}

	/**
	 * Parses data from a query string into the $data array
	 *
	 * @param Array $data
	 * @param String $queryString
	 * @return Array
	 */
	private function parseDataFromQueryString( &$data, $queryString ) {
		$params = explode( '&', $queryString );

		foreach ( $params as $param ) {
			$elements = explode( '=', $param, 2 );

			$key = trim( urldecode( $elements[0] ) );
			$value = count( $elements ) > 1 ? urldecode( $elements[1] ) : null;

			if ( $key == "query" || $key == "query string" ) {
				$this->parseDataFromQueryString( $data, $value );
			} else {
				self::addToArray( $data, $key, $value );
			}
		}

		return $data;
	}

	/**
	 * This function recursively inserts the value into a tree.
	 *
	 * @param array &$array is root
	 * @param string $key identifies path to position in tree.
	 *    Format: 1stLevelName[2ndLevel][3rdLevel][...], i.e. normal array notation
	 * @param mixed $value the value to insert
	 * @param bool $toplevel if this is a toplevel value.
	 */
	public static function addToArray( &$array, $key, $value, $toplevel = true ) {
		$matches = array();

		if ( preg_match( '/^([^\[\]]*)\[([^\[\]]*)\](.*)/', $key, $matches ) ) {
			// for some reason toplevel keys get their spaces encoded by MW.
			// We have to imitate that.
			if ( $toplevel ) {
				$key = str_replace( ' ', '_', $matches[1] );
			} else {
				$key = $matches[1];
			}

			// if subsequent element does not exist yet or is a string (we prefer arrays over strings)
			if ( !array_key_exists( $key, $array ) || is_string( $array[$key] ) ) {
				$array[$key] = array();
			}

			self::addToArray( $array[$key], $matches[2] . $matches[3], $value, false );
		} else {
			if ( $key ) {
				// only add the string value if there is no child array present
				if ( !array_key_exists( $key, $array ) || !is_array( $array[$key] ) ) {
					$array[$key] = $value;
				}
			} else {
				array_push( $array, $value );
			}
		}
	}

	/**
	 * Add error message to the ApiResult
	 *
	 * @param string $msg
	 * @param int $errorLevel
	 *
	 * @return string
	 */
	private function logMessage( $msg, $errorLevel = self::ERROR ) {
		if ( $errorLevel === self::ERROR ) {
			$this->mStatus = 400;
		}

		$this->getResult()->addValue( array( 'errors' ), null, array( 'level' => $errorLevel, 'message' => $msg ) );

		return $msg;
	}

	/**
	 * Indicates whether this module requires write mode
	 * @return bool
	 */
	public function isWriteMode() {
		return true;
	}

	/**
	 * Returns the array of allowed parameters (parameter name) => (default
	 * value) or (parameter name) => (array with PARAM_* constants as keys)
	 * Don't call this function directly: use getFinalParams() to allow
	 * hooks to modify parameters as needed.
	 *
	 * @return array or false
	 */
	function getAllowedParams() {
		return array(
			'form' => null,
			'target' => null,
			'query' => null,
			'preload' => null
		);
	}

	/**
	 * Returns an array of parameter descriptions.
	 * Don't call this functon directly: use getFinalParamDescription() to
	 * allow hooks to modify descriptions as needed.
	 *
	 * @return array or false
	 */
	function getParamDescription() {
		return array(
			'form' => 'The form to use.',
			'target' => 'The target page.',
			'query' => 'The query string.',
			'preload' => 'The name of a page to preload'
		);
	}

	/**
	 * Returns the description string for this module
	 *
	 * @return string|string[]
	 */
	function getDescription() {
		return <<<END
This module is used to remotely create or edit pages using Page Forms.

Add "template-name[field-name]=field-value" to the query string parameter, to set the value for a specific field.
To set values for more than one field use "&", or rather its URL encoded version "%26": "template-name[field-name-1]=field-value-1%26template-name[field-name-2]=field-value-2".
See the first example below.

In addition to the query parameter, any parameter in the URL of the form "template-name[field-name]=field-value" will be treated as part of the query. See the second example.
END;
	}

	/**
	 * Returns usage examples for this module.
	 *
	 * @return string|string[]
	 */
	protected function getExamples() {
		return array(
			'With query parameter:    api.php?action=pfautoedit&form=form-name&target=page-name&query=template-name[field-name-1]=field-value-1%26template-name[field-name-2]=field-value-2',
			'Without query parameter: api.php?action=pfautoedit&form=form-name&target=page-name&template-name[field-name-1]=field-value-1&template-name[field-name-2]=field-value-2'
		);
	}

	/**
	 * Returns a string that identifies the version of the class.
	 * Includes the class name, the svn revision, timestamp, and
	 * last author.
	 *
	 * @return string
	 */
	function getVersion() {
		global $wgPageFormsIP;
		$gitSha1 = SpecialVersion::getGitHeadSha1( $wgPageFormsIP );
		return __CLASS__ . '-' . PF_VERSION . ( $gitSha1 !== false ) ? ' (' . substr( $gitSha1, 0, 7 ) . ')' : '';
	}

}
