<?php
/**
 * `wp blaminhor broken-links ...` — Broken Links Checker commands.
 *
 * @package BlaminhorEssentials
 */

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

if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {
	return;
}

/**
 * Read, recheck and clean up broken-link entries produced by the scanner.
 *
 * Scanning itself runs as an AJAX batch in the admin UI (one post per
 * batch to avoid PHP timeouts). The CLI surfaces the persisted results
 * (list/dismiss/delete) and exposes a single-URL check helper.
 */
class Blaminhor_Essentials_CLI_Broken_Links extends Blaminhor_Essentials_CLI_Module {

	/**
	 * @var string
	 */
	protected $module_id = 'broken-links';

	/**
	 * Show the current Broken Links settings + last scan timestamp.
	 *
	 * @when after_wp_load
	 *
	 * @param array $args       Positional args (unused).
	 * @param array $assoc_args Associative args.
	 */
	public function get( $args, $assoc_args ) {
		$module   = $this->get_module_instance();
		$settings = $module->get_effective_settings();
		$rows = array(
			array( 'key' => 'post_types',       'value' => implode( ',', (array) $settings['post_types'] ) ),
			array( 'key' => 'check_external',   'value' => $settings['check_external'] ? 'true' : 'false' ),
			array( 'key' => 'check_internal',   'value' => $settings['check_internal'] ? 'true' : 'false' ),
			array( 'key' => 'timeout',          'value' => (string) (int) $settings['timeout'] ),
			array( 'key' => 'posts_per_batch',  'value' => (string) (int) $settings['posts_per_batch'] ),
			array( 'key' => 'last_scan',        'value' => $settings['last_scan'] ? (string) date_i18n( 'Y-m-d H:i:s', (int) $settings['last_scan'] ) : 'never' ),
		);
		$this->format_for_output( $rows, $assoc_args, array( 'key', 'value' ) );
	}

	/**
	 * Update Broken Links scanner settings.
	 *
	 * ## OPTIONS
	 *
	 * [--post-types=<csv>]
	 * : Comma-separated post type slugs to scan.
	 *
	 * [--check-external=<bool>]
	 * [--check-internal=<bool>]
	 * [--timeout=<seconds>]
	 * : HTTP timeout per URL (default 10).
	 *
	 * @when after_wp_load
	 *
	 * @param array $args       Positional args (unused).
	 * @param array $assoc_args Associative args.
	 */
	public function set( $args, $assoc_args ) {
		$this->ensure_capabilities();
		$module  = $this->get_module_instance();
		$current = $module->get_effective_settings();

		$data = array(
			'timeout' => isset( $assoc_args['timeout'] ) ? (string) (int) $assoc_args['timeout'] : (string) (int) $current['timeout'],
		);

		if ( isset( $assoc_args['post-types'] ) ) {
			$data['post_types'] = array_filter( array_map( 'sanitize_key', explode( ',', (string) $assoc_args['post-types'] ) ) );
		} else {
			$data['post_types'] = (array) $current['post_types'];
		}

		foreach ( array( 'check-external' => 'check_external', 'check-internal' => 'check_internal' ) as $flag => $key ) {
			if ( array_key_exists( $flag, $assoc_args ) ) {
				if ( $this->parse_bool( $assoc_args[ $flag ] ) ) {
					$data[ $key ] = '1';
				}
			} elseif ( ! empty( $current[ $key ] ) ) {
				$data[ $key ] = '1';
			}
		}

		$result = $module->apply_settings( $data );
		if ( is_wp_error( $result ) ) {
			WP_CLI::error( $result->get_error_message() );
		}

		blaminhor_essentials_log( 'Broken Links settings updated via WP-CLI.', 'info', 'cli', array( 'event' => 'broken_links_set' ) );
		WP_CLI::success( __( 'Broken Links settings updated.', 'blaminhor-essentials' ) );
	}

