<?php
/**
 * Broken Link Checker Module
 *
 * Batch-scans post content for broken internal and external links.
 *
 * @package BrokenLinkChecker
 * @since 1.0.0
 */

namespace BrokenLinkChecker;

use WP_PowerSuite\Core\Contracts\DataLocationsInterface;
use WP_PowerSuite\Core\AbstractModule;

class Module extends AbstractModule implements DataLocationsInterface {

	private const MODULE_ID        = 'broken-link-checker';
	private const MODULE_VERSION = '1.1.0';
	private const MAX_EXECUTION    = 35;
	private const POSTS_PER_BATCH  = 20;
	private const URL_CHECKS_PER_BATCH = 12;
	private const SCAN_TTL         = 3600;
	private const LOCK_TTL         = 600;

	public function getId(): string {
		return self::MODULE_ID;
	}

	public function getName(): string {
		return __( 'Broken Link Checker', 'wppowersuite-lite' );
	}

	public function getVersion(): string {
		return self::MODULE_VERSION;
	}

	public function getDescription(): string {
		return __( 'Batch-scans published content for broken links with export and SSRF-safe HTTP checks.', 'wppowersuite-lite' );
	}

	public function getContext(): string|array {
		return [ 'admin', 'ajax' ];
	}

	public function getDependencies(): array {
		return [];
	}

	private function isLicenseActive(): bool {
		return function_exists( 'wp_powersuite_is_license_valid' ) && wp_powersuite_is_license_valid();
	}

	protected function registerHooks(): void {
		if ( ! $this->isLicenseActive() ) {
			return;
		}

		// Scan runs on demand via admin AJAX only.
	}

	public function getDataLocations(): array {
		return [
			'option_store_keys' => [ self::MODULE_ID ],
			'transients'        => [ 'wpps_blc_scan_*', 'wpps_blc_scan_lock_*' ],
		];
	}

	/**
	 * Load module dependencies (module PHP is not autoloaded during AJAX).
	 */
	public static function loadDependencies(): void {
		static $loaded = false;
		if ( $loaded ) {
			return;
		}

		// Always load from this module folder. MODULES_DIR points at Lite and
		// misses these files when the class was bootstrapped from the Pro add-on.
		$dir   = __DIR__ . DIRECTORY_SEPARATOR . 'includes' . DIRECTORY_SEPARATOR;
		$files = [
			'class-url-safety.php',
			'class-link-extractor.php',
			'class-http-link-checker.php',
		];
		foreach ( $files as $file ) {
			$path = $dir . $file;
			if ( ! is_readable( $path ) ) {
				throw new \RuntimeException( 'Broken Link Checker dependency missing: ' . $file );
			}
			require_once $path;
		}
		$loaded = true;
	}

	/**
	 * @return array<string>
	 */
	public static function getAllowedPostTypes(): array {
		self::loadDependencies();
		return UrlSafety::getAllowedPostTypes();
	}

	/**
	 * @param array<string, mixed> $settings
	 * @return array<string, mixed>
	 */
	public static function normalizeSettings( array $settings ): array {
		self::loadDependencies();
		return UrlSafety::normalizeSettings( $settings );
	}

	/**
	 * @return array<string>
	 */
	public function getPostTypesToScan(): array {
		self::loadDependencies();
		$saved = $this->moduleSettings()->all( [] );
		$types = $saved['post_types'] ?? [ 'post', 'page' ];
		if ( ! is_array( $types ) ) {
			$types = [ 'post', 'page' ];
		}
		$allowed = array_flip( UrlSafety::getAllowedPostTypes() );
		$clean   = [];
		foreach ( $types as $type ) {
			$key = sanitize_key( (string) $type );
			if ( $key !== '' && isset( $allowed[ $key ] ) ) {
				$clean[] = $key;
			}
		}
		return $clean !== [] ? array_values( $clean ) : [ 'post', 'page' ];
	}

