<?php
declare( strict_types = 1 );

namespace Wikimedia\Parsoid\Core;

use Wikimedia\JsonCodec\JsonCodecable;
use Wikimedia\JsonCodec\JsonCodecableTrait;
use Wikimedia\Parsoid\Utils\CompatJsonCodec;

/**
 * Section metadata for generating TOC.
 *
 * This is not the complete data for the article section, just the
 * information needed to generate the table of contents.
 *
 * For now, this schema matches whatever is generated by Parser.php.
 * Parsoid will attempt to match this output for now.
 *
 * Parser.php::finalizeHeadings() is the authoritative source for how
 * some of these properties are computed right now, especially for the
 * $line, $anchor, and $linkAnchor properties below.
 *
 * Linker.php::tocLine() and ::makeHeadline() demonstrate how these
 * properties are used to create headings and table of contents lines.
 */
class SectionMetadata implements \JsonSerializable, JsonCodecable {
	use JsonCodecableTrait;

	/**
	 * The heading tag level: a 1 here means an <H1> tag was used, a
	 * 2 means an <H2> tag was used, etc.
	 */
	public int $hLevel;

	/**
	 * This is a one-indexed TOC level and the nesting level.
	 * So, if a page has a H2-H4-H6, then, those levels 2,4,6
	 * correspond to TOC-levels 1,2,3.
	 */
	public int $tocLevel;

	/**
	 * HTML heading of the section. Only a narrow set of HTML tags are allowed here.
	 *
	 * This starts with the parsed headline seen in wikitext and
	 * - replaces links with link text
	 * - processes extension strip markers
	 * - removes style, script tags
	 * - strips all HTML tags except the following tags (from Parser.php)
	 *   . <sup> and <sub> (T10393)
	 *   . <i> (T28375)
	 *   . <b> (r105284)
	 *   . <bdi> (T74884)
	 *   . <span dir="rtl"> and <span dir="ltr"> (T37167)
	 *   . <s> and <strike> (T35715)
	 *   . <q> (T251672)
	 *   We strip any parameter from accepted tags, except dir="rtl|ltr" from <span>,
	 *   to allow setting directionality in toc items.
	 *
	 * @note This should be converted into the proper html variant.
	 */
	public string $line;

	/**
	 * TOC number string (3.1.3, 4.5.2, etc.)
	 *
	 * @note This should be localized into the parser target language.
	 */
	public string $number;

	/**
	 * Section id (integer, assigned in depth first traversal order)
	 * Template generated sections get a "T-" prefix.
	 */
	public string $index;

	/**
	 * The title of the page that generated this heading.
	 * For template-generated sections, this will be the template title.
	 * This string is in "prefixed DB key" format.
	 */
	public ?string $fromTitle;

	/**
	 * Codepoint offset where the section shows up in wikitext; this is null
	 * if this section comes from a template, if it comes from a literal
	 * HTML <h_> tag, or otherwise doesn't correspond to a "preprocessor
	 * section".
	 * @note This is measured in codepoints, not bytes; you should use
	 * appropriate multi-byte aware string functions, *not* substr().
	 * Similarly, in JavaScript, be careful not to confuse JavaScript
	 * UCS-2 "characters" with codepoints.
	 */
	public ?int $codepointOffset;

	/**
	 * Anchor attribute.
	 *
	 * This property is the "true" value of the ID attribute, and should be
	 * used when looking up a heading or setting an attribute, for example
	 * using Document.getElementById() or Element.setAttribute('id',...).
	 *
	 * This value is *not* HTML-entity escaped; if you are writing HTML
	 * as a literal string, you should still entity-escape ampersands and
	 * single/double quotes as appropriate.
	 *
	 * This value is *not* URL-escaped either; instead use the `linkAnchor`
	 * property if you are constructing a URL to target this section.
	 *
	 * The anchor attribute is based on the $line property, but does extra
	 * processing to turn it into a valid attribute:
	 * - strip all HTML tags,
	 * - normalizes section name
	 * - normalizes section name whitespace
	 * - decodes char references
	 * - makes it a valid HTML id attribute value
	 *   (HTML5 / HTML4 based on $wgFragmentMode property)
	 * - dedupes (case-insensitively) identical anchors by adding "_$n" suffixes
	 */
	public string $anchor;

