<?php
/**
 * `wp blaminhor redirections ...` — Redirections module commands.
 *
 * @package BlaminhorEssentials
 */

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

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

/**
 * Manage 301 redirections from the command line.
 *
 * CRUD operations talk to the module's public API (create_redirection,
 * list_redirections, delete_redirection) so loop detection, duplicate
 * checks and cache invalidation behave identically to the admin UI.
 */
class Blaminhor_Essentials_CLI_Redirections extends Blaminhor_Essentials_CLI_Module {

	/**
	 * @var string
	 */
	protected $module_id = 'redirections';

	/**
	 * Show the current Redirections module settings.
	 *
	 * ## OPTIONS
	 *
	 * [--format=<format>]
	 * : Render format (table, json, csv, yaml). Default: table.
	 *
	 * ## EXAMPLES
	 *
	 *     $ wp blaminhor redirections get
	 *
	 * @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' => 'auto_redirect_on_slug_change', 'value' => $settings['auto_redirect_on_slug_change'] ? 'true' : 'false' ),
			array( 'key' => 'log_redirects',                'value' => $settings['log_redirects'] ? 'true' : 'false' ),
			array( 'key' => 'case_insensitive',             'value' => $settings['case_insensitive'] ? 'true' : 'false' ),
		);

		$this->format_for_output( $rows, $assoc_args, array( 'key', 'value' ) );
	}

	/**
	 * Update Redirections module settings.
	 *
	 * ## OPTIONS
	 *
	 * [--auto-redirect-on-slug-change=<bool>]
	 * : Create a 301 automatically when a post/term slug changes.
	 *
	 * [--log-redirects=<bool>]
	 * : Increment the hit counter for matched redirections.
	 *
	 * [--case-insensitive=<bool>]
	 * : Match source URLs without regard to letter case.
	 *
	 * ## EXAMPLES
	 *
	 *     $ wp blaminhor redirections set --auto-redirect-on-slug-change=true --log-redirects=true
	 *
	 * @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();
		$mapping = array(
			'auto-redirect-on-slug-change' => 'auto_redirect_on_slug_change',
			'log-redirects'                => 'log_redirects',
			'case-insensitive'             => 'case_insensitive',
		);

		foreach ( $mapping as $cli_flag => $setting_key ) {
			if ( array_key_exists( $cli_flag, $assoc_args ) ) {
				if ( $this->parse_bool( $assoc_args[ $cli_flag ] ) ) {
					$data[ $setting_key ] = '1';
				}
			} elseif ( ! empty( $current[ $setting_key ] ) ) {
				$data[ $setting_key ] = '1';
			}
		}

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

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

	/**
	 * Add a new 301 redirection.
	 *
	 * Uses the same loop detection as the admin form: a non-regex redirect
	 * that creates a cycle is inserted with is_active=0 and a warning is
	 * printed. Duplicate source URLs are rejected.
	 *
	 * ## OPTIONS
	 *
	 * --source=<url>
	 * : Source URL (or regex pattern with --regex).
	 *
	 * --target=<url>
	 * : Target URL.
	 *
	 * [--regex]
	 * : Treat <source> as a regular expression. The pattern is validated.
	 *
	 * [--porcelain]
	 * : Print only the new redirection's numeric ID (for scripting).
	 *
	 * ## EXAMPLES
	 *
	 *     $ wp blaminhor redirections add --source=/old-page --target=/new-page
	 *     $ wp blaminhor redirections add --source='#^/blog/(\d+)$#' --target=/posts/$1 --regex
	 *
	 * @when after_wp_load
	 *
	 * @param array $args       Positional args (unused).
	 * @param array $assoc_args Associative args.
	 */
	public function add( $args, $assoc_args ) {
		$this->ensure_capabilities();
		$module = $this->get_module_instance();

		if ( ! isset( $assoc_args['source'] ) || '' === (string) $assoc_args['source'] ) {
			WP_CLI::error( __( 'Missing --source.', 'blaminhor-essentials' ) );
		}
		if ( ! isset( $assoc_args['target'] ) || '' === (string) $assoc_args['target'] ) {
			WP_CLI::error( __( 'Missing --target.', 'blaminhor-essentials' ) );
		}

		$source   = (string) $assoc_args['source'];
		$target   = (string) $assoc_args['target'];
		$is_regex = isset( $assoc_args['regex'] );

		$result = $module->create_redirection( $source, $target, $is_regex );
		if ( is_wp_error( $result ) ) {
			WP_CLI::error( $result->get_error_message() );
		}

		blaminhor_essentials_log( sprintf( 'Redirection #%d created via WP-CLI (%s → %s).', $result, $source, $target ), 'info', 'cli', array( 'event' => 'redirections_add' ) );

		if ( isset( $assoc_args['porcelain'] ) ) {
			WP_CLI::line( (string) $result );
			return;
		}

		// Inform the user if the row was inserted disabled (loop detected).
		$rows = $module->list_redirections( array( 'limit' => 0 ) );
		foreach ( $rows as $row ) {
			if ( (int) $row->id === (int) $result && empty( $row->is_active ) && ! $is_regex ) {
				WP_CLI::warning( __( 'Loop detected — the redirection was inserted disabled. Inspect with `wp blaminhor redirections list` and fix the chain before activating it manually.', 'blaminhor-essentials' ) );
				break;
			}
		}

		WP_CLI::success( sprintf(
			/* translators: %d: row ID */
			__( 'Redirection created (ID %d).', 'blaminhor-essentials' ),
			$result
		) );
	}

