<?php
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * The check before publishing.
 *
 * WHAT IT SOLVES. The model writes good markup, but not always: a missing `alt`, two `h1`s on the
 * same page, a `{{#loop}}` with no `{{#empty}}` that leaves a white hole the day the category runs
 * dry. None of these raise an error, and none of them show up while previewing logged in with a
 * full database. They show up later, on the customer's site.
 *
 * HOW. By reading the TEMPLATE, not by asking the model: these are all decidable checks, and a
 * decidable check is not delegated to something that might answer differently tomorrow. It costs no
 * tokens, runs in milliseconds and can be launched on every save.
 *
 * WHAT IT DOES NOT DO. It does not block publishing. A builder that stops you going live is a
 * builder that gets uninstalled: here it warns, with the piece of code in front of you, and whoever
 * publishes decides. And it does not judge taste — "this section is ugly" is not a check, it is an
 * opinion.
 */
class AIPB_Check {

	/** Severity: `error` breaks something for a visitor, `warning` is something to tidy up. */
	const ERROR   = 'error';
	const WARNING = 'warning';

	/** The checks a single section goes through, in the order they are run. */
	const CHECKS = array( 'images', 'links', 'headings', 'loops', 'language', 'forms', 'external', 'tokens', 'motion' );

	/**
	 * Analyses a stack of blocks (a page) or a single template.
	 *
	 * @param array $blocks [ { name, template, values } ]
	 * @param bool  $whole  Are these ALL the page's blocks, or just one section?
	 *
	 * @return array{issues:list<array>,counts:array}
	 */
	public static function page( $blocks, $whole = true ) {
		$issues = array();
		$h1     = 0;

		foreach ( (array) $blocks as $index => $block ) {
			$template = isset( $block['template'] ) ? (string) $block['template'] : '';
			if ( '' === $template ) {
				continue;
			}

			$name = ( isset( $block['name'] ) && '' !== $block['name'] )
				? (string) $block['name']
				/* translators: %d: the section's position on the page. */
				: sprintf( __( 'Section %d', 'aipb' ), $index + 1 );

			$layer = isset( $block['scope'] ) && 'page' === $block['scope'];
			if ( ! $layer ) {
				$h1 += self::count_h1( $template );
			}

			foreach ( self::section( $template, isset( $block['values'] ) ? (array) $block['values'] : array() ) as $issue ) {
				$issue['block'] = $name;
				$issue['index'] = $index;
				$issues[]       = $issue;
			}

			if ( $layer ) {
				foreach ( self::check_layer( $template ) as $issue ) {
					$issue['block'] = $name;
					$issue['index'] = $index;
					$issues[]       = $issue;
				}
			}
		}

		$issues = array_merge( $issues, self::check_page_headings( $h1, (bool) $blocks, $whole ) );

		return array(
			'issues' => $issues,
			'counts' => self::summarise( $issues ),
		);
	}

	/**
	 * The checks on one section.
	 *
	 * @return list<array{severity:string,code:string,text:string}>
	 */
	public static function section( $template, $values = array() ) {
		// The code is looked at with the real content inside: an alt written into an empty
		// placeholder is empty on the page too, and looking for it in the bare template would never
		// find it.
		$html = self::inline( $template, $values );

		$out = array();
		foreach ( self::CHECKS as $check ) {
			$out = array_merge( $out, call_user_func( array( __CLASS__, 'check_' . $check ), $html ) );
		}

		return $out;
	}

	/* --------------------------------- The checks ------------------------------ */