	/**
	 * Anchor URL fragment.
	 *
	 * This is very similar to the $anchor property, but is appropriately
	 * URL-escaped to make it appropriate to use in constructing a URL
	 * fragment link.  You should almost always prepend a `#` symbol
	 * to `linkAnchor` if you are using it correctly.  You are still
	 * responsible for HTML-escaping the resulting URL if you are emitting
	 * this as an HTML attribute.
	 */
	public string $linkAnchor;

	/**
	 * Arbitrary data attached to this section by extensions.  This
	 * data will be stored and cached in the ParserOutput object along
	 * with the rest of the section data, and made available to external
	 * clients via the action API.
	 *
	 * This method is provided to overcome the unsafe practice of attaching
	 * extra information to a section by directly assigning member variables.
	 *
	 * See ParserOutput::setExtensionData() for more information on typical
	 * use.
	 */
	private array $extensionData;

	/**
	 * @param int $tocLevel One-indexed TOC level and the nesting level
	 * @param int $hLevel The heading tag level
	 * @param string $line Stripped headline text
	 * @param string $number TOC number string (3.1.3, 4.5.2, etc)
	 * @param string $index Section id
	 * @param ?string $fromTitle The title of the page or template that
	 *   generated this heading, or null.
	 * @param ?int $codepointOffset Codepoint offset (# of characters) where the
	 *   section shows up in wikitext, or null if this doesn't correspond to
	 *   a "preprocesor section".  (Be careful if using JavaScript, as
	 *   JavaScript "characters" are UCS-2 encoded and don't correspond
	 *   directly to code points.)
	 * @param string $anchor "True" value of the ID attribute
	 * @param string $linkAnchor URL-escaped value of the anchor, for use in
	 *   constructing a URL fragment link
	 * @param ?array $extensionData Extension data passed in as an associative array
	 */
	public function __construct(
		// This is a great candidate for named arguments in PHP 8.0+
		int $tocLevel = 0,
		int $hLevel = -1,
		string $line = '',
		string $number = '',
		string $index = '',
		?string $fromTitle = null,
		?int $codepointOffset = null,
		string $anchor = '',
		string $linkAnchor = '',
		?array $extensionData = null
	) {
		$this->tocLevel = $tocLevel;
		$this->line = $line;
		$this->hLevel = $hLevel;
		$this->number = $number;
		$this->index = $index;
		$this->fromTitle = $fromTitle;
		$this->codepointOffset = $codepointOffset;
		$this->anchor = $anchor;
		$this->linkAnchor = $linkAnchor;
		$this->extensionData = $extensionData ?? [];
	}

	/**
	 * Attaches arbitrary data to this SectionMetadata object. This
	 * can be used to store some information about this section in the
	 * ParserOutput object for later use during page output. The data
	 * will be cached along with the ParserOutput object.
	 *
	 * This method is provided to overcome the unsafe practice of
	 * attaching extra information to a section by directly assigning
	 * member variables.
	 *
	 * See ParserOutput::setExtensionData() in core for further information
	 * about typical usage in hooks.
	 *
	 * Setting conflicting values for the same key is not allowed.
	 * If you call ::setExtensionData() multiple times with the same key
	 * on a SectionMetadata, is is expected that the value will be identical
	 * each time.  If you want to collect multiple pieces of data under a
	 * single key, use ::appendExtensionData().
	 *
	 * @note Only scalar values (numbers, strings, or arrays) are
	 * supported as a value.  (A future revision will allow anything
	 * that core's JsonCodec can handle.)  Attempts to set other types
	 * as extension data values will break ParserCache for the page.
	 *
	 * @todo When more complex values than scalar values are supported,
	 * TOCData::__clone should be updated to take that into account.
	 *
	 * @param string $key The key for accessing the data. Extensions
	 *   should take care to avoid conflicts in naming keys. It is
	 *   suggested to use the extension's name as a prefix.  Using
	 *   the prefix `mw:` is reserved for core.
	 *
	 * @param mixed $value The value to set.
	 *   Setting a value to null is equivalent to removing the value.
	 */
	public function setExtensionData( string $key, $value ): void {
		if (
			array_key_exists( $key, $this->extensionData ) &&
			$this->extensionData[$key] !== $value
		) {
			throw new \InvalidArgumentException( "Conflicting data for $key" );
		}
		if ( $value === null ) {
			unset( $this->extensionData[$key] );
		} else {
			$this->extensionData[$key] = $value;
		}
	}

