<?php
use MediaWiki\MediaWikiServices;

class VoteHooks {
	public static $options = ['yes', 'no', 'abstain'];

	public static function onParserFirstCallInit( Parser $parser ) {
		$parser->setHook( 'vote', [ self::class, 'renderVoteTag' ] );
	}

	public static function onParserPreSaveTransformComplete( Parser $parser, string &$text ) {
		// workaround for https://phabricator.wikimedia.org/T319221
		$sigText = $parser->getUserSig(RequestContext::getMain()->getUser());
		$datetime = getFormattedTimestamp($parser);
		$text = preg_replace('/(?<!<nowiki>)(?<!<pre>)~~~~/', "$sigText $datetime", $text);
		// NOTE: this regex obviously matches false positives, however since
		// it's quite uncommon for ~~~~ to occur in wiki pages this is deemed good enough
		// until T319221 is addressed and this workaround is no longer necessary
	}

	// Render <vote>
	public static function renderVoteTag( $input, array $args, Parser $parser, PPFrame $frame ) {

		$msg = function (...$args) use ($parser) {
			// wfMessage() uses the interface language by default ... but since
			// MediaWiki caches the rendered page we of course want to use the
			// page content language instead. This closure is just for convenience.
			return wfMessage(...$args)->inLanguage($parser->getTargetLanguage());
		};

		$addErrorCategory = function () use ($parser) {
			$parser->addTrackingCategory('pages-with-vote-errors-category', $parser->getTitle());
		};

		$voteClosed = false;
		$endDate = null;

		$end = $args['end'] ?? null;
		unset($args['end']);
		$cancel = $args['cancel'] ?? null;
		unset($args['cancel']);

		$text = '';

		if ($cancel !== null) {
			$voteClosed = $msg('vote-cancelled');
		} else if ($end) {
			$endDate = DateTime::createFromFormat('Y-m-d', $end);
			if (!$endDate) {
				$addErrorCategory();
				return formatError($msg('vote-error-end'));
			}

			$endDate->setTime(23, 59);
			$now = new Datetime();
			$tz = date_default_timezone_get();

			if ($now > $endDate) {
				$voteClosed = $msg('vote-ended', $endDate->format('jS F Y, H:i') . " ($tz)");
			} else {
				$text .= $msg('vote-will-end', $endDate->format('jS F Y, H:i') . " ($tz)");
				$parser->getOutput()->updateCacheExpiry($endDate->getTimestamp() - $now->getTimestamp());
			}
		}


		if (!empty($args)) {
			$addErrorCategory();
			return formatError($msg('vote-error-unknown-attribute', array_keys($args)[0]));
		}

		if (!str_ends_with($input, "\n")) {
			$addErrorCategory();
			return formatError($msg('vote-error-missing-newline'));
		}

		// Parse the passed text into $lines.
		// Sidenote: We parse and render in two steps because we need both:
		// * lookahead (gray out votes that are superseded), as well as
		// * lookbehind (prefix "I changed my mind" for superseding votes).
		$lines = [];

		foreach (explode("\n", $input) as $line) {
			if (str_starts_with($line, '* ')) {
				$line = substr($line, 2);
				$vote = self::parseVote($line);

				if (!$vote['date']) {
					array_push($lines, ['error' => $msg('vote-error-invalid-date'), 'line' => $line]);
					continue;
				}
				if ($endDate && $vote['date'] > $endDate) {
					array_push($lines, ['error' => $msg('vote-error-too-late'), 'line' => $line]);
					continue;
				}
				if (!in_array($vote['vote'], self::$options)) {
					array_push($lines, ['error' => $msg('vote-error-invalid-vote', '(YES, NO, ABSTAIN)'), 'line' => $line]);
					continue;
				}

				array_push($lines, $vote);
				continue;
			}

			array_push($lines, ['raw' => $line . "\n"]);
		}

		// Render the parsed $lines into $voteText.
		$voteText = '';
		$votes = [];

		foreach ($lines as $idx => $line) {
			if (array_key_exists('raw', $line)) {
				$voteText .= $parser->recursiveTagParse($line['raw']);
			} elseif (array_key_exists('error', $line)) {
				$addErrorCategory();
				$voteText .= '* ' . formatError($line['error'] . ':') . " " . $line['line'] . "\n";
			} else {
				$user = $line['user'];
				$vote = $line['vote'];

				$voteText .= '* ';
				$class = '';
				for ($i = $idx + 1; $i < count($lines); $i++) {
					if (($lines[$i]['user'] ?? null) == $user) {
						$class = 'uncounted-vote';
					}
				}
				$voteText .= "<span class=$class>";
				if (array_key_exists($user, $votes)) {
					if ($votes[$user] == $vote) {
						$voteText .= "(" . $msg('vote-repeated') . ') ';
					} else {
						$voteText .= $msg('vote-changed') . ', ';
					}
				}
				$votes[$user] = $vote;
				$voteText .= $msg('vote-option-' . $vote) . ' ';
				if ($line['comment']) {
					$voteText .= $parser->recursiveTagParse($line['comment']);
				}

				$userLink = $parser->getLinkRenderer()->makeLink($line['userTitle'], $user);
				$voteText .= " --$userLink ". $line['date']->format('H:i, j F Y');
				$voteText .= "</span>\n";
			}
		}

		$voteCounts = ['yes' => 0, 'no' => 0, 'abstain' => 0];

		foreach (array_values($votes) as $vote) {
			$voteCounts[$vote] += 1;
		}

		if (!$voteClosed && $parser->getOutput()->getExtensionData('vote-open')) {
			$voteClosed = formatError($msg('vote-error-one-vote-per-page'));
			$addErrorCategory();
		}
		$parser->getOutput()->appendExtensionData('vote-open', true);

		$text .= '<div>';
		if (array_sum(array_values($voteCounts)) == 0 && !$voteClosed) {
			$text .= $msg('vote-call-to-vote');
		} else {
			$text .= $msg('vote-tally', $voteCounts['yes'], $voteCounts['no'], $voteCounts['abstain']);
		}
		$text .= '</div>';

		$text .= $voteText;

		if ($voteClosed) {
			$text .= $voteClosed;
		} else {
			$text .= self::votingFormHtml($parser, $votes);
			$parser->addTrackingCategory('pages-with-open-votes-category', $parser->getTitle());
		}

		$parser->getOutput()->addModules(['ext.vote-css']);

		return $text;
	}

