<?php
/**
 * Migration key model.
 *
 * A migration key authorizes ONE remote site to push a backup INTO this
 * site. The flow:
 *
 *   1. User on the destination site generates a key with an expiry.
 *   2. The key URL is shown to the user; user copies, takes it to the
 *      source site, pastes into the "Send" form.
 *   3. The source calls our `/migrate/handshake` endpoint with the key's
 *      token; we verify the token + expiry, and (if valid) issue a
 *      session token bound to the active migration.
 *   4. The source pushes backup chunks using the session token as
 *      bearer auth — the original key is NOT used after the handshake.
 *   5. On `/migrate/finalize` the destination verifies all chunks arrived,
 *      then triggers the restore engine. The key is invalidated.
 *
 * Why a separate key + session pair (rather than just one bearer token)?
 *   - The key is shown to the user — it may end up in screenshots, chat
 *     logs, browser history, copy buffers. The session token never leaves
 *     the source's encrypted local state. If a key is leaked AFTER it has
 *     been redeemed once, the leak is harmless: re-handshakes fail
 *     because the key was single-use.
 *   - Sessions can outlive their key — a 50 GB migration may take longer
 *     than the key's 8-hour expiry. The session token is what gates
 *     ongoing chunk uploads, set with a longer lifetime + auto-refreshed
 *     on activity.
 *
 * Persistence:
 *   - Active keys live in the `backreload_migration_keys` option as an
 *     array keyed by key id. The user only sees a handful at a time —
 *     no need for a custom table.
 *   - Token is stored hashed (constant-time-compared on handshake) so
 *     a DB read can't recover the token from disk.
 *
 * @package Backreload\Models
 */

namespace Backreload\Models;

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

class MigrationKey {

	const OPTION = 'backreload_migration_keys';

	/** Max simultaneously-active keys per site. Beyond this we GC the oldest. */
	const MAX_ACTIVE = 20;

	public string  $id;
	public string  $token_hash;  // sha256 of the bearer token
	public int     $expires_at;  // unix timestamp
	public int     $created_at;
	public int     $created_by;  // WP user id
	public string  $state;       // active | redeemed | revoked | expired
	public ?string $session_id;  // set when the key has been redeemed; null otherwise
	public ?string $source_url;  // set on handshake; helps the UI show "Receiving from …"
	public ?string $last_seen_ip;
	public ?int    $redeemed_at;
	/**
	 * Whether the destination admin allows the source to auto-restore after
	 * push. Defaults FALSE — the receive-side admin generated a key meaning
	 * "accept a backup," not "execute a destructive restore." Explicit
	 * opt-in is required at key-creation time to enable auto-restore.
	 * The /migrate/finalize endpoint enforces this on the destination side
	 * regardless of what `auto_restore` the source sends.
	 */
	public bool    $allow_auto_restore = false;

	/**
	 * Generate a brand-new key. Returns BOTH the persisted record AND the
	 * one-time plaintext token. The plaintext is shown ONCE to the user
	 * (embedded in the key URL); after this call returns, the token can
	 * never be retrieved again — only verified via hash compare.
	 *
	 * @param int  $ttl_seconds         How long the key is valid (e.g. 8 * HOUR_IN_SECONDS)
	 * @param bool $allow_auto_restore  Whether the source may auto-restore on push.
	 *                                  Defaults FALSE — explicit opt-in required.
	 * @return array{key:self, token:string}
	 */
	public static function create( int $ttl_seconds, bool $allow_auto_restore = false ): array {
		// 64 random bytes → ~128 base64 chars. ~10^115 entropy, brute-force-proof.
		$token = bin2hex( random_bytes( 32 ) );
		$key   = new self();
		$key->id           = 'mk_' . wp_generate_password( 10, false, false );
		$key->token_hash   = hash( 'sha256', $token );
		$key->expires_at   = time() + $ttl_seconds;
		$key->created_at   = time();
		$key->created_by   = get_current_user_id();
		$key->state        = 'active';
		$key->session_id   = null;
		$key->source_url   = null;
		$key->last_seen_ip = null;
		$key->redeemed_at  = null;
		$key->allow_auto_restore = $allow_auto_restore;

		$keys = self::load_all();
		$keys[ $key->id ] = $key->to_array();

		// GC: drop keys past their expiry; cap total active keys at MAX_ACTIVE
		// (oldest first) so a user who clicks Generate repeatedly doesn't
		// bloat wp_options.
		$keys = self::gc( $keys );
		update_option( self::OPTION, $keys );

		return [ 'key' => $key, 'token' => $token ];
	}

