<?php
/**
 * Livada SEO — Redirects Module
 *
 * Manages 301/302 URL redirections: DB-backed storage, hashmap cache via
 * transient, async hit counting, and a full CRUD API for the admin UI.
 *
 * @package Livada_SEO
 */

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

class Livada_SEO_Redirects {

	/** Transient key for the full redirects hashmap. */
	const TRANSIENT_KEY = 'livada_seo_redirects_map';

	/** Threshold above which the in-memory hashmap is skipped in favour of direct SQL. */
	const CACHE_SKIP_THRESHOLD = 500;

	/** Async action name for deferred hit counting. */
	const HIT_ACTION = 'livada_seo_count_hit';

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

	/**
	 * Registers all hooks for this module.
	 * Called once by Livada_SEO_Loader::init().
	 */
	public static function init(): void {
		add_action( 'template_redirect', [ self::class, 'handle_redirect' ], 1 );
		add_action( self::HIT_ACTION,    [ self::class, 'record_hit' ] );

		// Bulk ops + CSV (admin only).
		add_action( 'wp_ajax_livada_seo_bulk_delete_redirects', [ self::class, 'ajax_bulk_delete' ] );
		add_action( 'wp_ajax_livada_seo_import_redirects_csv',  [ self::class, 'ajax_import_csv'  ] );
		add_action( 'admin_post_livada_seo_export_redirects_csv', [ self::class, 'export_csv'     ] );
	}

	// ── Bulk delete ───────────────────────────────────────────────────────────