	private static function parseVote($line) {
		$date = DateTime::createFromFormat('Y-m-d H:i', substr($line, 0, strlen('2022-10-03 10:00')));
		$parts = explode(' ', substr($line, strlen('2022-10-03 10:00 ')), 3);
		$user = Title::newFromText('User:' . rtrim(array_shift($parts), ':'));
		$vote = strtolower(array_shift($parts));
		$comment = array_shift($parts);
		return ['date' => $date, 'userTitle' => $user, 'user' => $user->getText(), 'vote' => $vote, 'comment' => $comment];
	}

	private static function votingFormHtml($parser, $votes) {
		$previewMode = $parser->getRevisionId() == null;

		$formDescriptor = [
			'vote' => [
				'type' => 'radio',
				'options' => array_reduce(self::$options, function($result, $opt) use ($parser) {
					$result[wfMessage('vote-option-' . $opt)->inLanguage($parser->getTargetLanguage())->parse()] = $opt;
					return $result;
				}),
				// ideally we would set 'required' => true here but unfortunately that doesn't work
				// see https://phabricator.wikimedia.org/T319216
				'disabled' => $previewMode,
			],
			'comment' => [
				'type' => 'textarea',
				'rows' => 3,
				'label' => wfMessage('vote-comment')->inLanguage($parser->getTargetLanguage()),
				'disabled' => $previewMode,
				'cssclass' => 'vote-comment-textarea',
			],
			'page' => [
				'type' => 'hidden',
				'name' => 'page',
				'default' => $parser->getTitle()
			]
		];

		$parser->getOutput()->addModules(['ext.vote-js']);

		$form = new HTMLForm( $formDescriptor );

		if ($previewMode)
			$form->suppressDefaultSubmit();

		$form->mFieldData = [];

		$form
			->setId('vote-form')
			->setTitle(Title::newFromText('Special:InsertVote'))
			->setSubmitText( 'Submit' )
			->setAutocomplete('off')
			->setDisplayFormat('div')
			->addHeaderHtml('<h3>' . wfMessage('vote-add-your-vote')->inLanguage($parser->getTargetLanguage()) . '</h3>')
			->addFooterHtml('<script type="application/json" data-votes>'. json_encode($votes) . '</script>');

		$html = $form->getHTML(false);

		// dirty hack to work around https://phabricator.wikimedia.org/T319216
		$html = str_replace('type="radio"', 'type="radio" required', $html);

		return $html;
	}
}