	/**
	 * Find a key by id. Returns null if not found OR expired (treats
	 * expired-but-still-stored keys as gone — defense-in-depth in case
	 * GC hasn't run yet).
	 */
	public static function find( string $id ): ?self {
		$keys = self::load_all();
		if ( ! isset( $keys[ $id ] ) ) {
			return null;
		}
		$rec = self::from_array( $keys[ $id ] );
		if ( $rec->expires_at < time() && 'redeemed' !== $rec->state ) {
			// Auto-expire on read. A key in `redeemed` state with a
			// running session may legitimately outlive its expiry —
			// the session's own valid_until governs that.
			return null;
		}
		return $rec;
	}

	/**
	 * Constant-time compare a presented bearer token against the stored
	 * hash. Returns the matching key record on success, null on failure.
	 *
	 * Iterates through ALL active keys because the bearer is just the
	 * token — the source doesn't tell us which key id to look up (and
	 * couldn't if it tried, since the id never leaves the server). We
	 * accept the O(n) cost; in practice n is small (single-digit keys
	 * at any time) and hash_equals is cheap.
	 */
	public static function find_by_token( string $token_plaintext ): ?self {
		if ( '' === $token_plaintext ) {
			return null;
		}
		$presented_hash = hash( 'sha256', $token_plaintext );
		foreach ( self::load_all() as $rec_arr ) {
			$rec = self::from_array( $rec_arr );
			if ( $rec->expires_at < time() && 'redeemed' !== $rec->state ) {
				continue; // expired
			}
			if ( 'active' !== $rec->state ) {
				continue; // already redeemed or revoked
			}
			if ( hash_equals( $rec->token_hash, $presented_hash ) ) {
				return $rec;
			}
		}
		return null;
	}

	/**
	 * Return keys for the active-keys list UI. Sorted newest first.
	 *
	 * Filters out terminal states (revoked, expired) by default — the UI
	 * only wants to show keys the admin can still act on. A revoked key
	 * that was never redeemed has no audit value and is hard-deleted by
	 * revoke() anyway; one that was redeemed mid-session and then revoked
	 * lives on in storage for the audit trail but is excluded from this
	 * list (we don't want to confuse users with rows they can't act on).
	 */
	public static function all(): array {
		$now = time();
		$out = [];
		foreach ( self::load_all() as $rec_arr ) {
			$state = (string) ( $rec_arr['state'] ?? 'active' );
			if ( 'revoked' === $state )                    continue;
			if ( 'active' === $state && (int) ( $rec_arr['expires_at'] ?? 0 ) < $now ) continue;
			$out[] = self::from_array( $rec_arr );
		}
		usort( $out, static fn( $a, $b ) => $b->created_at <=> $a->created_at );
		return $out;
	}

	public function save(): void {
		$keys = self::load_all();
		$keys[ $this->id ] = $this->to_array();
		update_option( self::OPTION, $keys );
	}

	/**
	 * Revoke this key.
	 *
	 *   - If the key was never redeemed (`state === 'active'`), the record
	 *     is hard-deleted: there's no in-flight session to track and no
	 *     audit value in keeping a row that just says "issued then
	 *     revoked." This matches the user's mental model: "I revoked it,
	 *     it should be gone."
	 *
	 *   - If the key has been redeemed and a session is in progress
	 *     (`state === 'redeemed'`), we keep the row but flip state to
	 *     `revoked` so the in-flight upload endpoint can reject further
	 *     chunks and the admin keeps a record that a migration was
	 *     aborted mid-way. Auto-GC removes these rows after 7 days.
	 */
	public function revoke(): void {
		$keys = self::load_all();
		if ( ! isset( $keys[ $this->id ] ) ) {
			$this->state = 'revoked';
			return;
		}

		if ( 'redeemed' === $this->state ) {
			// Mid-session abort: keep the row, flip state.
			$keys[ $this->id ]['state'] = 'revoked';
			update_option( self::OPTION, $keys );
		} else {
			// Never-redeemed (or already-terminal): delete entirely.
			unset( $keys[ $this->id ] );
			update_option( self::OPTION, $keys );
		}
		$this->state = 'revoked';
	}

	/**
	 * Mark this key as having been used to open a session. After this
	 * point, the key cannot be redeemed again — every subsequent chunk
	 * upload must use the session token issued at handshake time.
	 */
	public function mark_redeemed( string $session_id, string $source_url, string $ip ): void {
		$this->state        = 'redeemed';
		$this->session_id   = $session_id;
		$this->source_url   = $source_url;
		$this->last_seen_ip = $ip;
		$this->redeemed_at  = time();
		$this->save();
	}