	/**
	 * The h1s, counted over the WHOLE page.
	 *
	 * A section with one h1 is correct, two sections with one each are not — and that is the case
	 * that actually happens, because each was generated on its own without knowing about the others.
	 */
	protected static function check_page_headings( $h1, $has_blocks, $whole ) {
		if ( $h1 > 1 ) {
			return array( self::page_issue( self::WARNING, 'h1-multiple', sprintf(
				/* translators: %d: number of h1 headings. */
				__( 'The page has %d <h1> headings. There should be only one: it is what says what the page is about, to a reader and to whoever indexes it.', 'aipb' ),
				$h1
			) ) );
		}

		// ONLY on a whole page. A section that is not the hero must NOT have an <h1> — the prompt
		// says so — so asking for one while looking at it alone would be a wrong warning on nearly
		// every generation. And a check that is wrong nearly always is not "just ignored": it
		// teaches people to skip the line even when it is right.
		if ( 0 === $h1 && $has_blocks && $whole ) {
			return array( self::page_issue( self::WARNING, 'h1-missing',
				__( 'The page has no <h1> heading.', 'aipb' )
			) );
		}

		return array();
	}

	/**
	 * Extra checks on a page layer: it is decoration, not a section, so an <h1> in it is a
	 * heading that does not belong on the page, and markup that sits in the document flow is
	 * an overlay that is not an overlay.
	 *
	 * @return list<array{severity:string,code:string,text:string}>
	 */
	protected static function check_layer( $template ) {
		$out = array();

		if ( self::count_h1( $template ) ) {
			$out[] = self::issue( self::WARNING, 'layer-h1', __( 'This page layer contains an <h1>. Overlays are decoration: the page\'s heading belongs in a section.', 'aipb' ) );
		}

		if ( ! preg_match( '/position\s*:\s*(fixed|absolute)/i', (string) $template ) ) {
			$out[] = self::issue( self::WARNING, 'layer-flow', __( 'This page layer has no position:fixed or position:absolute: it will sit in the document flow instead of overlaying the page.', 'aipb' ) );
		}

		return $out;
	}

	/**
	 * Images: alternative text and dimensions.
	 *
	 * The `alt` is accessibility. The dimensions are Core Web Vitals: without them the browser does
	 * not know how much room to reserve and the page jumps while loading — the fault a user feels
	 * most and connects to the code least.
	 */
	protected static function check_images( $html ) {
		if ( ! preg_match_all( '/<img\b[^>]*>/i', $html, $m ) ) {
			return array();
		}

		$no_alt        = 0;
		$empty_alt     = 0;
		$no_dimensions = 0;

		foreach ( $m[0] as $tag ) {
			if ( ! preg_match( '/\balt\s*=/i', $tag ) ) {
				++$no_alt;
			} elseif ( preg_match( '/\balt\s*=\s*["\']\s*["\']/i', $tag ) ) {
				// alt="" is valid HTML and means "decorative image". But the real values are
				// already inlined here, so the frequent case is a different one: the alt text field
				// exists and nobody filled it in. That is worth a warning, not an error — because
				// sometimes the image really is decorative.
				++$empty_alt;
			}

			if ( ! preg_match( '/\bwidth\s*=/i', $tag ) || ! preg_match( '/\bheight\s*=/i', $tag ) ) {
				++$no_dimensions;
			}
		}

		$out = array();

		if ( $no_alt ) {
			$out[] = self::issue( self::ERROR, 'img-alt', sprintf(
				/* translators: %d: number of images. */
				_n( '%d image with no alt attribute: somebody using a screen reader cannot tell what it shows.', '%d images with no alt attribute: somebody using a screen reader cannot tell what they show.', $no_alt, 'aipb' ),
				$no_alt
			) );
		}
		if ( $empty_alt ) {
			$out[] = self::issue( self::WARNING, 'img-alt-empty', sprintf(
				/* translators: %d: number of images. */
				_n( '%d image with empty alternative text: fine only if it is purely decorative.', '%d images with empty alternative text: fine only if they are purely decorative.', $empty_alt, 'aipb' ),
				$empty_alt
			) );
		}
		if ( $no_dimensions ) {
			$out[] = self::issue( self::WARNING, 'img-dimensions', sprintf(
				/* translators: %d: number of images. */
				_n( '%d image with no width and height: the page jumps while loading.', '%d images with no width and height: the page jumps while loading.', $no_dimensions, 'aipb' ),
				$no_dimensions
			) );
		}

		return $out;
	}