// We need a special page to receive the submitted votes.
class SpecialInsertVote extends UnlistedSpecialPage {
	function __construct() {
		parent::__construct( 'InsertVote' );
	}

	function execute( $par ) {
		$request = $this->getRequest();
		$output = $this->getOutput();

		$page = $request->getVal('page');
		if ($page == null) {
			$output->setStatusCode(400);
			$output->prepareErrorPage('missing page parameter');
			return;
		}
		$vote = $request->getVal('wpvote');
		if ($vote == null) {
			$output->setStatusCode(400);
			$output->prepareErrorPage('missing wpvote parameter');
			return;
		}
		$comment = $request->getText('wpcomment');

		$services = MediaWikiServices::getInstance();
		$permissionManager = $services->getPermissionManager();

		if ($this->getUser()->isAnon()) {
			$loginPageTitle = Title::newFromText('Special:UserLogin');
			$this->getOutput()->redirect($loginPageTitle->getFullURL([
				'returnto' => 'Special:InsertVote',
				'returntoquery' => $request->getRawPostString()
			]));
			return;
		}

		$title = Title::newFromText($page);
		$permissionErrors = $permissionManager->getPermissionErrors('edit', $this->getUser(), $title);
		if (!empty($permissionErrors)) {
			$output->prepareErrorPage("You are not allowed to edit this page.");
			$output->setStatusCode(403);
			return;
		}
		$content = WikiPage::factory($title)->getContent();
		if ($content == null) {
			$output->prepareErrorPage("The page no longer exists.");
			$output->setStatusCode(500);
			return;
		}
		$content = ContentHandler::getContentText($content);

		$date = date('Y-m-d H:i');
		$username = str_replace(' ', '_', $this->getUser()->getName());
		$comment = str_replace("\n", ' ', $comment);
		$newLine = "* $date $username: " . strtoupper($vote);
		if ($comment)
			$newLine .= ' ' . $comment;

		// not using preg_replace because we don't want references like $1 in the comment to be interpreted
		preg_match(',^</vote *>?$,m', $content, $matches, PREG_OFFSET_CAPTURE);
		if (!$matches) {
			$output->prepareErrorPage("could not find </vote> tag, maybe someone has removed the vote in the meantime?");
			$output->setStatusCode(500);
			return;
		}
		$offset = $matches[0][1];
		$content = substr($content, 0, $offset) . $newLine . "\n" . substr($content, $offset);

		$page = WikiPage::factory($title);
		$pageLang = $page->getLanguage() ?? $services->getContentLanguage();
		$status = $page->doUserEditContent(
			new WikitextContent($content),
			$this->getUser(),
			wfMessage('vote-editmsg-' . $vote)->inLanguage($pageLang), EDIT_UPDATE, false, ['vote']
		);
		if ($status->isOK()) {
			$this->getOutput()->redirect($title->getFullURL());
		} else {
			$output->prepareErrorPage($status->getHTML());
			$output->setStatusCode(500);
		}
	}
}

function formatError($error) {
	// The CSS class is provided by MediaWiki.
	return "<span class=error>$error</span>";
}

function getFormattedTimestamp($parser) {
	// This function exists solely because of the onParserPreSaveTransformComplete workaround for https://phabricator.wikimedia.org/T319221.
	// It is supposed to mirror the behavior of https://gerrit.wikimedia.org/r/plugins/gitiles/mediawiki/core/+/refs/tags/1.38.4/includes/parser/Parser.php#4575.

	$ts = $parser->mOptions->getTimestamp();
	$timestamp = MWTimestamp::getLocalInstance( $ts );
	$ts = $timestamp->format( 'YmdHis' );
	$tzMsg = $timestamp->getTimezoneMessage()->inContentLanguage()->text();
	return $parser->getContentLanguage()->timeanddate( $ts, false, false ) . " ($tzMsg)";
}