	/**
	 * Appends arbitrary data to this SectionMetadata. This can be used
	 * to store some information about the section in the ParserOutput object for later
	 * use during page output.
	 *
	 * See ::setExtensionData() for more details on rationale and use.
	 *
	 * @param string $key The key for accessing the data. Extensions should take care to avoid
	 *   conflicts in naming keys. It is suggested to use the extension's name as a prefix.
	 *
	 * @param int|string $value The value to append to the list.
	 * @return never This method is not yet implemented.
	 */
	public function appendExtensionData( string $key, $value ): void {
		// This implementation would mirror that of
		// ParserOutput::appendExtensionData, but let's defer implementing
		// this until we're sure we need it.  In particular, we might need
		// to figure out how a merge on section data is expected to work
		// before we can determine the right semantics for this.
		throw new \InvalidArgumentException( "Not yet implemented" );
	}

	/**
	 * Gets extension data previously attached to this SectionMetadata.
	 *
	 * @param string $key The key to look up
	 * @return mixed|null The value(s) previously set for the given key using
	 *   ::setExtensionData() or ::appendExtensionData(), or null if no
	 *  value was set for this key.
	 */
	public function getExtensionData( $key ) {
		$value = $this->extensionData[$key] ?? null;
		return $value;
	}

	/**
	 * Alias for :toLegacy(), for b/c compatibility only.
	 * @deprecated
	 * @return array
	 */
	public function toArray(): array {
		return $this->toLegacy();
	}

	/**
	 * Alias for :fromLegacy(), for b/c compatibility only.
	 * @deprecated
	 * @param array $data
	 * @return SectionMetadata
	 */
	public static function fromArray( array $data ): SectionMetadata {
		return self::fromLegacy( $data );
	}

	/**
	 * Create a new SectionMetadata object from an array in the legacy
	 * format returned by the action API.
	 *
	 * This is useful for backward-compatibility, but is expected to
	 * be replaced by conversion to/from JSON in the future.
	 *
	 * @param array $data Associative array with section metadata
	 * @return SectionMetadata
	 */
	public static function fromLegacy( array $data ): SectionMetadata {
		return new SectionMetadata(
			$data['toclevel'] ?? 0,
			(int)( $data['level'] ?? -1 ),
			$data['line'] ?? '',
			$data['number'] ?? '',
			$data['index'] ?? '',
			( $data['fromtitle'] ?? false ) ?: null,
			$data['byteoffset'] ?? null, // T319141: actually "codepoint offset"
			$data['anchor'] ?? '',
			$data['linkAnchor'] ?? $data['anchor'] ?? '',
			$data['extensionData'] ?? null
		);
	}

