<?php
/**
 * Livada SEO — Internal Link Graph
 *
 * Crawls every published post / page (within `sitemap_post_types`) and builds
 * the post → post link graph based on internal anchors found in
 * `post_content`. Surfaces:
 *
 *   - Inbound count per post (how many internal links point at it)
 *   - Orphan posts (zero inbound links)
 *   - Hub posts (inbound rank top 10)
 *   - Cornerstone posts that nobody links to (red flag)
 *
 * Pure DB introspection — no outbound HTTP. Results cached in a 12-hour
 * transient because the crawl is bounded but not free on big sites.
 *
 * @package Livada_SEO
 */

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

class Livada_SEO_Link_Graph {

	const CACHE_KEY = 'livada_seo_link_graph';
	const CACHE_TTL = 43200; // 12h
	const MAX_POSTS = 1000;

	// ── Bootstrap ─────────────────────────────────────────────────────────────

	public static function init(): void {
		add_action( 'admin_menu',                    [ self::class, 'register_menu' ], 35 );
		add_action( 'wp_ajax_livada_seo_link_graph', [ self::class, 'ajax_rebuild'  ] );

		// Invalidate the cache when posts change.
		add_action( 'save_post',   [ self::class, 'invalidate' ] );
		add_action( 'delete_post', [ self::class, 'invalidate' ] );
	}

	public static function register_menu(): void {
		add_submenu_page(
			'livada-seo',
			__( 'Internal Links — Livada SEO', 'livada-seo' ),
			__( 'Internal Links', 'livada-seo' ),
			'manage_options',
			'livada-seo-links',
			[ self::class, 'render_page' ]
		);
	}

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

	// ── Build the graph ───────────────────────────────────────────────────────

	/**
	 * Returns the cached link graph, building it on cache miss.
	 *
	 * @return array<string,mixed>
	 */
	public static function get_graph( bool $force = false ): array {
		if ( ! $force ) {
			$cached = get_transient( self::CACHE_KEY );
			if ( is_array( $cached ) ) {
				return $cached;
			}
		}
		return self::build( true );
	}

