<?php
/**
 * Health Analyzer.
 *
 * Computes the internal-link health of the site:
 *   - Total internal links across published content.
 *   - Orphan pages: published posts/pages that NO other published content
 *     links to (zero inbound internal links).
 *   - Broken internal links: <a href> pointing at this site but resolving to
 *     no published post (404-bound internal links).
 *
 * All work is bounded and cached. The scan walks published content once,
 * extracts internal anchors, resolves them to post IDs, and tallies inbound
 * counts. Results are cached in a transient so the dashboard and report read
 * cheaply; a manual "Rescan" busts the cache.
 *
 * Performance: the walk is capped at a generous ceiling and processed in PHP
 * from a single bounded query rather than N queries. On very large sites the
 * ceiling keeps it from running unbounded; the UI notes when the cap is hit.
 *
 * @package Platform\Helm\Modules\Link_Health
 */

declare( strict_types = 1 );

namespace Platform\Helm\Modules\Link_Health;

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

final class HealthAnalyzer {

	public const CACHE_KEY   = 'platform_helm_health_report_v2';
	public const CACHE_TTL   = 3600; // 1 hour.
	private const SCAN_CEILING = 2000; // Max posts walked per scan.

	/**
	 * Return the cached report, computing it if absent.
	 *
	 * @return array<string,mixed>
	 */
	public function get_report( bool $force = false ): array {
		if ( ! $force ) {
			$cached = get_transient( self::CACHE_KEY );
			if ( is_array( $cached ) ) {
				return $cached;
			}
		}
		$report = $this->compute();
		set_transient( self::CACHE_KEY, $report, self::CACHE_TTL );
		return $report;
	}

	public function bust_cache(): void {
		delete_transient( self::CACHE_KEY );
	}

	/**
	 * @return array<string,mixed>
	 */
	private function compute(): array {
		global $wpdb;

		$types    = $this->scanned_post_types();
		$type_in  = implode( ',', array_fill( 0, count( $types ), '%s' ) );
		$home     = home_url();
		$home_host = (string) wp_parse_url( $home, PHP_URL_HOST );

		$args   = $types;
		$args[] = self::SCAN_CEILING;

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$rows = $wpdb->get_results( $wpdb->prepare(
			"SELECT ID, post_title, post_content, post_type
			 FROM {$wpdb->posts}
			 WHERE post_status = 'publish'
			   AND post_type IN ({$type_in})
			 ORDER BY post_date DESC
			 LIMIT %d",
			$args
		), ARRAY_A );
		$rows = is_array( $rows ) ? $rows : [];

		$total_posts   = count( $rows );
		$capped        = $total_posts >= self::SCAN_CEILING;
		$inbound       = []; // post_id => inbound internal link count.
		$outbound      = []; // post_id => outbound internal link count.
		$broken_per    = []; // post_id => count of its broken internal links.
		$total_links   = 0;
		$broken        = []; // list of ['source_id','source_title','url'].
		$known_ids     = [];

		foreach ( $rows as $r ) {
			$id              = (int) $r['ID'];
			$known_ids[ $id ] = true;
			$inbound[ $id ]   = 0;
			$outbound[ $id ]  = 0;
			$broken_per[ $id ] = 0;
		}

		foreach ( $rows as $r ) {
			$source_id = (int) $r['ID'];
			$hrefs     = $this->extract_internal_hrefs( (string) $r['post_content'], $home_host );
			foreach ( $hrefs as $href ) {
				$total_links++;
				$outbound[ $source_id ] = ( $outbound[ $source_id ] ?? 0 ) + 1;
				$target_id = url_to_postid( $href );
				if ( $target_id > 0 ) {
					if ( ! isset( $inbound[ $target_id ] ) ) {
						$inbound[ $target_id ] = 0;
					}
					if ( $target_id !== $source_id ) {
						$inbound[ $target_id ]++;
					}
				} else {
					// Internal URL that resolves to no published post — broken.
					$broken_per[ $source_id ] = ( $broken_per[ $source_id ] ?? 0 ) + 1;
					if ( count( $broken ) < 100 ) {
						$broken[] = [
							'source_id'    => $source_id,
							'source_title' => (string) ( $r['post_title'] ?: __( '(no title)', 'platform-helm' ) ),
							'url'          => $href,
						];
					}
				}
			}
		}

		// Orphans: known published posts with zero inbound internal links.
		$orphans = [];
		foreach ( $rows as $r ) {
			$id = (int) $r['ID'];
			if ( ( $inbound[ $id ] ?? 0 ) === 0 ) {
				if ( count( $orphans ) < 200 ) {
					$orphans[] = [
						'id'        => $id,
						'title'     => (string) ( $r['post_title'] ?: __( '(no title)', 'platform-helm' ) ),
						'post_type' => (string) $r['post_type'],
						'edit_link' => (string) get_edit_post_link( $id ),
						'view_link' => (string) get_permalink( $id ),
					];
				}
			}
		}

		return [
			'generated_at' => time(),
			'total_posts'  => $total_posts,
			'total_links'  => $total_links,
			'orphan_count' => count( $orphans ),
			'orphans'      => $orphans,
			'broken_count' => count( $broken ),
			'broken'       => $broken,
			'capped'       => $capped,
			'scan_ceiling' => self::SCAN_CEILING,
			// Per-post link graph — used by the per-post meta box. Bounded:
			// only published posts seen by this scan, so it stays proportional
			// to the SCAN_CEILING (~6 KB per 1000 posts).
			'inbound'      => $inbound,
			'outbound'     => $outbound,
			'broken_per'   => $broken_per,
		];
	}