	/**
	 * Build the user-visible key URL. Contains the destination URL (so the
	 * source can verify it's pushing to the right host), the token (the
	 * one-time secret), and the expiry (so the source can pre-flight
	 * without a network round-trip).
	 *
	 * Format mirrors the WPvivid pattern users may already be familiar
	 * with from other backup plugins.
	 *
	 *   https://destination.com?
	 *     domain=https://destination.com&
	 *     token=<plaintext>&
	 *     expires=<unix>&
	 *     v=1
	 */
	public static function build_url( string $token, int $expires_at ): string {
		$home = untrailingslashit( home_url() );
		return $home . '?' . http_build_query( [
			'domain'  => $home,
			'token'   => $token,
			'expires' => $expires_at,
			'v'       => 1,
		] );
	}

	/**
	 * Public-facing array for the REST list endpoint. Excludes
	 * `token_hash` so the hash never leaks even to admins via the API.
	 */
	public function to_public_array(): array {
		return [
			'id'                 => $this->id,
			'expires_at'         => $this->expires_at,
			'created_at'         => $this->created_at,
			'state'              => $this->state,
			'session_id'         => $this->session_id,
			'source_url'         => $this->source_url,
			'redeemed_at'        => $this->redeemed_at,
			'last_seen_ip'       => $this->last_seen_ip,
			'allow_auto_restore' => $this->allow_auto_restore,
		];
	}

	/* ─── persistence helpers ──────────────────────────────────── */

	private function to_array(): array {
		return [
			'id'                 => $this->id,
			'token_hash'         => $this->token_hash,
			'expires_at'         => $this->expires_at,
			'created_at'         => $this->created_at,
			'created_by'         => $this->created_by,
			'state'              => $this->state,
			'session_id'         => $this->session_id,
			'source_url'         => $this->source_url,
			'last_seen_ip'       => $this->last_seen_ip,
			'redeemed_at'        => $this->redeemed_at,
			'allow_auto_restore' => $this->allow_auto_restore,
		];
	}

	private static function from_array( array $a ): self {
		$rec = new self();
		$rec->id           = (string) ( $a['id'] ?? '' );
		$rec->token_hash   = (string) ( $a['token_hash'] ?? '' );
		$rec->expires_at   = (int)    ( $a['expires_at'] ?? 0 );
		$rec->created_at   = (int)    ( $a['created_at'] ?? 0 );
		$rec->created_by   = (int)    ( $a['created_by'] ?? 0 );
		$rec->state        = (string) ( $a['state'] ?? 'active' );
		$rec->session_id   = isset( $a['session_id'] )   ? (string) $a['session_id']   : null;
		$rec->source_url   = isset( $a['source_url'] )   ? (string) $a['source_url']   : null;
		$rec->last_seen_ip = isset( $a['last_seen_ip'] ) ? (string) $a['last_seen_ip'] : null;
		$rec->redeemed_at  = isset( $a['redeemed_at'] )  ? (int)    $a['redeemed_at']  : null;
		$rec->allow_auto_restore = ! empty( $a['allow_auto_restore'] );
		return $rec;
	}

	private static function load_all(): array {
		$keys = get_option( self::OPTION, [] );
		return is_array( $keys ) ? $keys : [];
	}

	/**
	 * Garbage-collect expired + over-quota keys. Called inline on every
	 * create() so wp_options never accumulates stale rows. We keep:
	 *
	 *   - All `redeemed` keys with their session still alive (cleanup
	 *     happens when the session itself ends).
	 *   - The MAX_ACTIVE newest `active` keys.
	 *
	 * Drop:
	 *   - Anything `expired` (state OR clock).
	 *   - Anything `revoked` older than a week.
	 *   - Anything beyond MAX_ACTIVE in `active`.
	 */
	private static function gc( array $keys ): array {
		$now = time();
		$kept = [];

		// First pass: drop terminal states + clock-expired.
		foreach ( $keys as $id => $rec ) {
			$state = (string) ( $rec['state'] ?? 'active' );
			$exp   = (int) ( $rec['expires_at'] ?? 0 );

			if ( 'revoked' === $state && (int) ( $rec['created_at'] ?? 0 ) < $now - 7 * DAY_IN_SECONDS ) {
				continue;
			}
			if ( 'active' === $state && $exp < $now ) {
				continue; // active key whose expiry has passed
			}
			$kept[ $id ] = $rec;
		}

		// Second pass: cap actives at MAX_ACTIVE (oldest first).
		$actives = array_filter( $kept, static fn( $r ) => 'active' === ( $r['state'] ?? '' ) );
		if ( count( $actives ) > self::MAX_ACTIVE ) {
			uasort( $actives, static fn( $a, $b ) => ( $a['created_at'] ?? 0 ) <=> ( $b['created_at'] ?? 0 ) );
			$drop = array_slice( array_keys( $actives ), 0, count( $actives ) - self::MAX_ACTIVE );
			foreach ( $drop as $id ) {
				unset( $kept[ $id ] );
			}
		}
		return $kept;
	}
}