	/**
	 * List broken links currently stored in the database.
	 *
	 * ## OPTIONS
	 *
	 * [--include-dismissed]
	 * : Also include links that were marked as dismissed.
	 *
	 * [--fields=<fields>]
	 * : Columns to display. Available: id, post_id, post_title, url, anchor_text,
	 *   status_code, error_message, link_type, checked_at, dismissed.
	 *
	 * [--format=<format>]
	 * : Render format (table, json, csv, yaml, count, ids). Default: table.
	 *
	 * @subcommand list
	 * @when after_wp_load
	 *
	 * @param array $args       Positional args (unused).
	 * @param array $assoc_args Associative args.
	 */
	public function list_( $args, $assoc_args ) {
		$module = $this->get_module_instance();
		if ( ! method_exists( $module, 'get_broken_links' ) ) {
			WP_CLI::error( __( 'This build of Broken Links does not expose get_broken_links().', 'blaminhor-essentials' ) );
		}
		$rows_raw = $module->get_broken_links( isset( $assoc_args['include-dismissed'] ) );

		$rows = array();
		foreach ( (array) $rows_raw as $r ) {
			$rows[] = array(
				'id'            => (string) (int) $r->id,
				'post_id'       => (string) (int) $r->post_id,
				'post_title'    => (string) ( isset( $r->post_title ) ? $r->post_title : '' ),
				'url'           => (string) $r->url,
				'anchor_text'   => (string) $r->anchor_text,
				'status_code'   => (string) (int) $r->status_code,
				'error_message' => (string) $r->error_message,
				'link_type'     => (string) $r->link_type,
				'checked_at'    => (string) $r->checked_at,
				'dismissed'     => $r->dismissed ? 'true' : 'false',
			);
		}
		$this->format_for_output(
			$rows,
			$assoc_args,
			array( 'id', 'post_id', 'url', 'status_code', 'link_type' )
		);
	}

	/**
	 * Check a single URL outside of any scan run (read-only diagnostic).
	 *
	 * ## OPTIONS
	 *
	 * <url>
	 * : URL to test.
	 *
	 * @subcommand check-url
	 * @when after_wp_load
	 *
	 * @param array $args       Positional args: [<url>].
	 * @param array $assoc_args Associative args (unused).
	 */
	public function check_url( $args, $assoc_args ) {
		if ( empty( $args[0] ) ) {
			WP_CLI::error( __( 'Missing <url> argument.', 'blaminhor-essentials' ) );
		}
		$url    = esc_url_raw( (string) $args[0] );
		$module = $this->get_module_instance();
		if ( ! method_exists( $module, 'check_url' ) ) {
			WP_CLI::error( __( 'This build of Broken Links does not expose check_url().', 'blaminhor-essentials' ) );
		}

		$result = (array) $module->check_url( $url );
		$rows   = array(
			array( 'key' => 'url',           'value' => $url ),
			array( 'key' => 'is_broken',     'value' => ! empty( $result['is_broken'] ) ? 'true' : 'false' ),
			array( 'key' => 'status_code',   'value' => isset( $result['status_code'] ) ? (string) (int) $result['status_code'] : '' ),
			array( 'key' => 'error_message', 'value' => isset( $result['error_message'] ) ? (string) $result['error_message'] : '' ),
		);
		$this->format_for_output( $rows, $assoc_args, array( 'key', 'value' ) );
	}

	/**
	 * Mark a broken link as dismissed (won't be reported again until re-scanned).
	 *
	 * ## OPTIONS
	 *
	 * <id>...
	 * : One or more numeric IDs (from `list`).
	 *
	 * @when after_wp_load
	 *
	 * @param array $args       Positional args: [<id>...].
	 * @param array $assoc_args Associative args (unused).
	 */
	public function dismiss( $args, $assoc_args ) {
		$this->ensure_capabilities();
		if ( empty( $args ) ) {
			WP_CLI::error( __( 'Missing one or more numeric IDs.', 'blaminhor-essentials' ) );
		}
		global $wpdb;
		$table = $wpdb->prefix . 'blaminhor_essentials_broken_links';
		$count = 0;
		foreach ( $args as $arg ) {
			$id = (int) $arg;
			if ( $id <= 0 ) {
				continue;
			}
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery
			$count += (int) $wpdb->update( $table, array( 'dismissed' => 1 ), array( 'id' => $id ), array( '%d' ), array( '%d' ) );
		}
		blaminhor_essentials_log( sprintf( 'Broken Links: %d dismissed via WP-CLI.', $count ), 'info', 'cli', array( 'event' => 'broken_links_dismiss' ) );
		WP_CLI::success( sprintf(
			/* translators: %d: number of rows */
			__( '%d link(s) dismissed.', 'blaminhor-essentials' ),
			$count
		) );
	}