	/**
	 * Return as associative array, in the format returned by the
	 * action API (including the order of fields and the value types).
	 *
	 * This is helpful as b/c support while we transition to objects.
	 * @return array
	 */
	public function toLegacy(): array {
		$ret = [
			'toclevel' => $this->tocLevel,
			// cast $level to string in order to keep b/c for the parse api
			'level' => (string)$this->hLevel,
			'line' => $this->line,
			'number' => $this->number,
			'index' => $this->index,
			'fromtitle' => $this->fromTitle ?? false,
			 // T319141: legacy 'byteoffset' is actually "codepoint offset"
			'byteoffset' => $this->codepointOffset,
			'anchor' => $this->anchor,
			'linkAnchor' => $this->linkAnchor,
		];
		// Micro-opt: Output 'extensionData' conditionally to avoid bloat
		if ( $this->extensionData ) {
			$ret['extensionData'] = $this->extensionData;
		}
		return $ret;
	}

	/**
	 * @inheritDoc
	 */
	public function jsonSerialize(): array {
		return $this->toLegacy();
	}

	// JsonCodecable interface

	/** @inheritDoc */
	public function toJsonArray(): array {
		$ret = [];
		if ( $this->tocLevel !== 0 ) {
			$ret['tocLevel'] = $this->tocLevel;
		}
		if ( $this->hLevel !== -1 ) {
			$ret['hLevel'] = $this->hLevel;
		}
		if ( $this->line !== '' ) {
			$ret['line'] = $this->line;
		}
		if ( $this->number !== '' ) {
			$ret['number'] = $this->number;
		}
		if ( $this->index !== '' ) {
			$ret['index'] = $this->index;
		}
		if ( $this->fromTitle !== null ) {
			$ret['fromTitle'] = $this->fromTitle;
		}
		if ( $this->codepointOffset !== null ) {
			$ret['codepointOffset'] = $this->codepointOffset;
		}
		if ( $this->anchor !== '' ) {
			$ret['anchor'] = $this->anchor;
		}
		if ( $this->linkAnchor !== $this->anchor ) {
			$ret['linkAnchor'] = $this->linkAnchor;
		}
		if ( $this->extensionData ) {
			$ret['extensionData'] = $this->extensionData;
		}
		return $ret;
	}

	/** @inheritDoc */
	public static function newFromJsonArray( array $json ) {
		return new SectionMetadata(
			$json['tocLevel'] ?? 0,
			$json['hLevel'] ?? -1,
			$json['line'] ?? '',
			$json['number'] ?? '',
			$json['index'] ?? '',
			$json['fromTitle'] ?? null,
			$json['codepointOffset'] ?? null,
			$json['anchor'] ?? '',
			$json['linkAnchor'] ?? $json['anchor'] ?? '',
			$json['extensionData'] ?? null
		);
	}

	// Pretty-printing

	/**
	 * For use in parser tests and wherever else humans might appreciate
	 * some formatting in the JSON encoded output. For now, nothing special.
	 * @param int $indent Additional indentation to apply (defaults to zero)
	 * @return string
	 */
	public function prettyPrint( int $indent = 0 ): string {
		# Basic info
		$buf = str_repeat( ' ', $indent + $this->tocLevel ) . "h{$this->hLevel}";
		$buf .= " index:{$this->index} toclevel:$this->tocLevel number:{$this->number}";

		# Optional information
		$title = $this->fromTitle ?? "NULL";
		$offset = $this->codepointOffset ?? "NULL";
		$buf .= " title:{$title} off:{$offset}";

		# Anchors & link text
		if ( $this->anchor === $this->linkAnchor ) {
			$buf .= " anchor/linkAnchor:{$this->anchor}";
		} else {
			$buf .= " anchor:{$this->anchor} linkAnchor:{$this->linkAnchor}";
		}
		$line = $this->line;
		if ( str_contains( $line, "\n" ) ) {
			// Handle cases where $line has "funny" characters
			$line = json_encode( $line );
		}
		$buf .= " line:{$line}";

		# Extension data
		if ( $this->extensionData ) {
			$codec = new CompatJsonCodec();
			$buf .= " ext:" . json_encode( $codec->toJsonArray( $this->extensionData ) );
		}

		return $buf;
	}
}