	/**
	 * Crawls posts and assembles the graph.
	 *
	 * Output shape:
	 * [
	 *   generated_at: string,
	 *   stats: { posts_scanned, edges_total, orphans, cornerstones, lonely_cornerstones },
	 *   posts: [
	 *     post_id => {
	 *       title, edit_url, view_url, post_type,
	 *       outbound: int,
	 *       inbound:  int,
	 *       cornerstone: bool,
	 *     }
	 *   ],
	 *   top_hubs: [ post_id, ... ]            // top 10 by inbound
	 *   orphans: [ post_id, ... ]             // 0 inbound
	 *   lonely_cornerstones: [ post_id, ... ] // cornerstone with 0 inbound
	 * ]
	 *
	 * @return array<string,mixed>
	 */
	public static function build( bool $cache = true ): array {
		$started = microtime( true );

		$post_types = (array) Livada_SEO_Loader::get_setting( 'sitemap_post_types', [ 'post', 'page' ] );
		if ( class_exists( 'Livada_SEO_Sitemap' ) ) {
			$deny       = Livada_SEO_Sitemap::get_template_cpts();
			$post_types = array_values( array_diff( $post_types, $deny ) );
		}
		if ( empty( $post_types ) ) {
			$post_types = [ 'post', 'page' ];
		}

		$query = new WP_Query( [
			'post_type'              => $post_types,
			'post_status'            => 'publish',
			'posts_per_page'         => self::MAX_POSTS,
			'orderby'                => 'modified',
			'order'                  => 'DESC',
			'no_found_rows'          => true,
			'update_post_term_cache' => false,
			'update_post_meta_cache' => true,
		] );

		// First pass: collect every post + its outbound permalinks. Second
		// pass: count inbound links by walking the `target_pid` set.
		$posts   = [];
		$by_url  = [];

		foreach ( $query->posts as $p ) {
			$pid    = (int) $p->ID;
			$url    = (string) get_permalink( $pid );
			$canon  = self::canonical_path( $url );

			$posts[ $pid ] = [
				'post_id'     => $pid,
				'title'       => get_the_title( $p ),
				'edit_url'    => (string) get_edit_post_link( $pid, 'raw' ),
				'view_url'    => $url,
				'post_type'   => $p->post_type,
				'outbound'    => 0,
				'inbound'     => 0,
				'cornerstone' => (bool) get_post_meta( $pid, '_livada_seo_cornerstone', true ),
			];

			if ( '' !== $canon ) {
				$by_url[ $canon ] = $pid;
			}
		}

		$site_host = (string) wp_parse_url( home_url(), PHP_URL_HOST );

		$edges_total = 0;

		foreach ( $query->posts as $p ) {
			$pid     = (int) $p->ID;
			$content = (string) $p->post_content;

			if ( '' === $content ) {
				continue;
			}

			// Walk every <a href>.
			if ( preg_match_all( '/<a\s[^>]*href=["\']([^"\']+)["\'][^>]*>/i', $content, $m ) ) {
				$seen_targets = [];

				foreach ( $m[1] as $href ) {
					$href = trim( $href );
					if ( '' === $href || strpos( $href, '#' ) === 0 || strpos( $href, 'mailto:' ) === 0 || strpos( $href, 'tel:' ) === 0 ) {
						continue;
					}

					// Filter to internal links only.
					$is_internal = false;
					if ( strpos( $href, '//' ) === false && strpos( $href, 'http' ) !== 0 ) {
						$is_internal = true;
					} else {
						$h = (string) wp_parse_url( $href, PHP_URL_HOST );
						if ( '' !== $h && $h === $site_host ) {
							$is_internal = true;
						}
					}

					if ( ! $is_internal ) {
						continue;
					}

					$canon  = self::canonical_path( $href );
					if ( '' === $canon || ! isset( $by_url[ $canon ] ) ) {
						continue;
					}

					$target_pid = (int) $by_url[ $canon ];
					if ( $target_pid === $pid || isset( $seen_targets[ $target_pid ] ) ) {
						continue; // self-link or duplicate within same post
					}

					$seen_targets[ $target_pid ] = true;

					$posts[ $pid ]['outbound']++;
					if ( isset( $posts[ $target_pid ] ) ) {
						$posts[ $target_pid ]['inbound']++;
					}
					$edges_total++;
				}
			}
		}

		wp_reset_postdata();

		// Top hubs (top 10 by inbound).
		$top_hubs = [];
		uasort( $posts, static function ( $a, $b ) {
			return $b['inbound'] - $a['inbound'];
		} );
		$top_hubs = array_slice( array_keys( $posts ), 0, 10 );

		// Orphans (zero inbound, excluding the home page).
		$orphans = [];
		foreach ( $posts as $row ) {
			if ( 0 === (int) $row['inbound'] ) {
				$orphans[] = (int) $row['post_id'];
			}
		}

		// Lonely cornerstones — cornerstone posts with inbound == 0.
		$lonely = [];
		foreach ( $posts as $row ) {
			if ( $row['cornerstone'] && 0 === (int) $row['inbound'] ) {
				$lonely[] = (int) $row['post_id'];
			}
		}

		$payload = [
			'generated_at' => current_time( 'mysql' ),
			'duration_ms'  => (int) round( ( microtime( true ) - $started ) * 1000 ),
			'stats'        => [
				'posts_scanned'        => count( $posts ),
				'edges_total'          => $edges_total,
				'orphans'              => count( $orphans ),
				'cornerstones'         => count( array_filter( $posts, static fn( $r ) => $r['cornerstone'] ) ),
				'lonely_cornerstones'  => count( $lonely ),
			],
			'posts'                => $posts,
			'top_hubs'             => $top_hubs,
			'orphans'              => $orphans,
			'lonely_cornerstones'  => $lonely,
		];

		if ( $cache ) {
			set_transient( self::CACHE_KEY, $payload, self::CACHE_TTL );
		}

		return $payload;
	}