	/**
	 * Start, continue, or cancel a batched scan.
	 *
	 * @param string               $action     start|continue|cancel
	 * @param array<string>|null   $post_types Post types when starting.
	 * @param string               $scan_id    Existing scan ID for continue/cancel.
	 * @return array<string, mixed>|\WP_Error
	 */
	public function runScanAction( string $action, ?array $post_types, string $scan_id = '' ): array|\WP_Error {
		self::loadDependencies();

		$action = sanitize_key( $action );
		if ( $action === 'cancel' ) {
			$this->releaseScanLock();
			if ( $scan_id !== '' ) {
				delete_transient( self::scanTransientKey( $scan_id ) );
			}
			return [ 'cancelled' => true ];
		}

		if ( $action === 'start' ) {
			// A failed or abandoned batch leaves a per-user lock for LOCK_TTL.
			// Start Scan should always begin a new session for this user.
			$this->releaseScanLock();
			return $this->startScan( $post_types );
		}

		if ( $action === 'continue' && $scan_id !== '' ) {
			return $this->continueScan( $scan_id );
		}

		return new \WP_Error( 'invalid_action', __( 'Invalid scan action.', 'wppowersuite-lite' ) );
	}

	/**
	 * @param array<string>|null $post_types
	 * @return array<string, mixed>
	 */
	private function startScan( ?array $post_types ): array {
		if ( $post_types === null ) {
			$post_types = $this->getPostTypesToScan();
		}
		$post_types = $this->sanitizePostTypes( $post_types );

		$total_posts = $this->countPosts( $post_types );
		$scan_id     = wp_generate_password( 16, false, false );
		$state       = [
			'scan_id'            => $scan_id,
			'post_types'         => $post_types,
			'last_post_id'       => 0,
			'total_posts'        => $total_posts,
			'posts_scanned'      => 0,
			'unique_urls_checked'=> 0,
			'broken_links'       => [],
			'redirect_links'     => [],
			'inconclusive_links' => [],
			'url_cache'          => [],
			'pending_urls'       => [],
			'recorded_results'   => [],
			'started_at'         => microtime( true ),
			'elapsed'            => 0.0,
		];

		set_transient( self::scanTransientKey( $scan_id ), $state, self::SCAN_TTL );
		$this->acquireScanLock( $scan_id );

		return $this->processBatch( $state );
	}

	/**
	 * @return array<string, mixed>|\WP_Error
	 */
	private function continueScan( string $scan_id ): array|\WP_Error {
		$scan_id = sanitize_key( $scan_id );
		$state   = get_transient( self::scanTransientKey( $scan_id ) );
		if ( ! is_array( $state ) ) {
			$this->releaseScanLock();
			return new \WP_Error( 'scan_expired', __( 'Scan session expired. Please start a new scan.', 'wppowersuite-lite' ) );
		}

		if ( ! $this->isScanLocked( $scan_id ) ) {
			return new \WP_Error( 'scan_lock_lost', __( 'Scan lock expired. Please start a new scan.', 'wppowersuite-lite' ) );
		}

		$this->acquireScanLock( $scan_id );
		return $this->processBatch( $state );
	}