	/** Links: they need a destination, and text that can be read out of context. */
	protected static function check_links( $html ) {
		$out   = array();
		$empty = 0;
		$mute  = 0;

		if ( preg_match_all( '/<a\b([^>]*)>(.*?)<\/a>/is', $html, $m, PREG_SET_ORDER ) ) {
			foreach ( $m as $link ) {
				$href = preg_match( '/\bhref\s*=\s*["\']([^"\']*)["\']/i', $link[1], $h ) ? trim( $h[1] ) : '';
				$text = trim( wp_strip_all_tags( $link[2] ) );

				if ( '' === $href || '#' === $href ) {
					++$empty;
				}

				// A lone icon inside a link is mute unless it carries an accessible label.
				if ( '' === $text && ! preg_match( '/aria-label\s*=|aria-labelledby\s*=/i', $link[1] ) ) {
					++$mute;
				}
			}
		}

		if ( $empty ) {
			$out[] = self::issue( self::ERROR, 'link-empty', sprintf(
				/* translators: %d: number of links. */
				_n( '%d link with no destination (empty href, or #).', '%d links with no destination (empty href, or #).', $empty, 'aipb' ),
				$empty
			) );
		}
		if ( $mute ) {
			$out[] = self::issue( self::ERROR, 'link-mute', sprintf(
				/* translators: %d: number of links. */
				_n( '%d link with neither text nor aria-label: somebody who cannot see the icon does not know where it goes.', '%d links with neither text nor aria-label: somebody who cannot see the icons does not know where they go.', $mute, 'aipb' ),
				$mute
			) );
		}

		return $out;
	}

	/**
	 * Headings: the hierarchy must not skip levels.
	 *
	 * An h2 followed by an h4 is not an aesthetic fault: somebody navigating by headings finds a
	 * missing step and cannot tell what sits under what.
	 */
	protected static function check_headings( $html ) {
		if ( ! preg_match_all( '/<h([1-6])\b/i', $html, $m ) ) {
			return array();
		}

		$previous = 0;
		foreach ( array_map( 'intval', $m[1] ) as $level ) {
			if ( $previous && $level > $previous + 1 ) {
				// One is enough: the fault is the hierarchy, not every single jump.
				return array( self::issue( self::WARNING, 'heading-skip', sprintf(
					/* translators: 1: previous level, 2: level found. */
					__( 'The heading hierarchy skips a level: from h%1$d to h%2$d.', 'aipb' ),
					$previous,
					$level
				) ) );
			}
			$previous = $level;
		}

		return array();
	}

	/**
	 * Loops with no empty state.
	 *
	 * This is the check worth the most, because the fault is invisible until it is too late: the
	 * section works perfectly today with twelve products, and the day the category empties it
	 * leaves a white rectangle with not a word on it.
	 */
	protected static function check_loops( $html ) {
		$out = array();

		$loops  = preg_match_all( '/\{\{#loop\s+[^}]*source="(post|term|menu)"/', $html );
		$filters = preg_match_all( '/\{\{#filter\b/', $html );
		$empties = preg_match_all( '/\{\{#empty\}\}/', $html );

		if ( $filters && $filters > $empties ) {
			$out[] = self::issue( self::WARNING, 'filter-empty', __( 'A filter has no {{#empty}} block: if the search finds nothing, the visitor sees blank space with no explanation.', 'aipb' ) );
		}
		if ( $loops && ! $empties && ! $filters ) {
			$out[] = self::issue( self::WARNING, 'loop-empty', __( 'The section lists content but does not cover the case where there is none: the day the list empties, a hole is left.', 'aipb' ) );
		}

		return $out;
	}