	/**
	 * Returns a comparable canonical key for a post URL — strips host, scheme,
	 * trailing slash, and query/fragment so http vs https / www vs no-www / a
	 * trailing `/` don't break the matching.
	 */
	private static function canonical_path( string $url ): string {
		if ( '' === $url ) {
			return '';
		}
		$path = '';

		if ( strpos( $url, '://' ) !== false ) {
			$path = (string) wp_parse_url( $url, PHP_URL_PATH );
		} else {
			$path = (string) wp_parse_url( '/' . ltrim( $url, '/' ), PHP_URL_PATH );
		}

		$path = '/' . ltrim( $path, '/' );
		$path = rtrim( $path, '/' );
		return '' === $path ? '/' : $path;
	}

	// ── AJAX rebuild ──────────────────────────────────────────────────────────

	public static function ajax_rebuild(): void {
		check_ajax_referer( 'livada_seo_admin_nonce', 'nonce' );
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'livada-seo' ) ], 403 );
		}
		$payload = self::build( true );
		wp_send_json_success( $payload['stats'] );
	}

	// ── Admin page ────────────────────────────────────────────────────────────

	public static function render_page(): void {
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'Insufficient permissions.', 'livada-seo' ) );
		}

		$graph = self::get_graph();
		$stats = $graph['stats'];
		$posts = $graph['posts'];

		// Filter ?show=orphans|hubs|lonely (default: hubs).
		$show = isset( $_GET['show'] ) ? sanitize_key( $_GET['show'] ) : 'hubs'; // phpcs:ignore
		if ( ! in_array( $show, [ 'hubs', 'orphans', 'lonely' ], true ) ) {
			$show = 'hubs';
		}

		switch ( $show ) {
			case 'orphans':
				$ids = $graph['orphans'];
				$label = __( 'Orphans (no inbound link)', 'livada-seo' );
				break;
			case 'lonely':
				$ids = $graph['lonely_cornerstones'];
				$label = __( 'Lonely cornerstones', 'livada-seo' );
				break;
			case 'hubs':
			default:
				$ids = $graph['top_hubs'];
				$label = __( 'Top inbound hubs', 'livada-seo' );
				break;
		}
		?>
		<div class="wrap livada-seo-wrap">
			<h1>
				<?php esc_html_e( 'Internal Link Graph', 'livada-seo' ); ?>
				<button type="button" id="livada-link-graph-rebuild" class="page-title-action">
					<?php esc_html_e( 'Rebuild now', 'livada-seo' ); ?>
				</button>
				<span id="livada-link-graph-status" class="description" style="margin-left:8px;"></span>
			</h1>
			<p class="description">
				<?php
				printf(
					/* translators: 1: post count, 2: edge count, 3: ms */
					esc_html__( 'Last build: %1$d posts scanned, %2$d internal edges (%3$d ms).', 'livada-seo' ),
					(int) $stats['posts_scanned'],
					(int) $stats['edges_total'],
					(int) $graph['duration_ms']
				);
				?>
			</p>

			<div class="livada-card-grid" style="margin: 20px 0;">
				<a class="livada-card livada-card--link <?php echo $stats['orphans'] > 0 ? 'livada-card--warning' : 'livada-card--ok'; ?>"
				   href="<?php echo esc_url( add_query_arg( 'show', 'orphans' ) ); ?>">
					<div class="livada-card__icon dashicons dashicons-warning"></div>
					<div class="livada-card__body">
						<h2 class="livada-card__value"><?php echo (int) $stats['orphans']; ?></h2>
						<p class="livada-card__label"><?php esc_html_e( 'Orphan posts', 'livada-seo' ); ?></p>
					</div>
				</a>

				<a class="livada-card livada-card--link"
				   href="<?php echo esc_url( add_query_arg( 'show', 'hubs' ) ); ?>">
					<div class="livada-card__icon dashicons dashicons-networking"></div>
					<div class="livada-card__body">
						<h2 class="livada-card__value"><?php echo (int) $stats['edges_total']; ?></h2>
						<p class="livada-card__label"><?php esc_html_e( 'Internal edges', 'livada-seo' ); ?></p>
					</div>
				</a>

				<a class="livada-card livada-card--link <?php echo $stats['lonely_cornerstones'] > 0 ? 'livada-card--warning' : ''; ?>"
				   href="<?php echo esc_url( add_query_arg( 'show', 'lonely' ) ); ?>">
					<div class="livada-card__icon dashicons dashicons-star-filled"></div>
					<div class="livada-card__body">
						<h2 class="livada-card__value"><?php echo (int) $stats['lonely_cornerstones']; ?></h2>
						<p class="livada-card__label"><?php esc_html_e( 'Lonely cornerstones', 'livada-seo' ); ?></p>
					</div>
				</a>
			</div>

			<h2><?php echo esc_html( $label ); ?></h2>
			<?php if ( empty( $ids ) ) : ?>
				<p class="description"><?php esc_html_e( 'Nothing to show.', 'livada-seo' ); ?></p>
			<?php else : ?>
				<table class="wp-list-table widefat fixed striped">
					<thead>
						<tr>
							<th><?php esc_html_e( 'Post', 'livada-seo' ); ?></th>
							<th style="width: 80px; text-align:right;"><?php esc_html_e( 'Inbound', 'livada-seo' ); ?></th>
							<th style="width: 80px; text-align:right;"><?php esc_html_e( 'Outbound', 'livada-seo' ); ?></th>
							<th style="width: 90px;"><?php esc_html_e( 'Cornerstone', 'livada-seo' ); ?></th>
							<th style="width: 120px;"><?php esc_html_e( 'Actions', 'livada-seo' ); ?></th>
						</tr>
					</thead>
					<tbody>
						<?php foreach ( $ids as $pid ) :
							$row = $posts[ $pid ] ?? null;
							if ( ! $row ) { continue; }
						?>
							<tr>
								<td>
									<strong><?php echo esc_html( $row['title'] ); ?></strong>
									<br><small><?php echo esc_html( $row['post_type'] ); ?> #<?php echo (int) $pid; ?></small>
								</td>
								<td style="text-align:right;"><?php echo (int) $row['inbound']; ?></td>
								<td style="text-align:right;"><?php echo (int) $row['outbound']; ?></td>
								<td><?php echo $row['cornerstone'] ? '★' : '—'; ?></td>
								<td>
									<a href="<?php echo esc_url( $row['edit_url'] ); ?>"><?php esc_html_e( 'Edit', 'livada-seo' ); ?></a>
									·
									<a href="<?php echo esc_url( $row['view_url'] ); ?>" target="_blank" rel="noopener"><?php esc_html_e( 'View', 'livada-seo' ); ?></a>
								</td>
							</tr>
						<?php endforeach; ?>
					</tbody>
				</table>
			<?php endif; ?>
		</div>

		<script>
		(function () {
			var data    = window.livadaSEO || {};
			var ajaxUrl = data.ajaxUrl || '<?php echo esc_js( admin_url( 'admin-ajax.php' ) ); ?>';
			var nonce   = data.nonce   || '<?php echo esc_js( wp_create_nonce( 'livada_seo_admin_nonce' ) ); ?>';

			var btn = document.getElementById('livada-link-graph-rebuild');
			var st  = document.getElementById('livada-link-graph-status');
			if (!btn) return;

			btn.addEventListener('click', function () {
				btn.disabled = true;
				st.textContent = '<?php echo esc_js( __( 'Rebuilding…', 'livada-seo' ) ); ?>';
				var body = new URLSearchParams({ action: 'livada_seo_link_graph', nonce: nonce });
				fetch(ajaxUrl, {
					method: 'POST', credentials: 'same-origin',
					headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
					body: body.toString()
				})
					.then(function (r) { return r.json(); })
					.then(function () { window.location.reload(); })
					.catch(function () { btn.disabled = false; st.textContent = '<?php echo esc_js( __( 'Failed.', 'livada-seo' ) ); ?>'; });
			});
		})();
		</script>
		<?php
	}
}
