<?php
/**
 * File holding the SFAutoEditAPI class
 *
 * @author Stephan Gambke
 * @file
 * @ingroup SemanticForms
 */

/**
 * The SF_AutoEditAPI class.
 *
 * @ingroup SemanticForms
 */
class SFAutoeditAPI 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 occured.
	 */
	const ERROR = 0;

	/**
	 * Error level used when a revcoverable error occured.
	 */
	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 );
	}

	/**
	 * Returns the options array
	 * @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;
	}

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

	/**
	 * Sets an option in the options array
	 */
	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
		global $wgVersion;
		if ( version_compare( $wgVersion, '1.20', '>=' ) ) {
			$data = $this->getRequest()->getValues();
		} else { // TODO: remove else branch when raising supported version to MW 1.20, getValues() was buggy before
			$data = $_POST + $_GET;
		}
		$this->mOptions = SFUtils::array_merge_recursive_distinct( $data, $this->mOptions );

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

		$wgParser->startExternalParse( null, ParserOptions::newFromUser( $wgUser ), 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' ] );
		} else if ( array_key_exists( 'wpPreview', $this->mOptions ) ) {

			// set action to 'preview' if requested
			$this->mAction = self::ACTION_PREVIEW;
			unset( $this->mOptions[ 'wpPreview' ] );
		} else if ( array_key_exists( 'wpDiff', $this->mOptions ) ) {

			// set action to 'preview' if requested
			$this->mAction = self::ACTION_DIFF;
			unset( $this->mOptions[ 'wpDiff' ] );
		} else if ( array_key_exists( 'action', $this->mOptions ) ) {

			switch ( $this->mOptions[ 'action' ] ) {

				case 'sfautoedit' :
					$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();
		}

		wfRunHooks( 'sfSetTargetName', 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( 'sf_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( 'sf_autoedit_invalidtargetspecified', $this->mOptions[ 'target' ] )->parse() );
				}

				$formNames = SFFormLinker::getDefaultFormsForPage( $targetTitle );

				// if no default form can be found, try alternate forms
				if ( count( $formNames ) === 0 ) {

					$formNames = SFFormLinker::getFormsThatPagePointsTo( $targetTitle->getText(), $targetTitle->getNamespace(), SFFormLinker::ALTERNATE_FORM );

					// if still no form can be found, give up
					if ( count( $formNames ) === 0 ) {
						throw new MWException( wfMessage( 'sf_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( 'sf_autoedit_toomanyformsfound' )->parse(), self::DEBUG );
			}

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

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

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

		// if the given form is not a valid title, give up
		if ( !($formTitle instanceOf Title) ) {
			throw new MWException( wfMessage( 'sf_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 );

			// FIXME: Title::newFromRedirectRecurse is deprecated as of MW 1.21
			$formTitle = Title::newFromRedirectRecurse( WikiPage::factory( $formTitle )->getRawText() );

			// 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( 'sf_autoedit_redirectlimitexeeded', $this->mOptions[ 'form' ] )->parse() );
				} else {
					throw new MWException( wfMessage( 'sf_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( 'sf_autoedit_invalidform', $this->mOptions[ 'form' ] )->parse() );
		}

		return $formTitle;
	}

	protected function setupEditPage( $targetContent ) {

		global $wgUser;

		// 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( 'sf_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' => $wgUser->isLoggedIn() ? $wgUser->editToken() : EDIT_TOKEN_SUFFIX,
					'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 );

		return $editor;
	}

	/**
	 * Sets the output HTML of wgOut as the module's result
	 */
	protected function setResultFromOutput() {

		global $wgOut;

		// turn on output buffering
		ob_start();

		// generate preview document and write it to output buffer
		$wgOut->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();

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

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

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

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

		$this->setResultFromOutput();

	}

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

	protected function doStore( EditPage $editor ) {

		global $wgUser, $wgOut, $wgLang;

		if ( method_exists( $editor, 'getTitle' ) ) {
			$title = $editor->getTitle();
		} else { // TODO: remove else branch when raising supported version to MW 1.19
			$title = $editor->mTitle;
		}

		// 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( 'sf_autoedit_redlinkexists' )->parse(), self::WARNING );
		}

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

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

		if ( $permErrors ) {

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

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

			return;
		}

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

		$status = $editor->internalAttemptSave( $resultDetails, $bot );

		switch ( $status->value ) {
			case EditPage::AS_HOOK_ERROR_EXPECTED: // A hook function returned an error
			case EditPage::AS_CONTENT_TOO_BIG: // Content too big (> $wgMaxArticleSize)
			case EditPage::AS_ARTICLE_WAS_DELETED: // article was deleted while editting 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( 'sf_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

			// TODO: This error code only exists from 1.21 onwards. It is
			// suitably handled by the default branch, but really should get its
			// own branch. Uncomment once compatibility to pre1.21 is dropped.
//			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' ] : '';

				$wgOut->redirect( $title->getFullURL( $query ) . $anchor );
				$this->getResult()->addValue( NULL, 'redirect', $title->getFullURL( $query ) . $anchor );
				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
				wfRunHooks( 'ArticleUpdateBeforeRedirect', array( $editor->getArticle(), &$sectionanchor, &$extraQuery ) );

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

				$wgOut->redirect( $title->getFullURL( $extraQuery ) . $sectionanchor );
				$this->getResult()->addValue( NULL, 'redirect', $title->getFullURL( $extraQuery ) . $sectionanchor );

				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 );

				$wgOut->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 = $wgLang->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 editting editor page
				throw new UserBlockedError( $wgUser->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 doFormEdit( $formHTML, $formJS ) {
		// return form html and js in the result
		$this->getResult()->addValue( array('form'), 'HTML', $formHTML );
		$this->getResult()->addValue( array('form'), 'JS', $formJS );
}

	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( 'sf_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( 'sf_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() {
		/**
		 * @global OutputPage $wgOut
		 */
		global $wgOut;

		if ( !headers_sent() ) {

			header( 'X-Status: ' . $this->mStatus, true, $this->mStatus );
			header( 'X-Form: ' . $this->mOptions[ 'form' ] );
			header( 'X-Target: ' . $this->mOptions[ 'target' ] );

			$redirect = $wgOut->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;
		$targetName = $wgParser->transformMsg( $targetName, ParserOptions::newFromUser( null ) );

		$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 = SFUtils::makeRandomNumber( $randomNumDigits, $randomNumHasPadding );
			} else if ( 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 ];
				}
			} else if ( 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( 'sf_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;
			//
			// can not use $targetTitle->exists(); it does not use
			// Title::GAID_FOR_UPDATE, which is needed to get correct data from
			// cache; use $targetTitle->getArticleID() instead
			while ( $targetTitle->getArticleID( Title::GAID_FOR_UPDATE ) !== 0 ) {

				if ( $isRandom ) {
					$titleNumber = SFUtils::makeRandomNumber( $randomNumDigits, $randomNumHasPadding );
				}
				// if title number is blank, change it to 2; otherwise,
				// increment it, and if necessary pad it with leading 0s as well
				elseif ( $titleNumber == "" ) {
					$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;
	}

	/**
	 * 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 SFFormPrinter $sfgFormPrinter
	 * @throws MWException
	 */
	public function doAction() {
		global $wgOut, $wgRequest, $sfgFormPrinter;

		// if the wiki is read-only, do not save
		if ( wfReadOnly() ) {

			if ( $this->mAction === self::ACTION_SAVE ) {
				throw new MWException( wfMessage( 'sf_autoedit_readonly', wfReadOnlyReason() )->parse() );
			}

			// even if not saving notify client anyway. Might want to dislay a notice
			$this->logMessage( wfMessage( 'sf_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
						WikiPage::factory( $formTitle )->getRawText() // 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 SFFormPrinter::formHTML?)
		if ( $targetName === '' ) {

			// parse the form to see if it has a 'page name' value set
			if ( preg_match( '/{{{info.*page name\s*=\s*(.*)}}}/m', $formContent, $matches ) ) {
				$pageNameElements = SFUtils::getFormTagComponents( $matches[ 1 ] );
				$targetNameFormula = $pageNameElements[ 0 ];
			} else {
				throw new MWException( wfMessage( 'sf_autoedit_notargetspecified' )->parse() );
			}

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

		$preloadContent = '';

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

		// 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 = WikiPage::factory( $preloadTitle )->getRawText();

				$pageExists = true;

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

		// allow extensions to set/change the preload text
		wfRunHooks( 'sfEditFormPreloadText', array( &$preloadContent, $targetTitle, $formTitle ) );

		// flag to keep track of formHTML runs
		$formHtmlHasRun = false;

		if ( $preloadContent !== '' ) {

			// spoof $wgRequest for SFFormPrinter::formHTML
			$wgRequest = new FauxRequest( $this->mOptions, true );

			// call SFFormPrinter::formHTML to get at the form html of the existing page
			list ( $formHTML, $formJS, $targetContent, $form_page_title, $generatedTargetNameFormula ) =
				$sfgFormPrinter->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 = SFUtils::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 SFFormPrinter::formHTML().
		$wgRequest = new FauxRequest( $this->mOptions, true );

		// if necessary spoof wgOut; if we took the general $wgOut again some JS
		// modules might attach themselves twice and thus be called twice
		if ( $formHtmlHasRun ) {
			// save wgOut for later restoration
			$oldOut = $wgOut;

			$wgOut = new OutputPage( RequestContext::getMain() );
		}

		// get wikitext for submitted data and form
		list ( $formHTML, $formJS, $targetContent, $generatedFormName, $generatedTargetNameFormula ) =
				$sfgFormPrinter->formHTML( $formContent, $isFormSubmitted, $pageExists, $formArticleId, $preloadContent, $targetName, $targetNameFormula );

		if ( $formHtmlHasRun ) {
			// restore wgOut
			$wgOut = $oldOut;
		}

		// restore original request
		$wgRequest = $oldRequest;

		if ( $generatedFormName !== '' ) {
			$formTitle = Title::newFromText( $generatedFormName );
			$this->mOptions[ 'formtitle' ] = $formTitle->getText();
		}

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

		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 can not save => give up
				if ( $generatedTargetNameFormula === '' ) {
					throw new MWException( wfMessage( 'sf_autoedit_notargetspecified' )->parse() );
				}

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

			// Lets other code process additional form-definition syntax
			wfRunHooks( 'sfWritePageData', 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 );
			} else if ( $this->mAction === self::ACTION_DIFF ) {
				$this->doDiff( $editor );
			} else {
				$this->doStore( $editor );
			}
		} else if ( $this->mAction === self::ACTION_FORMEDIT ) {
			$this->doFormEdit( $formHTML, $formJS );
		}
	}

	private function parseDataFromHTMLFrag( $html ) {

		$data = array( );
		$doc = new DOMDocument();
		@$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>'
		);

		// 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.
	// $array is root
	// $key identifies path to position in tree.
	// Format: 1stLevelName[2ndLevel][3rdLevel][...], i.e. normal array notation
	// $value: the value to insert
	// $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 mixed string or array of strings
	 */
	function getDescription() {
		return <<<END
This module is used to remotely create or edit pages using Semantic 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 mixed string or array of strings
	 */
	protected function getExamples() {
		return array(
			'With query parameter:    api.php?action=sfautoedit&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=sfautoedit&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 $sfgIP;
		$gitSha1 = SpecialVersion::getGitHeadSha1( $sfgIP );
		return __CLASS__ . '-' . SF_VERSION . ($gitSha1 !== false) ? ' (' . substr( $gitSha1, 0, 7 ) . ')' : '';
	}


	/**
	 * Available in parent class since MW 1.19
	 * @deprecated
	 */
	function getRequest() {
		if ( is_callable( 'parent::getRequest' ) ) {
			return parent::getRequest();
		} else {
			global $wgRequest;
			return $wgRequest;
		}
	}

}