	/**
	 * Permanently delete one or more broken-link entries.
	 *
	 * ## OPTIONS
	 *
	 * <id>...
	 * : One or more numeric IDs.
	 *
	 * [--yes]
	 * : Skip the interactive confirmation prompt.
	 *
	 * @when after_wp_load
	 *
	 * @param array $args       Positional args: [<id>...].
	 * @param array $assoc_args Associative args.
	 */
	public function delete( $args, $assoc_args ) {
		$this->ensure_capabilities();
		if ( empty( $args ) ) {
			WP_CLI::error( __( 'Missing one or more numeric IDs.', 'blaminhor-essentials' ) );
		}
		WP_CLI::confirm(
			sprintf(
				/* translators: %d: count of rows */
				__( 'Delete %d broken-link entries from the database?', 'blaminhor-essentials' ),
				count( $args )
			),
			$assoc_args
		);
		global $wpdb;
		$table = $wpdb->prefix . 'blaminhor_essentials_broken_links';
		$count = 0;
		foreach ( $args as $arg ) {
			$id = (int) $arg;
			if ( $id <= 0 ) {
				continue;
			}
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery
			$count += (int) $wpdb->delete( $table, array( 'id' => $id ), array( '%d' ) );
		}
		blaminhor_essentials_log( sprintf( 'Broken Links: %d deleted via WP-CLI.', $count ), 'info', 'cli', array( 'event' => 'broken_links_delete' ) );
		WP_CLI::success( sprintf(
			/* translators: %d: deleted count */
			__( '%d row(s) deleted.', 'blaminhor-essentials' ),
			$count
		) );
	}

	/**
	 * Print summary stats (count broken, count dismissed, last scan).
	 *
	 * @when after_wp_load
	 *
	 * @param array $args       Positional args (unused).
	 * @param array $assoc_args Associative args (unused).
	 */
	public function stats( $args, $assoc_args ) {
		global $wpdb;
		$table = $wpdb->prefix . 'blaminhor_essentials_broken_links';
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		$active    = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE dismissed = 0" );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		$dismissed = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE dismissed = 1" );
		$module    = $this->get_module_instance();
		$last      = (int) $module->get_setting( 'last_scan', 0 );