	/**
	 * List existing redirections.
	 *
	 * ## OPTIONS
	 *
	 * [--search=<term>]
	 * : Substring matched against source_url and target_url.
	 *
	 * [--active-only]
	 * : Only show active redirections.
	 *
	 * [--limit=<n>]
	 * : Cap the number of rows returned (0 = no limit). Default: 0.
	 *
	 * [--fields=<fields>]
	 * : Columns to display. Available: id, source_url, target_url, is_regex, is_active, hit_count, created_at.
	 *
	 * [--format=<format>]
	 * : Render format (table, json, csv, yaml, count, ids). Default: table.
	 *
	 * ## EXAMPLES
	 *
	 *     $ wp blaminhor redirections list
	 *     $ wp blaminhor redirections list --search=/blog --format=json
	 *     $ wp blaminhor redirections list --active-only --format=ids
	 *
	 * @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();

		$rows = $module->list_redirections( array(
			'search'      => isset( $assoc_args['search'] ) ? (string) $assoc_args['search'] : '',
			'active_only' => isset( $assoc_args['active-only'] ),
			'limit'       => isset( $assoc_args['limit'] ) ? (int) $assoc_args['limit'] : 0,
		) );

		$items = array();
		foreach ( $rows as $row ) {
			$items[] = array(
				'id'          => (string) (int) $row->id,
				'source_url'  => (string) $row->source_url,
				'target_url'  => (string) $row->target_url,
				'is_regex'    => $row->is_regex ? 'true' : 'false',
				'is_active'   => $row->is_active ? 'true' : 'false',
				'hit_count'   => (string) (int) $row->hit_count,
				'created_at'  => (string) $row->created_at,
			);
		}

		$this->format_for_output(
			$items,
			$assoc_args,
			array( 'id', 'source_url', 'target_url', 'is_active', 'hit_count' )
		);
	}

	/**
	 * Delete a redirection by ID.
	 *
	 * ## OPTIONS
	 *
	 * <id>
	 * : Numeric row ID (see `wp blaminhor redirections list`).
	 *
	 * [--yes]
	 * : Skip the interactive confirmation prompt.
	 *
	 * ## EXAMPLES
	 *
	 *     $ wp blaminhor redirections delete 42
	 *     $ wp blaminhor redirections delete 42 --yes
	 *
	 * @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();
		$module = $this->get_module_instance();

		if ( ! isset( $args[0] ) || ! is_numeric( $args[0] ) ) {
			WP_CLI::error( __( 'Missing redirection ID. Usage: wp blaminhor redirections delete <id>', 'blaminhor-essentials' ) );
		}
		$id = (int) $args[0];

		WP_CLI::confirm(
			sprintf(
				/* translators: %d: redirection row ID */
				__( 'Delete redirection #%d?', 'blaminhor-essentials' ),
				$id
			),
			$assoc_args
		);

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

		blaminhor_essentials_log( sprintf( 'Redirection #%d deleted via WP-CLI.', $id ), 'info', 'cli', array( 'event' => 'redirections_delete' ) );
		WP_CLI::success( sprintf(
			/* translators: %d: redirection row ID */
			__( 'Redirection #%d deleted.', 'blaminhor-essentials' ),
			$id
		) );
	}

	/**
	 * Dump every redirection as CSV (or JSON) on STDOUT.
	 *
	 * Header row matches what a future import command would expect:
	 * id,source_url,target_url,is_regex,is_active,hit_count,created_at.
	 * Read-only.
	 *
	 * ## OPTIONS
	 *
	 * [--format=<format>]
	 * : Render format (csv, json). Default: csv.
	 *
	 * [--active-only]
	 * : Limit to active redirections only.
	 *
	 * ## EXAMPLES
	 *
	 *     $ wp blaminhor redirections export > redirects.csv
	 *     $ wp blaminhor redirections export --format=json > redirects.json
	 *
	 * @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();
		$rows   = $module->list_redirections( array(
			'limit'       => 0,
			'active_only' => isset( $assoc_args['active-only'] ),
		) );

		$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' ) );
		}

		$out = array();
		foreach ( $rows as $row ) {
			$out[] = array(
				'id'          => (int) $row->id,
				'source_url'  => (string) $row->source_url,
				'target_url'  => (string) $row->target_url,
				'is_regex'    => $row->is_regex ? 1 : 0,
				'is_active'   => $row->is_active ? 1 : 0,
				'hit_count'   => (int) $row->hit_count,
				'created_at'  => (string) $row->created_at,
			);
		}

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

		// CSV.
		$fh = fopen( 'php://output', 'w' );
		fputcsv( $fh, array( 'id', 'source_url', 'target_url', 'is_regex', 'is_active', 'hit_count', 'created_at' ) );
		foreach ( $out as $r ) {
			fputcsv( $fh, $r );
		}
		fclose( $fh );
	}

	/**
	 * Report redirections whose target URL currently returns a 4xx/5xx.
	 *
	 * Performs a HEAD request on every active, non-regex redirection's target
	 * (or a GET as fallback for hosts that reject HEAD). Read-only — never
	 * mutates the database. Relative targets are resolved against home_url().
	 *
	 * ## OPTIONS
	 *
	 * [--timeout=<seconds>]
	 * : HTTP timeout per request (default 8).
	 *
	 * [--limit=<n>]
	 * : Cap the number of redirections probed (0 = no cap). Default: 0.
	 *
	 * [--format=<format>]
	 * : Render format (table, json, csv, yaml). Default: table.
	 *
	 * ## EXAMPLES
	 *
	 *     $ wp blaminhor redirections find-orphans
	 *     $ wp blaminhor redirections find-orphans --limit=50 --format=json
	 *
	 * @subcommand find-orphans
	 * @when after_wp_load
	 *
	 * @param array $args       Positional args (unused).
	 * @param array $assoc_args Associative args.
	 */
	public function find_orphans( $args, $assoc_args ) {
		$module  = $this->get_module_instance();
		$rows    = $module->list_redirections( array( 'limit' => 0, 'active_only' => true ) );
		$timeout = isset( $assoc_args['timeout'] ) ? max( 1, (int) $assoc_args['timeout'] ) : 8;
		$limit   = isset( $assoc_args['limit'] ) ? (int) $assoc_args['limit'] : 0;

		$home     = home_url();
		$orphans  = array();
		$checked  = 0;

		foreach ( $rows as $row ) {
			if ( $limit > 0 && $checked >= $limit ) {
				break;
			}
			if ( ! empty( $row->is_regex ) ) {
				continue;
			}
			$target = (string) $row->target_url;
			if ( '' === $target ) {
				continue;
			}
			$url = $target;
			if ( '/' === substr( $url, 0, 1 ) ) {
				$url = rtrim( $home, '/' ) . $url;
			}
			if ( ! preg_match( '#^https?://#i', $url ) ) {
				continue;
			}
			$checked++;

			$response = wp_remote_head( $url, array( 'timeout' => $timeout, 'redirection' => 3 ) );
			$code     = is_wp_error( $response ) ? 0 : (int) wp_remote_retrieve_response_code( $response );
			if ( 0 === $code || 405 === $code ) {
				$response = wp_remote_get( $url, array( 'timeout' => $timeout, 'redirection' => 3 ) );
				$code     = is_wp_error( $response ) ? 0 : (int) wp_remote_retrieve_response_code( $response );
			}
			if ( $code >= 400 || 0 === $code ) {
				$orphans[] = array(
					'id'         => (string) (int) $row->id,
					'source_url' => (string) $row->source_url,
					'target_url' => $target,
					'status'     => (string) $code,
				);
			}
		}

		$this->format_for_output( $orphans, $assoc_args, array( 'id', 'source_url', 'target_url', 'status' ) );
		if ( empty( $orphans ) ) {
			WP_CLI::success( __( 'No orphan targets detected.', 'blaminhor-essentials' ) );
		} else {
			WP_CLI::warning( sprintf(
				/* translators: 1: orphan count, 2: total checked */
				__( '%1$d orphan(s) detected out of %2$d active redirection(s) probed.', 'blaminhor-essentials' ),
				count( $orphans ),
				$checked
			) );
		}
	}

	/**
	 * Detect redirection chains that loop back to their source.
	 *
	 * Builds an in-memory graph of source → target for non-regex redirections
	 * and walks it from every node. A cycle is reported when the walk visits
	 * the starting source again. Detection is bounded to 25 hops to stay safe
	 * on dense graphs. Read-only.
	 *
	 * ## OPTIONS
	 *
	 * [--format=<format>]
	 * : Render format (table, json, csv, yaml). Default: table.
	 *
	 * ## EXAMPLES
	 *
	 *     $ wp blaminhor redirections find-circular
	 *
	 * @subcommand find-circular
	 * @when after_wp_load
	 *
	 * @param array $args       Positional args (unused).
	 * @param array $assoc_args Associative args.
	 */
	public function find_circular( $args, $assoc_args ) {
		$module = $this->get_module_instance();
		$rows   = $module->list_redirections( array( 'limit' => 0 ) );

		$graph = array();
		foreach ( $rows as $row ) {
			if ( ! empty( $row->is_regex ) || empty( $row->is_active ) ) {
				continue;
			}
			$source = $this->normalize_path( (string) $row->source_url );
			$target = $this->normalize_path( (string) $row->target_url );
			if ( '' === $source || '' === $target ) {
				continue;
			}
			$graph[ $source ] = array(
				'id'          => (int) $row->id,
				'source_url'  => (string) $row->source_url,
				'target_url'  => (string) $row->target_url,
				'target_norm' => $target,
			);
		}

		$loops = array();
		foreach ( $graph as $start => $node ) {
			$visited = array( $start => true );
			$current = $node['target_norm'];
			$hops    = 0;
			while ( $hops < 25 && isset( $graph[ $current ] ) ) {
				if ( $current === $start ) {
					$loops[] = array(
						'id'          => (string) (int) $node['id'],
						'source_url'  => $node['source_url'],
						'target_url'  => $node['target_url'],
						'hops'        => (string) ( $hops + 1 ),
					);
					break;
				}
				if ( isset( $visited[ $current ] ) ) {
					break;
				}
				$visited[ $current ] = true;
				$current             = $graph[ $current ]['target_norm'];
				$hops++;
			}
		}

		$this->format_for_output( $loops, $assoc_args, array( 'id', 'source_url', 'target_url', 'hops' ) );
		if ( empty( $loops ) ) {
			WP_CLI::success( __( 'No circular redirections detected.', 'blaminhor-essentials' ) );
		} else {
			WP_CLI::warning( sprintf(
				/* translators: %d: count of circular chains */
				__( '%d circular redirection(s) detected.', 'blaminhor-essentials' ),
				count( $loops )
			) );
		}
	}

	/**
	 * Normalize a URL or path so identical references collapse to the same
	 * graph node (drops the scheme/host part of absolute home URLs, removes
	 * trailing slash, lowercases when the module is configured insensitive).
	 *
	 * @param string $url Source or target URL as stored in the row.
	 * @return string
	 */
	private function normalize_path( $url ) {
		$url = trim( $url );
		if ( '' === $url ) {
			return '';
		}
		$home = home_url( '/' );
		if ( 0 === strpos( $url, $home ) ) {
			$url = '/' . ltrim( substr( $url, strlen( $home ) ), '/' );
		} elseif ( preg_match( '#^https?://#i', $url ) ) {
			// External URL — keep as-is (won't collide with internal paths).
			return strtolower( $url );
		}
		// Strip query string and fragment for graph collapsing.
		$url = strtok( $url, '?#' );
		$url = rtrim( $url, '/' );
		if ( '' === $url ) {
			$url = '/';
		}
		// Honour case-insensitive setting if module is configured that way.
		$module = $this->get_module_instance();
		if ( $module->get_setting( 'case_insensitive' ) ) {
			$url = strtolower( $url );
		}
		return $url;
	}
}