	/**
	 * Look up a single post's link summary from the cached report. Cache-only:
	 * if no scan has run yet, returns null. We deliberately do NOT trigger a
	 * full scan from the post-editor screen — a synchronous walk of every
	 * published post on every "edit post" load is unacceptable on bigger
	 * sites. The meta box handles null by inviting the user to run a scan.
	 *
	 * @return array{inbound:int,outbound:int,broken:int,is_orphan:bool,generated_at:int}|null
	 */
	public function get_post_summary( int $post_id ): ?array {
		$cached = get_transient( self::CACHE_KEY );
		if ( ! is_array( $cached ) ) {
			return null;
		}
		// Newer report shape with per-post graph; older shape (no graph) is
		// treated as "no data for this post" so we prompt a rescan.
		if ( ! isset( $cached['inbound'] ) || ! is_array( $cached['inbound'] ) ) {
			return null;
		}
		return [
			'inbound'      => (int) ( $cached['inbound'][ $post_id ] ?? 0 ),
			'outbound'     => (int) ( $cached['outbound'][ $post_id ] ?? 0 ),
			'broken'       => (int) ( $cached['broken_per'][ $post_id ] ?? 0 ),
			'is_orphan'    => ( (int) ( $cached['inbound'][ $post_id ] ?? 0 ) ) === 0 && isset( $cached['inbound'][ $post_id ] ),
			'generated_at' => (int) ( $cached['generated_at'] ?? 0 ),
		];
	}

	/**
	 * Pull internal <a href> targets from a content blob, normalised to
	 * absolute URLs on this site's host. Skips anchors, mailto, tel, and
	 * off-site links.
	 *
	 * @return string[]
	 */
	private function extract_internal_hrefs( string $html, string $home_host ): array {
		if ( '' === trim( $html ) ) {
			return [];
		}
		$out = [];
		if ( ! preg_match_all( '/<a\b[^>]*href=["\']([^"\']+)["\']/i', $html, $m ) ) {
			return [];
		}
		foreach ( $m[1] as $href ) {
			$href = trim( $href );
			if ( '' === $href ) {
				continue;
			}
			$lower = strtolower( $href );
			if ( 0 === strpos( $lower, '#' )
				|| 0 === strpos( $lower, 'mailto:' )
				|| 0 === strpos( $lower, 'tel:' )
				|| 0 === strpos( $lower, 'javascript:' ) ) {
				continue;
			}
			// Resolve protocol-relative and root-relative URLs.
			if ( 0 === strpos( $href, '//' ) ) {
				$href = ( is_ssl() ? 'https:' : 'http:' ) . $href;
			} elseif ( 0 === strpos( $href, '/' ) ) {
				$href = home_url( $href );
			}
			$host = (string) wp_parse_url( $href, PHP_URL_HOST );
			if ( '' === $host ) {
				continue; // Unparseable.
			}
			if ( $host !== $home_host ) {
				continue; // External link — not our concern here.
			}
			$out[] = $href;
		}
		return $out;
	}

	/**
	 * @return string[]
	 */
	private function scanned_post_types(): array {
		/**
		 * Filter which post types the health report scans.
		 *
		 * @param string[] $types
		 */
		$types = apply_filters( 'platform_helm_link_health_post_types', [ 'post', 'page' ] );
		return is_array( $types ) && ! empty( $types )
			? array_values( array_filter( $types, 'is_string' ) )
			: [ 'post' ];
	}
}