	/**
	 * @param array<string, mixed> $state
	 * @return array<string, mixed>
	 */
	private function processBatch( array $state ): array {
		$start_time = microtime( true );
		$checker    = new HttpLinkChecker();
		$post_types = is_array( $state['post_types'] ?? null ) ? $state['post_types'] : [ 'post', 'page' ];
		$url_cache  = is_array( $state['url_cache'] ?? null ) ? $state['url_cache'] : [];
		$pending    = is_array( $state['pending_urls'] ?? null ) ? $state['pending_urls'] : [];

		$last_post_id = (int) ( $state['last_post_id'] ?? 0 );
		$posts        = $this->fetchPublishedPosts( $post_types, $last_post_id, self::POSTS_PER_BATCH );

		foreach ( $posts as $post ) {
			if ( ! $post instanceof \WP_Post ) {
				continue;
			}
			if ( $this->isTimeBudgetExceeded( $start_time ) ) {
				break;
			}

			$state['posts_scanned'] = (int) ( $state['posts_scanned'] ?? 0 ) + 1;
			$state['last_post_id']  = (int) $post->ID;
			$title                  = ! empty( $post->post_title ) ? wp_strip_all_tags( $post->post_title ) : __( '(No Title)', 'wppowersuite-lite' );

			foreach ( LinkExtractor::uniqueCanonicalFromPost( $post ) as $url ) {
				if ( isset( $url_cache[ $url ] ) ) {
					$this->appendLinkResults( $state, (int) $post->ID, $title, $url, $url_cache[ $url ] );
					continue;
				}

				if ( ! isset( $pending[ $url ] ) ) {
					$pending[ $url ] = [];
				}
				if ( $this->pendingHasPost( $pending[ $url ], (int) $post->ID ) ) {
					continue;
				}
				$pending[ $url ][] = [
					'post_id'    => (int) $post->ID,
					'post_title' => $title,
				];
			}
		}

		$url_checks = 0;
		foreach ( array_keys( $pending ) as $url ) {
			if ( $url_checks >= self::URL_CHECKS_PER_BATCH || $this->isTimeBudgetExceeded( $start_time ) ) {
				break;
			}

			if ( isset( $url_cache[ $url ] ) ) {
				foreach ( $pending[ $url ] as $ref ) {
					$this->appendLinkResults( $state, (int) $ref['post_id'], (string) $ref['post_title'], $url, $url_cache[ $url ] );
				}
				unset( $pending[ $url ] );
				continue;
			}

			$result            = $checker->check( $url );
			$url_cache[ $url ]  = $result;
			++$url_checks;
			$state['unique_urls_checked'] = (int) ( $state['unique_urls_checked'] ?? 0 ) + 1;

			foreach ( $pending[ $url ] as $ref ) {
				$this->appendLinkResults( $state, (int) $ref['post_id'], (string) $ref['post_title'], $url, $result );
			}
			unset( $pending[ $url ] );
		}

		$state['url_cache']    = $url_cache;
		$state['pending_urls'] = $pending;
		$state['elapsed']      = round( microtime( true ) - (float) ( $state['started_at'] ?? microtime( true ) ), 2 );

		$total_posts   = (int) ( $state['total_posts'] ?? 0 );
		$posts_scanned = min( (int) ( $state['posts_scanned'] ?? 0 ), max( 0, $total_posts ) );
		$has_more_posts = $posts_scanned < $total_posts
			&& $this->hasPublishedPostsAfter( $post_types, (int) $state['last_post_id'] );
		$pending_count  = count( $pending );
		$has_more       = $has_more_posts || $pending_count > 0;
		$phase          = $has_more_posts ? 'posts' : 'links';

		if ( $has_more ) {
			set_transient( self::scanTransientKey( (string) $state['scan_id'] ), $state, self::SCAN_TTL );
		} else {
			delete_transient( self::scanTransientKey( (string) ( $state['scan_id'] ?? '' ) ) );
			$this->releaseScanLock();
		}

		$progress = $this->calculateProgress( $posts_scanned, $total_posts, (int) ( $state['unique_urls_checked'] ?? 0 ), $pending_count, $has_more_posts );

		return [
			'scan_id'             => (string) ( $state['scan_id'] ?? '' ),
			'has_more'            => $has_more,
			'phase'               => $phase,
			'progress'            => $progress,
			'pending_urls'        => $pending_count,
			'broken_links'        => $this->dedupeResultEntries( $state['broken_links'] ?? [] ),
			'redirect_links'      => $this->dedupeResultEntries( $state['redirect_links'] ?? [] ),
			'inconclusive_links'  => $this->dedupeResultEntries( $state['inconclusive_links'] ?? [] ),
			'total_urls'          => (int) ( $state['unique_urls_checked'] ?? 0 ),
			'total_posts'         => $total_posts,
			'posts_scanned'       => $posts_scanned,
			'scan_time'           => (float) ( $state['elapsed'] ?? 0 ),
			'timeout_reached'     => $has_more && $this->isTimeBudgetExceeded( $start_time ),
		];
	}