	public static function ajax_bulk_delete(): 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 );
		}

		$ids = isset( $_POST['ids'] ) ? (array) $_POST['ids'] : [];
		$ids = array_values( array_filter( array_map( 'absint', $ids ) ) );

		if ( empty( $ids ) ) {
			wp_send_json_error( [ 'message' => __( 'No IDs provided.', 'livada-seo' ) ], 400 );
		}

		global $wpdb;
		$table = $wpdb->prefix . 'livada_seo_redirects';

		$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		$deleted = (int) $wpdb->query(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
				"DELETE FROM {$table} WHERE id IN ({$placeholders})",
				...$ids
			)
		);

		self::invalidate_cache();

		wp_send_json_success( [ 'deleted' => $deleted ] );
	}

	// ── CSV import ────────────────────────────────────────────────────────────

	/**
	 * Imports redirects from a CSV upload. Expected columns (header row required):
	 *   source_url, target_url, type
	 *
	 * Lines that fail validation are skipped and reported as warnings.
	 */
	public static function ajax_import_csv(): 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 );
		}

		if ( empty( $_FILES['csv'] ) || ! isset( $_FILES['csv']['tmp_name'] ) ) {
			wp_send_json_error( [ 'message' => __( 'No CSV file uploaded.', 'livada-seo' ) ], 400 );
		}

		$tmp_name = (string) $_FILES['csv']['tmp_name']; // phpcs:ignore
		if ( ! is_uploaded_file( $tmp_name ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid upload.', 'livada-seo' ) ], 400 );
		}

		$handle = fopen( $tmp_name, 'r' );
		if ( ! $handle ) {
			wp_send_json_error( [ 'message' => __( 'Could not open uploaded file.', 'livada-seo' ) ], 500 );
		}

		// Detect delimiter from the first line.
		$first_line = fgets( $handle );
		rewind( $handle );

		$delimiter = ',';
		if ( false !== $first_line && substr_count( $first_line, ';' ) > substr_count( $first_line, ',' ) ) {
			$delimiter = ';';
		}

		$header = fgetcsv( $handle, 0, $delimiter );
		if ( ! is_array( $header ) ) {
			fclose( $handle );
			wp_send_json_error( [ 'message' => __( 'CSV header missing.', 'livada-seo' ) ], 400 );
		}

		$header = array_map( static function ( $h ) {
			return strtolower( trim( (string) $h ) );
		}, $header );

		$src_idx = array_search( 'source_url', $header, true );
		$tgt_idx = array_search( 'target_url', $header, true );
		$typ_idx = array_search( 'type',       $header, true );

		if ( false === $src_idx || false === $tgt_idx ) {
			fclose( $handle );
			wp_send_json_error( [
				'message' => __( 'CSV must include source_url and target_url columns.', 'livada-seo' ),
			], 400 );
		}

		$imported = 0;
		$skipped  = 0;
		$warnings = [];
		$line_num = 1; // header consumed

		while ( ( $row = fgetcsv( $handle, 0, $delimiter ) ) !== false ) {
			$line_num++;

			$source = isset( $row[ $src_idx ] ) ? trim( (string) $row[ $src_idx ] ) : '';
			$target = isset( $row[ $tgt_idx ] ) ? trim( (string) $row[ $tgt_idx ] ) : '';
			$type   = ( false !== $typ_idx && isset( $row[ $typ_idx ] ) ) ? (int) $row[ $typ_idx ] : 301;

			if ( '' === $source || '' === $target ) {
				$skipped++;
				continue;
			}

			if ( ! in_array( $type, [ 301, 302 ], true ) ) {
				$type = 301;
			}

			$result = self::add_redirect( $source, $target, $type );

			if ( false === $result ) {
				$skipped++;
				$warnings[] = sprintf(
					/* translators: 1: line number, 2: source URL */
					__( 'Line %1$d: could not insert "%2$s" (duplicate or invalid).', 'livada-seo' ),
					$line_num,
					$source
				);
			} else {
				$imported++;
			}
		}

		fclose( $handle );

		wp_send_json_success( [
			'imported' => $imported,
			'skipped'  => $skipped,
			'warnings' => $warnings,
		] );
	}

	// ── CSV export ────────────────────────────────────────────────────────────

	/**
	 * Streams the full redirects table as a CSV download.
	 * Hooked to admin_post_livada_seo_export_redirects_csv.
	 */
	public static function export_csv(): void {
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'Insufficient permissions.', 'livada-seo' ) );
		}

		$nonce = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ) : '';
		if ( ! wp_verify_nonce( $nonce, 'livada_seo_export_redirects' ) ) {
			wp_die( esc_html__( 'Security check failed.', 'livada-seo' ) );
		}

		global $wpdb;
		$table = $wpdb->prefix . 'livada_seo_redirects';

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		$rows = $wpdb->get_results( "SELECT source_url, target_url, type, hits, last_hit, created_at FROM {$table} ORDER BY id ASC" );

		nocache_headers();
		header( 'Content-Type: text/csv; charset=UTF-8' );
		header( 'Content-Disposition: attachment; filename="livada-redirects-' . gmdate( 'Y-m-d' ) . '.csv"' );

		$out = fopen( 'php://output', 'w' );

		// UTF-8 BOM so Excel reads accents correctly.
		fwrite( $out, "\xEF\xBB\xBF" );

		fputcsv( $out, [ 'source_url', 'target_url', 'type', 'hits', 'last_hit', 'created_at' ] );

		if ( $rows ) {
			foreach ( $rows as $r ) {
				fputcsv( $out, [
					$r->source_url,
					$r->target_url,
					$r->type,
					$r->hits,
					$r->last_hit,
					$r->created_at,
				] );
			}
		}

		fclose( $out );
		exit;
	}

	// ── Activation ────────────────────────────────────────────────────────────

	/**
	 * Creates the redirects table on plugin activation.
	 * Called from the main plugin file's activation hook.
	 */
	public static function create_table(): void {
		global $wpdb;

		$table   = $wpdb->prefix . 'livada_seo_redirects';
		$charset = $wpdb->get_charset_collate();

		$sql = "CREATE TABLE {$table} (
			id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
			source_url VARCHAR(500) NOT NULL,
			target_url VARCHAR(500) NOT NULL,
			type SMALLINT NOT NULL DEFAULT 301,
			hits INT UNSIGNED NOT NULL DEFAULT 0,
			last_hit DATETIME NULL,
			created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
			PRIMARY KEY (id),
			UNIQUE KEY source_url (source_url(191))
		) {$charset};";

		require_once ABSPATH . 'wp-admin/includes/upgrade.php';
		dbDelta( $sql );
	}

	// ── Request handling ──────────────────────────────────────────────────────

	/**
	 * Hooked on `template_redirect` (priority 1).
	 * Resolves the current path, looks it up in the redirect store, and fires
	 * wp_redirect() if a match is found.
	 */
	public static function handle_redirect(): void {
		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
		$raw_uri = isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : '';
		$path    = parse_url( $raw_uri, PHP_URL_PATH );

		if ( ! $path ) {
			return;
		}

		$total = self::count_redirects();

		if ( $total > self::CACHE_SKIP_THRESHOLD ) {
			// Large dataset: skip in-memory cache, query DB directly.
			$row = self::fetch_by_source_direct( $path );
			if ( ! $row ) {
				return;
			}
			$target = $row->target_url;
			$type   = (int) $row->type;
			$id     = (int) $row->id;
		} else {
			$map = self::get_redirects_cached();
			if ( ! isset( $map[ $path ] ) ) {
				return;
			}
			$entry  = $map[ $path ];
			$target = $entry['target'];
			$type   = (int) $entry['type'];
			$id     = (int) $entry['id'];
		}

		// Schedule async hit count so it doesn't block the redirect response.
		wp_schedule_single_event( time(), self::HIT_ACTION, [ $id ] );

		wp_redirect( $target, $type );
		exit;
	}

	// ── Cache ─────────────────────────────────────────────────────────────────

	/**
	 * Returns the full redirects hashmap, loading from transient or DB as needed.
	 *
	 * Structure: [ '/source-path' => ['target' => 'https://...', 'type' => 301, 'id' => N], ... ]
	 *
	 * @return array<string, array{target: string, type: int, id: int}>
	 */
	public static function get_redirects_cached(): array {
		$cached = get_transient( self::TRANSIENT_KEY );

		if ( false !== $cached ) {
			return (array) $cached;
		}

		global $wpdb;

		$table = $wpdb->prefix . 'livada_seo_redirects';
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		$rows = $wpdb->get_results( "SELECT id, source_url, target_url, type FROM {$table}" );

		$map = [];
		if ( $rows ) {
			foreach ( $rows as $row ) {
				$map[ $row->source_url ] = [
					'target' => $row->target_url,
					'type'   => (int) $row->type,
					'id'     => (int) $row->id,
				];
			}
		}

		// Store with no expiry — invalidated explicitly on every CRUD operation.
		set_transient( self::TRANSIENT_KEY, $map, 0 );

		return $map;
	}

	/**
	 * Invalidates the transient cache.
	 * Must be called after every CRUD operation.
	 */
	private static function invalidate_cache(): void {
		delete_transient( self::TRANSIENT_KEY );
	}

	// ── Direct DB lookup (large datasets) ────────────────────────────────────

	/**
	 * Fetches a single redirect row by source path via direct SQL.
	 * Used when the total row count exceeds CACHE_SKIP_THRESHOLD.
	 *
	 * @param string $source_path URL path to match.
	 * @return object|null
	 */
	private static function fetch_by_source_direct( string $source_path ): ?object {
		global $wpdb;

		$table = $wpdb->prefix . 'livada_seo_redirects';

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		return $wpdb->get_row(
			$wpdb->prepare(
				"SELECT id, target_url, type FROM {$table} WHERE source_url = %s LIMIT 1",
				$source_path
			)
		);
	}

	// ── Async hit counting ────────────────────────────────────────────────────

	/**
	 * Increments the hit counter and updates last_hit for the given redirect ID.
	 * Executed asynchronously via WP-Cron.
	 *
	 * @param int $id Redirect row ID.
	 */
	public static function record_hit( int $id ): void {
		global $wpdb;

		$id    = absint( $id );
		$table = $wpdb->prefix . 'livada_seo_redirects';

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		$wpdb->query(
			$wpdb->prepare(
				"UPDATE {$table} SET hits = hits + 1, last_hit = %s WHERE id = %d",
				current_time( 'mysql' ),
				$id
			)
		);
	}

	// ── CRUD ──────────────────────────────────────────────────────────────────

	/**
	 * Inserts a new redirect.
	 *
	 * @param string $source Source path (e.g. /old-page/).
	 * @param string $target Destination URL.
	 * @param int    $type   HTTP status code (301 or 302).
	 * @return int|false Inserted row ID, or false on failure.
	 */
	public static function add_redirect( string $source, string $target, int $type = 301 ) {
		global $wpdb;

		$source = '/' . ltrim( esc_url_raw( $source ), '/' );
		$target = esc_url_raw( $target );
		$type   = absint( $type );

		$table = $wpdb->prefix . 'livada_seo_redirects';

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		$result = $wpdb->insert(
			$table,
			[
				'source_url' => $source,
				'target_url' => $target,
				'type'       => $type,
			],
			[ '%s', '%s', '%d' ]
		);

		if ( false === $result ) {
			return false;
		}

		self::invalidate_cache();

		$inserted_id = (int) $wpdb->insert_id;

		if ( class_exists( 'Livada_SEO_Activity_Log' ) ) {
			Livada_SEO_Activity_Log::log(
				'redirect_added',
				sprintf( '%s → %s [%d]', $source, $target, $type ),
				[ 'source' => $source, 'target' => $target, 'type' => $type ],
				[ 'type' => 'redirect', 'id' => $inserted_id ]
			);
		}

		return $inserted_id;
	}

	/**
	 * Updates an existing redirect by ID.
	 *
	 * @param int   $id   Redirect row ID.
	 * @param array $data Associative array of fields to update (source_url, target_url, type).
	 * @return bool True on success, false on failure.
	 */
	public static function update_redirect( int $id, array $data ): bool {
		global $wpdb;

		$id    = absint( $id );
		$table = $wpdb->prefix . 'livada_seo_redirects';

		$fields  = [];
		$formats = [];

		if ( isset( $data['source_url'] ) ) {
			$fields['source_url'] = '/' . ltrim( esc_url_raw( $data['source_url'] ), '/' );
			$formats[]            = '%s';
		}

		if ( isset( $data['target_url'] ) ) {
			$fields['target_url'] = esc_url_raw( $data['target_url'] );
			$formats[]            = '%s';
		}

		if ( isset( $data['type'] ) ) {
			$fields['type'] = absint( $data['type'] );
			$formats[]      = '%d';
		}

		if ( empty( $fields ) ) {
			return false;
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		$result = $wpdb->update(
			$table,
			$fields,
			[ 'id' => $id ],
			$formats,
			[ '%d' ]
		);

		self::invalidate_cache();

		return false !== $result;
	}

	/**
	 * Deletes a redirect by ID.
	 *
	 * @param int $id Redirect row ID.
	 * @return bool True on success, false on failure.
	 */
	public static function delete_redirect( int $id ): bool {
		global $wpdb;

		$id    = absint( $id );
		$table = $wpdb->prefix . 'livada_seo_redirects';

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		$result = $wpdb->delete(
			$table,
			[ 'id' => $id ],
			[ '%d' ]
		);

		self::invalidate_cache();

		if ( false !== $result && class_exists( 'Livada_SEO_Activity_Log' ) ) {
			Livada_SEO_Activity_Log::log(
				'redirect_deleted',
				sprintf( 'Redirect #%d deleted', $id ),
				null,
				[ 'type' => 'redirect', 'id' => $id ]
			);
		}

		return false !== $result;
	}

	/**
	 * Returns a paginated, optionally filtered list of redirects for the admin UI.
	 *
	 * @param int    $page     1-based page number.
	 * @param int    $per_page Rows per page.
	 * @param string $search   Optional search string matched against source_url or target_url.
	 * @return array{items: object[], total: int}
	 */
	public static function get_redirects_paginated( int $page = 1, int $per_page = 25, string $search = '' ): array {
		global $wpdb;

		$page     = max( 1, absint( $page ) );
		$per_page = max( 1, absint( $per_page ) );
		$offset   = ( $page - 1 ) * $per_page;
		$table    = $wpdb->prefix . 'livada_seo_redirects';

		if ( '' !== $search ) {
			$like = '%' . $wpdb->esc_like( sanitize_text_field( $search ) ) . '%';

			// phpcs:ignore WordPress.DB.DirectDatabaseQuery
			$items = $wpdb->get_results(
				$wpdb->prepare(
					"SELECT * FROM {$table}
					 WHERE source_url LIKE %s OR target_url LIKE %s
					 ORDER BY id DESC
					 LIMIT %d OFFSET %d",
					$like,
					$like,
					$per_page,
					$offset
				)
			);

			// phpcs:ignore WordPress.DB.DirectDatabaseQuery
			$total = (int) $wpdb->get_var(
				$wpdb->prepare(
					"SELECT COUNT(*) FROM {$table}
					 WHERE source_url LIKE %s OR target_url LIKE %s",
					$like,
					$like
				)
			);
		} else {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery
			$items = $wpdb->get_results(
				$wpdb->prepare(
					"SELECT * FROM {$table} ORDER BY id DESC LIMIT %d OFFSET %d",
					$per_page,
					$offset
				)
			);

			$total = self::count_redirects();
		}

		return [
			'items' => $items ?: [],
			'total' => $total,
		];
	}

	/**
	 * Returns a single redirect row by ID.
	 *
	 * @param int $id Redirect row ID.
	 * @return object|null
	 */
	public static function get_redirect( int $id ): ?object {
		global $wpdb;

		$id    = absint( $id );
		$table = $wpdb->prefix . 'livada_seo_redirects';

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		return $wpdb->get_row(
			$wpdb->prepare(
				"SELECT * FROM {$table} WHERE id = %d LIMIT 1",
				$id
			)
		) ?: null;
	}

	/**
	 * Returns the total number of redirects stored in the DB.
	 *
	 * @return int
	 */
	public static function count_redirects(): int {
		global $wpdb;

		$table = $wpdb->prefix . 'livada_seo_redirects';

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
	}
}
