<?php
/**
 * The diff engine: two field sets in, classified changes out.
 *
 * Severity discipline (from the build contract, judge-reviewed on the two
 * shipped siblings): confidence labels, not red alarms. CRITICAL is reserved
 * for states that actively destroy search visibility. An expected content
 * edit downgrades WARNING-class changes to INFO — but never CRITICAL-class
 * ones (an editor can accidentally noindex their own page; that stays loud).
 *
 * @package SEO_Flight_Recorder
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Class SFRec_Differ
 */
class SFRec_Differ {

	/**
	 * Compare two field sets.
	 *
	 * @param array $before Previous snapshot fields.
	 * @param array $after  Current fields.
	 * @param array $events Site events since the previous snapshot (SFRec_Events::since()).
	 * @param int   $post_id The monitored page's post ID (0 if none) — used to
	 *                       recognize "you edited this page yourself".
	 * @return array[] List of change rows: {field, before, after, severity, classification, suspects}.
	 */
	public static function diff( $before, $after, $events = array(), $post_id = 0 ) {
		$changes = array();

		$content_edited = false;
		$suspects       = array();
		foreach ( $events as $event ) {
			if ( 'post_edit' === $event['type'] && (int) $event['post_id'] === (int) $post_id && $post_id > 0 ) {
				$content_edited = true;
			} elseif ( in_array( $event['type'], array( 'update_plugin', 'update_theme', 'update_core', 'activate', 'deactivate', 'switch_theme', 'option' ), true ) ) {
				$suspects[] = $event['label'];
			}
		}
		$suspects       = array_values( array_unique( $suspects ) );
		$classification = 'unexplained';
		if ( ! empty( $suspects ) ) {
			$classification = 'update_correlated';
		}

		// --- CRITICAL-class states (never downgraded). ---

		if ( empty( $before['noindex'] ) && ! empty( $after['noindex'] ) ) {
			$changes[] = self::row( 'noindex', __( 'indexable', 'buildsbyluke-seo-flight-recorder' ), __( 'NOINDEX', 'buildsbyluke-seo-flight-recorder' ), 'critical', $classification, $suspects );
		} elseif ( ! empty( $before['noindex'] ) && empty( $after['noindex'] ) ) {
			$changes[] = self::row( 'noindex', __( 'NOINDEX', 'buildsbyluke-seo-flight-recorder' ), __( 'indexable', 'buildsbyluke-seo-flight-recorder' ), 'info', $classification, $suspects );
		}

		$status_before = (int) $before['http_status'];
		$status_after  = (int) $after['http_status'];
		if ( $status_before !== $status_after ) {
			$severity  = ( $status_after >= 400 || 0 === $status_after ) ? 'critical' : ( $status_after >= 300 ? 'warning' : 'info' );
			$changes[] = self::row( 'http_status', (string) $status_before, (string) $status_after, $severity, $classification, $suspects );
		}

		$redirect_before = (string) $before['redirect_to'];
		$redirect_after  = (string) $after['redirect_to'];
		if ( $redirect_before !== $redirect_after && '' !== $redirect_after ) {
			$same_host = self::same_host( $redirect_after, home_url( '/' ) );
			$changes[] = self::row( 'redirect', $redirect_before, $redirect_after, $same_host ? 'warning' : 'critical', $classification, $suspects );
		}

		$canonical_before = (string) $before['canonical'];
		$canonical_after  = (string) $after['canonical'];
		if ( $canonical_before !== $canonical_after ) {
			if ( '' === $canonical_after && '' !== $canonical_before ) {
				$changes[] = self::row( 'canonical', $canonical_before, '', 'critical', $classification, $suspects );
			} elseif ( '' !== $canonical_before && ! self::same_host( $canonical_after, $canonical_before ) ) {
				$changes[] = self::row( 'canonical', $canonical_before, $canonical_after, 'critical', $classification, $suspects );
			} else {
				$changes[] = self::row( 'canonical', $canonical_before, $canonical_after, self::soften( 'warning', $content_edited ), $content_edited ? 'content_edit' : $classification, $suspects );
			}
		}

		if ( '' !== (string) $before['title'] && '' === (string) $after['title'] ) {
			$changes[] = self::row( 'title', $before['title'], '', 'critical', $classification, $suspects );
		} elseif ( (string) $before['title'] !== (string) $after['title'] ) {
			$changes[] = self::row( 'title', $before['title'], $after['title'], self::soften( 'warning', $content_edited ), $content_edited ? 'content_edit' : $classification, $suspects );
		}

		// Schema types: any type that disappears entirely is critical (rich
		// results die with it); count drops and additions are softer.
		$schema_before = is_array( $before['schema_types'] ) ? $before['schema_types'] : array();
		$schema_after  = is_array( $after['schema_types'] ) ? $after['schema_types'] : array();
		foreach ( $schema_before as $schema_type => $count_before ) {
			if ( ! isset( $schema_after[ $schema_type ] ) ) {
				$changes[] = self::row( 'schema', $schema_type, '', 'critical', $classification, $suspects );
			} elseif ( (int) $schema_after[ $schema_type ] < (int) $count_before ) {
				$changes[] = self::row( 'schema', $schema_type . ' ×' . (int) $count_before, $schema_type . ' ×' . (int) $schema_after[ $schema_type ], 'warning', $classification, $suspects );
			}
		}
		foreach ( $schema_after as $schema_type => $count_after ) {
			if ( ! isset( $schema_before[ $schema_type ] ) ) {
				$changes[] = self::row( 'schema', '', $schema_type, 'info', $classification, $suspects );
			}
		}

		// --- WARNING-class fields (downgrade to INFO on own content edits). ---

		if ( (string) $before['meta_description'] !== (string) $after['meta_description'] ) {
			$severity  = ( '' === (string) $after['meta_description'] ) ? 'warning' : self::soften( 'warning', $content_edited );
			$changes[] = self::row( 'meta_description', $before['meta_description'], $after['meta_description'], $severity, $content_edited ? 'content_edit' : $classification, $suspects );
		}

		if ( (string) $before['meta_robots'] !== (string) $after['meta_robots'] ) {
			// The noindex transition is already covered above; other robots
			// directive drift (nofollow, max-snippet…) is a warning.
			$changes[] = self::row( 'meta_robots', $before['meta_robots'], $after['meta_robots'], 'warning', $classification, $suspects );
		}

		foreach ( array( 'og' => array( 'title', 'description', 'image', 'type', 'url' ), 'twitter' => array( 'card', 'title', 'description', 'image' ) ) as $group => $keys ) {
			$group_before = is_array( $before[ $group ] ) ? $before[ $group ] : array();
			$group_after  = is_array( $after[ $group ] ) ? $after[ $group ] : array();
			foreach ( $keys as $key ) {
				$value_before = isset( $group_before[ $key ] ) ? (string) $group_before[ $key ] : '';
				$value_after  = isset( $group_after[ $key ] ) ? (string) $group_after[ $key ] : '';
				if ( $value_before !== $value_after ) {
					$severity  = ( '' === $value_after && '' !== $value_before ) ? 'warning' : self::soften( 'warning', $content_edited );
					$changes[] = self::row( $group . ':' . $key, $value_before, $value_after, $severity, $content_edited ? 'content_edit' : $classification, $suspects );
				}
			}
		}

		$h1_before = (int) $before['h1_count'];
		$h1_after  = (int) $after['h1_count'];
		if ( $h1_before > 0 && 0 === $h1_after ) {
			$changes[] = self::row( 'h1', $before['h1_first'], '', 'warning', $classification, $suspects );
		} elseif ( (string) $before['h1_first'] !== (string) $after['h1_first'] || $h1_before !== $h1_after ) {
			$changes[] = self::row(
				'h1',
				$before['h1_first'] . ' (×' . $h1_before . ')',
				$after['h1_first'] . ' (×' . $h1_after . ')',
				self::soften( 'warning', $content_edited ),
				$content_edited ? 'content_edit' : $classification,
				$suspects
			);
		}

		return $changes;
	}