	private function isTimeBudgetExceeded( float $start_time ): bool {
		return ( microtime( true ) - $start_time ) > self::MAX_EXECUTION;
	}

	private function calculateProgress( int $posts_scanned, int $total_posts, int $urls_checked, int $pending_urls, bool $has_more_posts ): int {
		if ( $total_posts <= 0 && $pending_urls <= 0 ) {
			return 100;
		}

		if ( $has_more_posts && $total_posts > 0 ) {
			return min( 85, (int) round( ( $posts_scanned / $total_posts ) * 85 ) );
		}

		$urls_total = max( 1, $urls_checked + $pending_urls );
		return min( 100, 85 + (int) round( ( $urls_checked / $urls_total ) * 15 ) );
	}

	/**
	 * @param array<string, mixed> $state
	 * @param array<string, mixed> $result
	 */
	private function appendLinkResults( array &$state, int $post_id, string $post_title, string $url, array $result ): void {
		$status_class = (string) ( $result['status_class'] ?? 'broken' );
		if ( $status_class === 'ok' || ( $result['working'] ?? false ) ) {
			return;
		}

		$record_key = $post_id . '|' . $url;
		if ( ! isset( $state['recorded_results'] ) || ! is_array( $state['recorded_results'] ) ) {
			$state['recorded_results'] = [];
		}
		if ( isset( $state['recorded_results'][ $record_key ] ) ) {
			return;
		}
		$state['recorded_results'][ $record_key ] = true;

		$entry = $this->createEntry( $post_id, $post_title, $url, $result );
		if ( $status_class === 'broken' ) {
			$state['broken_links'][] = $entry;
		} elseif ( $status_class === 'redirect' ) {
			$state['redirect_links'][] = $entry;
		} elseif ( $status_class === 'inconclusive' ) {
			$state['inconclusive_links'][] = $entry;
		}
	}