	/** Text the model left as a placeholder or as a note to itself. */
	protected static function check_language( $html ) {
		$out  = array();
		$text = wp_strip_all_tags( preg_replace( '#<(style|script)\b[^>]*>.*?</\1>#is', ' ', $html ) );

		if ( preg_match( '/\b(lorem ipsum|dolor sit amet)\b/i', $text ) ) {
			$out[] = self::issue( self::ERROR, 'lorem', __( 'There is placeholder text (lorem ipsum) left on the page.', 'aipb' ) );
		}
		if ( preg_match( '/\b(TODO|FIXME|PLACEHOLDER|XXX)\b/', $text ) ) {
			$out[] = self::issue( self::WARNING, 'todo', __( 'There is a reminder (TODO/FIXME) left in the visible text.', 'aipb' ) );
		}

		return $out;
	}

	/** Form fields with no label: the case where accessibility really is lost. */
	protected static function check_forms( $html ) {
		if ( ! preg_match_all( '/<(input|select|textarea)\b([^>]*)>/i', $html, $m, PREG_SET_ORDER ) ) {
			return array();
		}

		$without = 0;
		foreach ( $m as $field ) {
			$attributes = $field[2];

			// Buttons and hidden fields do not need a label.
			if ( preg_match( '/type\s*=\s*["\'](hidden|submit|button|image)["\']/i', $attributes ) ) {
				continue;
			}

			$id        = preg_match( '/\bid\s*=\s*["\']([^"\']+)["\']/i', $attributes, $i ) ? $i[1] : '';
			$has_label = $id && preg_match( '/<label\b[^>]*\bfor\s*=\s*["\']' . preg_quote( $id, '/' ) . '["\']/i', $html );

			if ( ! $has_label && ! preg_match( '/aria-label\s*=|aria-labelledby\s*=/i', $attributes ) ) {
				++$without;
			}
		}

		if ( ! $without ) {
			return array();
		}

		return array( self::issue( self::ERROR, 'field-no-label', sprintf(
			/* translators: %d: number of form fields. */
			_n( '%d form field with no label attached.', '%d form fields with no label attached.', $without, 'aipb' ),
			$without
		) ) );
	}

	/**
	 * Third-party libraries: the engine strips them, and this is where it gets said they were there.
	 *
	 * WHY THIS IS AN ERROR AND NOT A WARNING. The other checks flag a section that looks worse than
	 * it could; this one flags a section whose behaviour IS NOT THERE. If the model built the slider
	 * on Swiper, `AIPB_Render::strip_external()` takes Swiper away and the slides stay stacked: it
	 * is not a missing finishing touch, it is the section not doing what it was generated for.
	 *
	 * It looks at the template, not at the output: here the library is still visible, while on the
	 * page it has already gone. This is the only place where WHICH one it was can be said, along
	 * with what to ask for instead.
	 */
	protected static function check_external( $html ) {
		$seen = array();

		if ( preg_match_all( '#<script\b[^>]*\bsrc\s*=\s*["\']?([^"\'\s>]+)#i', $html, $m ) ) {
			$seen = array_merge( $seen, $m[1] );
		}
		if ( preg_match_all( '#<link\b[^>]*\bstylesheet\b[^>]*>#i', $html, $m ) ) {
			foreach ( $m[0] as $tag ) {
				if ( preg_match( '#\bhref\s*=\s*["\']?([^"\'\s>]+)#i', $tag, $h ) ) {
					$seen[] = $h[1];
				}
			}
		}
		if ( preg_match_all( '#@import\b[^;{]*?["\(]\s*["\']?([^"\'\)\s;]+)#i', $html, $m ) ) {
			$seen = array_merge( $seen, $m[1] );
		}

		// Only what really points outside: a relative path stays on the page, and flagging it would
		// send whoever is building to look for a problem that does not exist.
		$outside = array();
		foreach ( array_unique( $seen ) as $url ) {
			if ( AIPB_Render::is_external_resource( $url ) ) {
				$outside[] = $url;
			}
		}

		if ( ! $outside ) {
			return array();
		}

		// Swiper is the one case where the answer is not "redo it by hand": the plugin serves it
		// from its own files, so the fix is to drop the tag and keep the code. Saying "rewrite it
		// in plain JavaScript" here would send somebody to rebuild what is already installed.
		foreach ( $outside as $url ) {
			if ( false !== stripos( $url, 'swiper' ) ) {
				return array( self::issue( self::ERROR, 'external-library', __(
					'The section loads Swiper from another site, and the engine removes it — but Swiper is already here: the plugin serves it from its own files and loads it by itself on the pages that use it. Ask for the <script> tag to be removed and the initialisation to be kept as it is.',
					'aipb'
				) ) );
			}
		}

		return array( self::issue( self::ERROR, 'external-library', sprintf(
			/* translators: %s: list of URLs. */
			_n(
				'The section loads a library from another site (%s). The engine removes it — a customer\'s site must not depend on third-party servers — so on the page the behaviour that relied on it is missing. Ask for that part to be redone in plain JavaScript.',
				'The section loads libraries from other sites (%s). The engine removes them — a customer\'s site must not depend on third-party servers — so on the page the behaviour that relied on them is missing. Ask for those parts to be redone in plain JavaScript.',
				count( $outside ),
				'aipb'
			),
			implode( ', ', array_map( 'esc_html', array_slice( $outside, 0, 3 ) ) )
		) ) );
	}