		$rows = array(
			array( 'key' => 'active_broken',  'value' => (string) $active ),
			array( 'key' => 'dismissed',      'value' => (string) $dismissed ),
			array( 'key' => 'last_scan',      'value' => $last ? (string) date_i18n( 'Y-m-d H:i:s', $last ) : 'never' ),
		);
		$this->format_for_output( $rows, $assoc_args, array( 'key', 'value' ) );
	}

	/**
	 * Scan posts for broken links synchronously (no AJAX batches).
	 *
	 * Iterates every published post of the configured post types, extracts
	 * <a href="..."> links from the content, and probes each URL via
	 * Blaminhor_Essentials_Module_Broken_Links::check_url(). Newly broken
	 * links are inserted, existing entries refreshed, and last_scan is
	 * updated when the run completes (unless --dry-run).
	 *
	 * ## OPTIONS
	 *
	 * [--limit=<n>]
	 * : Cap the number of posts scanned (0 = no cap, scan all). Default: 0.
	 *
	 * [--dry-run]
	 * : Run all probes but do not write to the database (and do not update last_scan).
	 *
	 * [--post-types=<csv>]
	 * : Override the configured post types for this run.
	 *
	 * ## EXAMPLES
	 *
	 *     $ wp blaminhor broken-links scan
	 *     $ wp blaminhor broken-links scan --limit=10 --dry-run
	 *     $ wp blaminhor broken-links scan --post-types=post,page
	 *
	 * @when after_wp_load
	 *
	 * @param array $args       Positional args (unused).
	 * @param array $assoc_args Associative args.
	 */
	public function scan( $args, $assoc_args ) {
		$this->ensure_capabilities();
		$module = $this->get_module_instance();

		if ( ! method_exists( $module, 'extract_links' ) || ! method_exists( $module, 'check_url' ) ) {
			WP_CLI::error( __( 'This build of Broken Links does not expose the scan primitives.', 'blaminhor-essentials' ) );
		}

		$post_types = ! empty( $assoc_args['post-types'] )
			? array_filter( array_map( 'sanitize_key', explode( ',', (string) $assoc_args['post-types'] ) ) )
			: (array) $module->get_setting( 'post_types', array( 'post', 'page' ) );
		if ( empty( $post_types ) ) {
			WP_CLI::error( __( 'No post types to scan.', 'blaminhor-essentials' ) );
		}

		$check_external = (bool) $module->get_setting( 'check_external', true );
		$check_internal = (bool) $module->get_setting( 'check_internal', true );
		$limit          = isset( $assoc_args['limit'] ) ? (int) $assoc_args['limit'] : 0;
		$dry_run        = isset( $assoc_args['dry-run'] );

		global $wpdb;
		$table = method_exists( $module, 'get_table_name' ) ? $module->get_table_name() : ( $wpdb->prefix . 'blaminhor_essentials_broken_links' );

		$query = new WP_Query( array(
			'post_type'      => $post_types,
			'post_status'    => 'publish',
			'posts_per_page' => $limit > 0 ? $limit : -1,
			'fields'         => 'ids',
			'no_found_rows'  => true,
		) );

		$post_ids       = $query->posts;
		$total          = count( $post_ids );
		$links_total    = 0;
		$broken_total   = 0;
		$inserted_total = 0;
		$updated_total  = 0;

		if ( 0 === $total ) {
			WP_CLI::warning( __( 'No posts to scan.', 'blaminhor-essentials' ) );
			return;
		}

		WP_CLI::log( sprintf(
			/* translators: 1: post count, 2: comma-separated post types */
			__( 'Scanning %1$d post(s) of type [%2$s]...', 'blaminhor-essentials' ),
			$total,
			implode( ',', $post_types )
		) );

		foreach ( $post_ids as $post_id ) {
			$post = get_post( $post_id );
			if ( ! $post ) {
				continue;
			}
			$links = (array) $module->extract_links( (string) $post->post_content );
			foreach ( $links as $link ) {
				if ( 'external' === $link['link_type'] && ! $check_external ) {
					continue;
				}
				if ( 'internal' === $link['link_type'] && ! $check_internal ) {
					continue;
				}
				$links_total++;
				$result = (array) $module->check_url( $link['url'] );
				if ( empty( $result['is_broken'] ) ) {
					continue;
				}
				$broken_total++;
				if ( $dry_run ) {
					continue;
				}
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery
				$exists = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$table} WHERE post_id = %d AND url = %s", $post_id, $link['url'] ) );
				if ( $exists ) {
					// phpcs:ignore WordPress.DB.DirectDatabaseQuery
					$wpdb->update(
						$table,
						array(
							'status_code'   => (int) $result['status_code'],
							'error_message' => (string) $result['error_message'],
							'checked_at'    => current_time( 'mysql' ),
							'dismissed'     => 0,
						),
						array( 'id' => (int) $exists ),
						array( '%d', '%s', '%s', '%d' ),
						array( '%d' )
					);
					$updated_total++;
				} else {
					// phpcs:ignore WordPress.DB.DirectDatabaseQuery
					$wpdb->insert(
						$table,
						array(
							'post_id'       => (int) $post_id,
							'url'           => (string) $link['url'],
							'anchor_text'   => (string) $link['anchor_text'],
							'status_code'   => (int) $result['status_code'],
							'error_message' => (string) $result['error_message'],
							'link_type'     => (string) $link['link_type'],
							'checked_at'    => current_time( 'mysql' ),
							'dismissed'     => 0,
						),
						array( '%d', '%s', '%s', '%d', '%s', '%s', '%s', '%d' )
					);
					$inserted_total++;
				}
			}
		}

		if ( ! $dry_run ) {
			// Mark the scan completion timestamp. process_settings() ignores
			// `last_scan`, so we patch the raw option to bypass the form
			// pipeline — same shortcut as ajax_scan_links uses internally.
			$option_name = 'blaminhor_essentials_module_' . $this->module_id;
			$persisted   = get_option( $option_name, array() );
			if ( ! is_array( $persisted ) ) {
				$persisted = array();
			}
			$persisted['last_scan'] = time();
			update_option( $option_name, $persisted );
		}

		blaminhor_essentials_log(
			sprintf( 'Broken-links scan via WP-CLI: %d posts, %d links, %d broken (inserted=%d, updated=%d, dry_run=%s).', $total, $links_total, $broken_total, $inserted_total, $updated_total, $dry_run ? '1' : '0' ),
			'info',
			'cli',
			array( 'event' => 'broken_links_scan' )
		);

		WP_CLI::success( sprintf(
			/* translators: 1: posts scanned, 2: links probed, 3: broken count, 4: inserted, 5: updated, 6: dry-run note */
			__( '%1$d post(s) scanned, %2$d link(s) probed, %3$d broken (%4$d new, %5$d updated)%6$s.', 'blaminhor-essentials' ),
			$total,
			$links_total,
			$broken_total,
			$inserted_total,
			$updated_total,
			$dry_run ? __( ' [dry-run — DB unchanged]', 'blaminhor-essentials' ) : ''
		) );
	}

	/**
	 * Re-check a single stored broken-link entry.
	 *
	 * Fetches the row, probes the URL again, and updates status_code /
	 * error_message in place. If the link now responds 2xx/3xx, the row is
	 * removed (the link is no longer broken).
	 *
	 * ## OPTIONS
	 *
	 * <id>
	 * : Numeric row ID from `list`.
	 *
	 * ## EXAMPLES
	 *
	 *     $ wp blaminhor broken-links re-check 42
	 *
	 * @subcommand re-check
	 * @when after_wp_load
	 *
	 * @param array $args       Positional args: [<id>].
	 * @param array $assoc_args Associative args (unused).
	 */
	public function re_check( $args, $assoc_args ) {
		$this->ensure_capabilities();
		if ( empty( $args[0] ) || ! is_numeric( $args[0] ) ) {
			WP_CLI::error( __( 'Missing or invalid numeric ID.', 'blaminhor-essentials' ) );
		}
		$id     = (int) $args[0];
		$module = $this->get_module_instance();
		if ( ! method_exists( $module, 'check_url' ) ) {
			WP_CLI::error( __( 'This build of Broken Links does not expose check_url().', 'blaminhor-essentials' ) );
		}

		global $wpdb;
		$table = method_exists( $module, 'get_table_name' ) ? $module->get_table_name() : ( $wpdb->prefix . 'blaminhor_essentials_broken_links' );

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		$row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ) );
		if ( ! $row ) {
			WP_CLI::error( sprintf(
				/* translators: %d: row ID */
				__( 'No broken-link entry found for ID %d.', 'blaminhor-essentials' ),
				$id
			) );
		}

		$result = (array) $module->check_url( (string) $row->url );
		if ( empty( $result['is_broken'] ) ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery
			$wpdb->delete( $table, array( 'id' => $id ), array( '%d' ) );
			blaminhor_essentials_log( sprintf( 'Broken-link #%d resolved (re-check via WP-CLI).', $id ), 'info', 'cli', array( 'event' => 'broken_links_recheck_ok' ) );
			WP_CLI::success( sprintf(
				/* translators: %d: row ID */
				__( 'Link #%d is now working — entry removed.', 'blaminhor-essentials' ),
				$id
			) );
			return;
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		$wpdb->update(
			$table,
			array(
				'status_code'   => (int) $result['status_code'],
				'error_message' => (string) $result['error_message'],
				'checked_at'    => current_time( 'mysql' ),
			),
			array( 'id' => $id ),
			array( '%d', '%s', '%s' ),
			array( '%d' )
		);
		blaminhor_essentials_log( sprintf( 'Broken-link #%d still broken (re-check via WP-CLI).', $id ), 'info', 'cli', array( 'event' => 'broken_links_recheck_still' ) );
		WP_CLI::warning( sprintf(
			/* translators: 1: row ID, 2: HTTP status */
			__( 'Link #%1$d still broken (HTTP %2$d).', 'blaminhor-essentials' ),
			$id,
			(int) $result['status_code']
		) );
	}

	/**
	 * Dump every stored broken-link entry as CSV (or JSON) on STDOUT.
	 *
	 * Useful to feed external dashboards or send a periodic report by email.
	 * Read-only — no settings or rows are mutated.
	 *
	 * ## OPTIONS
	 *
	 * [--format=<format>]
	 * : Render format (csv, json). Default: csv.
	 *
	 * [--include-dismissed]
	 * : Include rows marked as dismissed.
	 *
	 * ## EXAMPLES
	 *
	 *     $ wp blaminhor broken-links export > broken.csv
	 *     $ wp blaminhor broken-links export --format=json --include-dismissed
	 *
	 * @when after_wp_load
	 *
	 * @param array $args       Positional args (unused).
	 * @param array $assoc_args Associative args.
	 */
	public function export( $args, $assoc_args ) {
		$module = $this->get_module_instance();
		if ( ! method_exists( $module, 'get_broken_links' ) ) {
			WP_CLI::error( __( 'This build of Broken Links does not expose get_broken_links().', 'blaminhor-essentials' ) );
		}
		$rows_raw = (array) $module->get_broken_links( isset( $assoc_args['include-dismissed'] ) );
		$format   = isset( $assoc_args['format'] ) ? (string) $assoc_args['format'] : 'csv';
		if ( ! in_array( $format, array( 'csv', 'json' ), true ) ) {
			WP_CLI::error( __( '--format must be csv or json.', 'blaminhor-essentials' ) );
		}

		$rows = array();
		foreach ( $rows_raw as $r ) {
			$rows[] = array(
				'id'            => (int) $r->id,
				'post_id'       => (int) $r->post_id,
				'post_title'    => isset( $r->post_title ) ? (string) $r->post_title : '',
				'url'           => (string) $r->url,
				'anchor_text'   => (string) $r->anchor_text,
				'status_code'   => (int) $r->status_code,
				'error_message' => (string) $r->error_message,
				'link_type'     => (string) $r->link_type,
				'checked_at'    => (string) $r->checked_at,
				'dismissed'     => $r->dismissed ? 1 : 0,
			);
		}

		if ( 'json' === $format ) {
			WP_CLI::line( wp_json_encode( array(
				'module'         => 'broken-links',
				'plugin_version' => defined( 'BLAMINHOR_ESSENTIALS_VERSION' ) ? BLAMINHOR_ESSENTIALS_VERSION : '',
				'exported_at'    => gmdate( 'c' ),
				'broken_links'   => $rows,
			), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) );
			return;
		}

		$fh = fopen( 'php://output', 'w' );
		fputcsv( $fh, array( 'id', 'post_id', 'post_title', 'url', 'anchor_text', 'status_code', 'error_message', 'link_type', 'checked_at', 'dismissed' ) );
		foreach ( $rows as $r ) {
			fputcsv( $fh, $r );
		}
		fclose( $fh );
	}
}