	/**
	 * Build one change row.
	 *
	 * @param string   $field          Field key.
	 * @param string   $before         Before value.
	 * @param string   $after          After value.
	 * @param string   $severity       critical|warning|info.
	 * @param string   $classification unexplained|update_correlated|content_edit.
	 * @param string[] $suspects       Correlated event labels.
	 * @return array
	 */
	private static function row( $field, $before, $after, $severity, $classification, $suspects ) {
		return array(
			'field'          => $field,
			'before'         => (string) $before,
			'after'          => (string) $after,
			'severity'       => $severity,
			'classification' => $classification,
			'suspects'       => $suspects,
		);
	}

	/**
	 * WARNING → INFO when the page's own content was just edited by a person.
	 *
	 * @param string $severity       Base severity.
	 * @param bool   $content_edited Whether this page's post was edited.
	 * @return string
	 */
	private static function soften( $severity, $content_edited ) {
		if ( $content_edited && 'warning' === $severity ) {
			return 'info';
		}
		return $severity;
	}

	/**
	 * Do two URLs share a host?
	 *
	 * @param string $url_a First URL.
	 * @param string $url_b Second URL.
	 * @return bool
	 */
	private static function same_host( $url_a, $url_b ) {
		$host_a = wp_parse_url( $url_a, PHP_URL_HOST );
		$host_b = wp_parse_url( $url_b, PHP_URL_HOST );
		if ( ! $host_a || ! $host_b ) {
			return false;
		}
		return strtolower( $host_a ) === strtolower( $host_b );
	}
}
