<?php
/**
 * Sync engine: pulls the manifest from the hub, upserts changed guides, and
 * trashes local hub guides that are no longer targeted at this site.
 *
 * Safety rules:
 * - A failed/invalid manifest aborts with ZERO local changes (never trash on
 *   a failed fetch).
 * - The sync never touches taxonomy terms or the _bw_guides_notes meta, so
 *   client tags and notes survive every sync by construction.
 * - Removals go to Trash (recoverable), never hard delete.
 */

defined( 'ABSPATH' ) || exit;

class BW_Guides_Sync {

	const CRON_HOOK      = 'bw_guides_sync_event';
	const LOCK_TRANSIENT = 'bw_guides_sync_lock';
	const BATCH          = 20;
	const MAX_GUIDES     = 500;
	const CHECK_THROTTLE = 600; // Seconds between opened-Guides update checks.

	public function register() {
		add_action( self::CRON_HOOK, array( $this, 'run' ) );
	}

	public static function schedule_cron() {
		// Daily is only a backstop (and keeps the hub's last-sync bookkeeping
		// alive); freshness comes from the throttled check-on-view.
		if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
			wp_schedule_event( time() + MINUTE_IN_SECONDS, 'daily', self::CRON_HOOK );
		}
	}

	/**
	 * Whether an opened-Guides-screen update check is due. Throttled off the
	 * last sync attempt (success or failure) so a down hub is not hammered.
	 */
	public static function needs_check() {
		$last     = (int) BW_Guides_Settings::get( 'last_sync_at', 0 );
		$interval = (int) apply_filters( 'bw_guides_check_interval', self::CHECK_THROTTLE );
		return ( time() - $last ) >= max( 60, $interval );
	}

	public static function clear_cron() {
		wp_clear_scheduled_hook( self::CRON_HOOK );
	}

	/**
	 * @return array|WP_Error Counts on success.
	 */
	public function run() {
		if ( get_transient( self::LOCK_TRANSIENT ) ) {
			return new WP_Error( 'bw_guides_sync_locked', __( 'A sync is already running.', 'bw-guides' ) );
		}
		set_transient( self::LOCK_TRANSIENT, 1, 5 * MINUTE_IN_SECONDS );
		$result = $this->do_sync();
		delete_transient( self::LOCK_TRANSIENT );

		if ( is_wp_error( $result ) ) {
			BW_Guides_Settings::update(
				array(
					'last_sync_at'     => time(),
					'last_sync_status' => 'error',
					'last_error'       => $result->get_error_message(),
				)
			);
		} else {
			BW_Guides_Settings::update(
				array(
					'last_sync_at'     => time(),
					'last_sync_status' => 'ok',
					'last_error'       => '',
					'last_sync_counts' => $result,
				)
			);
		}
		return $result;
	}

	private function do_sync() {
		$client   = new BW_Guides_Client();
		$manifest = $client->manifest();
		if ( is_wp_error( $manifest ) ) {
			return $manifest;
		}
		if ( empty( $manifest['schema_version'] ) || (int) $manifest['schema_version'] > BW_Guides_Client::SCHEMA_VERSION ) {
			return new WP_Error( 'bw_guides_schema', __( 'The guides hub uses a newer protocol than this plugin supports. Update BW Guides.', 'bw-guides' ) );
		}
		if ( ! isset( $manifest['guides'] ) || ! is_array( $manifest['guides'] ) ) {
			return new WP_Error( 'bw_guides_bad_manifest', __( 'The guides hub returned an unexpected manifest.', 'bw-guides' ) );
		}

		$remote = array();
		foreach ( $manifest['guides'] as $row ) {
			if ( is_array( $row ) && ! empty( $row['id'] ) ) {
				$remote[ (int) $row['id'] ] = $row;
			}
		}

		$local = $this->local_hub_guides();

		// Diff: new remote ids, changed content, or a local copy not in publish
		// state (e.g. previously untargeted → trashed, now re-targeted).
		$to_fetch = array();
		foreach ( $remote as $rid => $row ) {
			$post = isset( $local[ $rid ] ) ? $local[ $rid ] : null;
			if ( ! $post ) {
				$to_fetch[] = $rid;
				continue;
			}
			$hash     = (string) get_post_meta( $post->ID, '_bw_guides_content_hash', true );
			$modified = (string) get_post_meta( $post->ID, '_bw_guides_remote_modified', true );
			if (
				'publish' !== $post->post_status
				|| $hash !== (string) ( isset( $row['content_hash'] ) ? $row['content_hash'] : '' )
				|| $modified !== (string) ( isset( $row['modified_gmt'] ) ? $row['modified_gmt'] : '' )
			) {
				$to_fetch[] = $rid;
			}
		}

		$created = 0;
		$updated = 0;
		foreach ( array_chunk( $to_fetch, self::BATCH ) as $chunk ) {
			$payload = $client->guides( $chunk );
			if ( is_wp_error( $payload ) ) {
				// Abort before the removal pass — partial upserts are idempotent,
				// but we never trash based on an incomplete run.
				return $payload;
			}
			$guides = ( isset( $payload['guides'] ) && is_array( $payload['guides'] ) ) ? $payload['guides'] : array();
			foreach ( $guides as $guide ) {
				$outcome = $this->upsert( $guide, $local );
				if ( 'created' === $outcome ) {
					$created++;
				} elseif ( 'updated' === $outcome ) {
					$updated++;
				}
			}
		}

		// The manifest is the complete truth for this site: any local hub guide
		// absent from it has been unpublished or untargeted — trash it.
		$trashed = 0;
		foreach ( $local as $rid => $post ) {
			if ( ! isset( $remote[ $rid ] ) && 'trash' !== $post->post_status ) {
				wp_trash_post( $post->ID );
				$trashed++;
			}
		}

		return array(
			'created' => $created,
			'updated' => $updated,
			'trashed' => $trashed,
			'remote'  => count( $remote ),
		);
	}

	/**
	 * @return string 'created' | 'updated' | 'skipped'
	 */
	private function upsert( $guide, $local ) {
		if ( ! is_array( $guide ) ) {
			return 'skipped';
		}
		$rid = isset( $guide['id'] ) ? (int) $guide['id'] : 0;
		if ( ! $rid || empty( $guide['title'] ) ) {
			return 'skipped';
		}
		$existing = isset( $local[ $rid ] ) ? $local[ $rid ] : null;

		$postarr = array(
			'post_type'    => BW_Guides_CPT::POST_TYPE,
			'post_status'  => 'publish',
			'post_title'   => sanitize_text_field( (string) $guide['title'] ),
			'post_name'    => sanitize_title( isset( $guide['slug'] ) ? (string) $guide['slug'] : '' ),
			// Defense in depth: hub content is trusted, but sanitize anyway.
			// Block delimiters survive wp_kses_post by core design.
			'post_content' => wp_kses_post( isset( $guide['content'] ) ? (string) $guide['content'] : '' ),
			'post_excerpt' => sanitize_textarea_field( isset( $guide['excerpt'] ) ? (string) $guide['excerpt'] : '' ),
			'post_author'  => 0,
		);
		$meta = array(
			'_bw_guides_source'          => 'hub',
			'_bw_guides_remote_id'       => $rid,
			'_bw_guides_remote_modified' => sanitize_text_field( isset( $guide['modified_gmt'] ) ? (string) $guide['modified_gmt'] : '' ),
			'_bw_guides_content_hash'    => sanitize_text_field( isset( $guide['content_hash'] ) ? (string) $guide['content_hash'] : '' ),
			'_bw_guides_remote_meta'     => wp_json_encode( ( isset( $guide['meta'] ) && is_array( $guide['meta'] ) ) ? $guide['meta'] : array() ),
		);

		if ( $existing ) {
			if ( 'trash' === $existing->post_status ) {
				wp_untrash_post( $existing->ID );
			}
			$postarr['ID'] = $existing->ID;
			$result        = wp_update_post( wp_slash( $postarr ), true );
			if ( is_wp_error( $result ) ) {
				return 'skipped';
			}
			foreach ( $meta as $key => $value ) {
				update_post_meta( $existing->ID, $key, wp_slash( $value ) );
			}
			return 'updated';
		}

		$postarr['meta_input'] = $meta;
		$result                = wp_insert_post( wp_slash( $postarr ), true );
		return ( is_wp_error( $result ) || ! $result ) ? 'skipped' : 'created';
	}

	/**
	 * All local hub-sourced guides (any status, including trash), keyed by
	 * remote id.
	 *
	 * @return array<int, WP_Post>
	 */
	private function local_hub_guides() {
		$query = new WP_Query(
			array(
				'post_type'              => BW_Guides_CPT::POST_TYPE,
				'post_status'            => array( 'publish', 'draft', 'pending', 'private', 'future', 'trash' ),
				'posts_per_page'         => self::MAX_GUIDES,
				'no_found_rows'          => true,
				'update_post_term_cache' => false,
				'meta_query'             => array(
					array(
						'key'     => '_bw_guides_remote_id',
						'compare' => 'EXISTS',
					),
				),
			)
		);

		$map = array();
		foreach ( $query->posts as $post ) {
			$rid = (int) get_post_meta( $post->ID, '_bw_guides_remote_id', true );
			if ( $rid ) {
				$map[ $rid ] = $post;
			}
		}
		return $map;
	}
}