	/**
	 * @param list<array<string, mixed>> $refs
	 */
	private function pendingHasPost( array $refs, int $post_id ): bool {
		foreach ( $refs as $ref ) {
			if ( (int) ( $ref['post_id'] ?? 0 ) === $post_id ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * @param list<array<string, mixed>> $entries
	 * @return list<array<string, mixed>>
	 */
	private function dedupeResultEntries( array $entries ): array {
		$seen    = [];
		$deduped = [];
		foreach ( $entries as $entry ) {
			if ( ! is_array( $entry ) ) {
				continue;
			}
			$key = (int) ( $entry['post_id'] ?? 0 ) . '|' . (string) ( $entry['url'] ?? '' );
			if ( isset( $seen[ $key ] ) ) {
				continue;
			}
			$seen[ $key ] = true;
			$deduped[]    = $entry;
		}
		return array_values( $deduped );
	}

	/**
	 * @param array<string, mixed> $result
	 * @return array<string, mixed>
	 */
	private function createEntry( int $post_id, string $post_title, string $url, array $result ): array {
		$title = $post_title !== '' ? wp_strip_all_tags( $post_title ) : __( '(No Title)', 'wppowersuite-lite' );
		return [
			'post_id'       => $post_id,
			'post_title'    => $title,
			'edit_url'      => esc_url( get_edit_post_link( $post_id, 'raw' ) ?: '' ),
			'url'           => esc_url( $url ),
			'status'        => sanitize_text_field( (string) ( $result['error'] ?? '' ) ),
			'type'          => sanitize_text_field( (string) ( $result['type'] ?? '' ) ),
			'status_class'  => sanitize_key( (string) ( $result['status_class'] ?? 'broken' ) ),
			'redirect_hops' => (int) ( $result['redirect_hops'] ?? 0 ),
		];
	}

	/**
	 * @param array<string> $post_types
	 * @return array<string>
	 */
	private function sanitizePostTypes( array $post_types ): array {
		$allowed = array_flip( UrlSafety::getAllowedPostTypes() );
		$clean   = [];
		foreach ( $post_types as $type ) {
			$key = sanitize_key( (string) $type );
			if ( $key !== '' && isset( $allowed[ $key ] ) ) {
				$clean[] = $key;
			}
		}
		return $clean !== [] ? array_values( $clean ) : [ 'post', 'page' ];
	}

	/**
	 * @param array<string> $post_types
	 * @return array<int, \WP_Post>
	 */
	private function fetchPublishedPosts( array $post_types, int $after_post_id, int $limit ): array {
		$ids = $this->queryPublishedPostIds( $post_types, $after_post_id, $limit );
		$posts = [];
		foreach ( $ids as $id ) {
			$post = get_post( $id );
			if ( $post instanceof \WP_Post ) {
				$posts[] = $post;
			}
		}
		return $posts;
	}

	/**
	 * @param array<string> $post_types
	 */
	private function hasPublishedPostsAfter( array $post_types, int $after_post_id ): bool {
		return $this->fetchPublishedPosts( $post_types, $after_post_id, 1 ) !== [];
	}

	/**
	 * @param array<string> $post_types
	 */
	private function countPosts( array $post_types ): int {
		global $wpdb;

		$in = $this->postTypeInClause( $post_types );
		if ( $in === '' ) {
			return 0;
		}

		return (int) $wpdb->get_var(
			"SELECT COUNT(ID) FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN ({$in})"
		);
	}

	/**
	 * Direct table read so theme/plugin WP_Query hooks cannot abort the scan.
	 *
	 * @param array<string> $post_types
	 * @return list<int>
	 */
	private function queryPublishedPostIds( array $post_types, int $after_post_id, int $limit ): array {
		global $wpdb;

		$in    = $this->postTypeInClause( $post_types );
		$limit = max( 1, min( 100, $limit ) );
		if ( $in === '' ) {
			return [];
		}

		$sql = "SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN ({$in})";
		if ( $after_post_id > 0 ) {
			$sql .= $wpdb->prepare( ' AND ID > %d', $after_post_id );
		}
		$sql .= $wpdb->prepare( ' ORDER BY ID ASC LIMIT %d', $limit );

		$ids = $wpdb->get_col( $sql );
		if ( ! is_array( $ids ) ) {
			return [];
		}

		return array_values( array_filter( array_map( 'absint', $ids ) ) );
	}

	/**
	 * Allowlisted post types only; each value is sanitize_key'd then escaped.
	 */
	private function postTypeInClause( array $post_types ): string {
		global $wpdb;

		$parts = [];
		foreach ( $this->sanitizePostTypes( $post_types ) as $type ) {
			$prepared = $wpdb->prepare( '%s', $type );
			if ( is_string( $prepared ) && $prepared !== '' ) {
				$parts[] = $prepared;
			}
		}
		return implode( ',', $parts );
	}

	private static function scanTransientKey( string $scan_id ): string {
		return 'wpps_blc_scan_' . sanitize_key( $scan_id );
	}

	private function lockTransientKey(): string {
		return 'wpps_blc_scan_lock_' . get_current_user_id();
	}

	private function acquireScanLock( string $scan_id ): void {
		set_transient( $this->lockTransientKey(), sanitize_key( $scan_id ), self::LOCK_TTL );
	}

	private function releaseScanLock(): void {
		delete_transient( $this->lockTransientKey() );
	}

	private function isScanLocked( string $scan_id = '' ): bool {
		$locked = get_transient( $this->lockTransientKey() );
		if ( $locked === false ) {
			return false;
		}
		if ( $scan_id === '' ) {
			return true;
		}
		return (string) $locked === sanitize_key( $scan_id );
	}
}