	/**
	 * Orphan tokens: a `var(--aipb-…)` with no fallback pointing at a token that does not exist.
	 *
	 * WHY THIS IS AN ERROR AND NOT A WARNING. In CSS an undefined var() with no fallback does not
	 * just lose that value: it makes the WHOLE declaration invalid. An orphan
	 * `padding: var(--aipb-space-xl)` does not give the wrong padding, it gives no padding. These
	 * are the lines that stop existing.
	 *
	 * WHAT IT DELIBERATELY DOES NOT FLAG:
	 *  - a bare var() pointing at a token THAT EXISTS: on save its fallback is sewn in
	 *    (AIPB_Design::add_fallbacks), so it is already safe and saying so would be noise;
	 *  - a var() that already has a fallback, whether or not the token exists: that stands on its own.
	 * What is left is only the cases where the section is broken RIGHT NOW, which are few and real.
	 *
	 * A site with NO tokens at all is the one case where it says nothing: there the mistake is not
	 * the section's but the missing design system's, and repeating it on every section of the page
	 * would bury the one thing to do — define them.
	 */
	protected static function check_tokens( $html ) {
		if ( ! preg_match_all( '/var\(\s*(--aipb-[a-z0-9_-]+)\s*\)/i', $html, $m ) ) {
			return array();
		}

		$map = AIPB_Design::token_map();
		if ( ! $map ) {
			return array();
		}

		$orphans = array();
		foreach ( array_unique( $m[1] ) as $variable ) {
			if ( ! isset( $map[ $variable ] ) ) {
				$orphans[] = $variable;
			}
		}

		if ( ! $orphans ) {
			return array();
		}

		return array( self::issue( self::ERROR, 'token-orphan', sprintf(
			/* translators: %s: list of CSS variables. */
			_n(
				'The section uses the variable %s, which is not among the design tokens and has no fallback value: in CSS that line is not worth zero, it is worth NOTHING — the declaration is thrown away entirely. Define the token in Brand & Design, or have that rule rewritten.',
				'The section uses the variables %s, which are not among the design tokens and have no fallback values: in CSS those lines are not worth zero, they are thrown away entirely. Define the tokens in Brand & Design, or have those rules rewritten.',
				count( $orphans ),
				'aipb'
			),
			implode( ', ', array_map( 'esc_html', array_slice( $orphans, 0, 5 ) ) )
		) ) );
	}

	/**
	 * Hand-written tempo: durations and easing curves that should be the motion tokens.
	 *
	 * WHY THIS IS A WARNING AND NOT AN ERROR. Nothing is broken — the animation plays, at the speed
	 * somebody wrote. What is lost is quieter: this section moves at ITS OWN tempo instead of the
	 * brand's, and when the motion tokens are retuned from the Brand page, every section written on
	 * them follows and this one stays behind. Ten of these on one site is ten different entrances —
	 * exactly the drift the tokens exist to end.
	 *
	 * A literal inside a var() fallback is not flagged: that is the pattern the prompts teach, and
	 * the fallback only speaks on a page whose tokens never rendered. A literal ZERO is not flagged
	 * either — 0s carries no tempo to disagree with.
	 */
	protected static function check_motion( $html ) {
		if ( ! preg_match_all( '#<style\b[^>]*>(.*?)</style>#is', $html, $m ) ) {
			return array();
		}

		// Only when there are motion tokens to point at. They ship with the plugin, so the case
		// where this stays quiet is a site whose design system was emptied on purpose.
		$has_motion = false;
		foreach ( AIPB_Design::token_map() as $variable => $value ) {
			if ( 0 === strpos( (string) $variable, '--aipb-motion-' ) ) {
				$has_motion = true;
				break;
			}
		}
		if ( ! $has_motion ) {
			return array();
		}

		$css = preg_replace( '~/\*.*?\*/~s', ' ', implode( ' ', $m[1] ) );
		if ( ! preg_match_all( '/(?:transition|animation)[a-z-]*\s*:[^;{}]*/i', (string) $css, $found ) ) {
			return array();
		}

		$hard = 0;
		foreach ( $found[0] as $declaration ) {
			// What sits inside a var() is a fallback, and a fallback is the taught pattern.
			$bare = (string) preg_replace( '/var\((?:[^()]|\([^()]*\))*\)/', ' ', $declaration );

			if ( preg_match( '/cubic-bezier\s*\(/i', $bare ) ) {
				++$hard;
				continue;
			}
			if ( preg_match_all( '/(?:^|[\s:,(])(\.?\d+(?:\.\d+)?)(?:ms|s)\b/i', $bare, $times ) ) {
				foreach ( $times[1] as $time ) {
					if ( 0.0 !== (float) $time ) {
						++$hard;
						break;
					}
				}
			}
		}

		if ( ! $hard ) {
			return array();
		}

		return array( self::issue( self::WARNING, 'motion-hardcoded', sprintf(
			/* translators: %d: number of CSS declarations. */
			_n(
				'%d animation writes its duration or easing by hand instead of using the motion tokens (var(--aipb-motion-…)): it moves at its own tempo, and retuning the brand\'s motion from Brand & Design will not reach it.',
				'%d animations write their durations or easing by hand instead of using the motion tokens (var(--aipb-motion-…)): they move at their own tempo, and retuning the brand\'s motion from Brand & Design will not reach them.',
				$hard,
				'aipb'
			),
			$hard
		) ) );
	}

	/* ---------------------------------- Helpers -------------------------------- */

	protected static function count_h1( $template ) {
		return preg_match_all( '/<h1\b/i', $template );
	}

	/** The code with the real content inside: this is what the visitor will see. */
	protected static function inline( $template, $values ) {
		return preg_replace_callback(
			'/\{\{([tciurhs]):([a-zA-Z0-9_.]+)\}\}/',
			static function ( $m ) use ( $values ) {
				$key = $m[2];

				return isset( $values[ $key ] ) && ! is_array( $values[ $key ] ) ? (string) $values[ $key ] : '';
			},
			(string) $template
		);
	}

	protected static function issue( $severity, $code, $text ) {
		return array( 'severity' => $severity, 'code' => $code, 'text' => $text );
	}

	/** An issue that belongs to the page rather than to one section. */
	protected static function page_issue( $severity, $code, $text ) {
		return self::issue( $severity, $code, $text ) + array( 'block' => '', 'index' => -1 );
	}

	/** @return array{error:int,warning:int} */
	protected static function summarise( $issues ) {
		$out = array( self::ERROR => 0, self::WARNING => 0 );

		foreach ( $issues as $issue ) {
			++$out[ $issue['severity'] ];
		}

		return $out;
	}
}
