<?php
/**
 * Migration session model.
 *
 * A session represents an active push from one site INTO this one. It is
 * created when a source site successfully redeems a MigrationKey via the
 * /migrate/handshake endpoint, and stays alive across the (potentially
 * many) chunk uploads + the final restore phase.
 *
 * Why sessions are separate from keys:
 *   - A 50 GB backup can take hours to push. A key may expire in 8 hours
 *     for security, but the session it kicks off needs to be allowed to
 *     complete even if the key's expiry would otherwise stop it.
 *   - The key is shown to the user (and may end up in screenshots / chat
 *     logs / browser history). The session token never leaves the source's
 *     encrypted local storage. A leak of the key URL AFTER it has been
 *     redeemed is harmless because subsequent uses are gated by the
 *     session token, not the key.
 *
 * Stored in the `backreload_migration_sessions` option as an array keyed
 * by session id. Like the keys option, we expect very few rows at any
 * time so wp_options is fine.
 *
 * @package Backreload\Models
 */

namespace Backreload\Models;

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

class MigrationSession {

	const OPTION = 'backreload_migration_sessions';

	/** Default session lifetime — 24 hours is more than enough for any
	 *  realistic migration; we auto-extend on activity. */
	const DEFAULT_TTL = DAY_IN_SECONDS;

	/**
	 * Whitelisted content modes a source can request.
	 * Mirrors the BackupRecord type enum.
	 *
	 * @var array<int,string>
	 */
	const ALLOWED_CONTENT = [ 'full', 'files', 'database' ];

	public string  $id;
	public string  $token_hash;       // sha256 of the session bearer token
	public string  $key_id;           // which key opened this session
	public string  $source_url;
	public string  $source_ip;
	public string  $content;          // full | files | database
	public int     $created_at;
	public int     $valid_until;      // bumped on activity
	public int     $expected_size;    // bytes; source-declared estimate
	public int     $chunk_size_limit; // bytes; destination dictates
	public int     $chunks_total;     // calculated at handshake
	public int     $chunks_received;
	public string  $state;            // active | completed | aborted | expired
	public ?string $backup_ext_id;    // the BackupRecord this session creates on finalize

	/**
	 * Open a fresh session. Returns BOTH the persisted record AND the
	 * one-time session token. The token is sent back to the source ONCE
	 * in the handshake response; from then on it's used as bearer auth
	 * for chunk uploads. Server-side we only ever see the hash.
	 *
	 * @param array{key_id:string,source_url:string,source_ip:string,content:string,
	 *              expected_size:int,chunk_size_limit:int,chunks_total:int} $args
	 * @return array{session:self, token:string}
	 */
	public static function create( array $args ): array {
		$token = bin2hex( random_bytes( 32 ) );
		$s     = new self();
		$s->id               = 'ms_' . wp_generate_password( 12, false, false );
		$s->token_hash       = hash( 'sha256', $token );
		$s->key_id           = (string) $args['key_id'];
		$s->source_url       = (string) $args['source_url'];
		$s->source_ip        = (string) $args['source_ip'];
		$s->content          = in_array( $args['content'], self::ALLOWED_CONTENT, true ) ? (string) $args['content'] : 'full';
		$s->created_at       = time();
		$s->valid_until      = time() + self::DEFAULT_TTL;
		$s->expected_size    = (int) $args['expected_size'];
		$s->chunk_size_limit = (int) $args['chunk_size_limit'];
		$s->chunks_total     = (int) $args['chunks_total'];
		$s->chunks_received  = 0;
		$s->state            = 'active';
		$s->backup_ext_id    = null;

		$rows = self::load_all();
		$rows[ $s->id ] = $s->to_array();
		$rows = self::gc( $rows );
		update_option( self::OPTION, $rows );

		return [ 'session' => $s, 'token' => $token ];
	}

	/**
	 * Look up a session by its presented bearer token. Returns null if no
	 * active session matches, or the session is not in `active` state.
	 *
	 * Walks every row because the source sends only the token (not the
	 * session id). We accept O(n) since n is tiny (single-digit sessions
	 * at any time).
	 */
	public static function find_by_token( string $token ): ?self {
		if ( '' === $token ) {
			return null;
		}
		$presented = hash( 'sha256', $token );
		foreach ( self::load_all() as $arr ) {
			$rec = self::from_array( $arr );
			if ( 'active' !== $rec->state )           continue;
			if ( $rec->valid_until < time() )         continue;
			if ( hash_equals( $rec->token_hash, $presented ) ) {
				return $rec;
			}
		}
		return null;
	}

	public static function find( string $id ): ?self {
		$rows = self::load_all();
		return isset( $rows[ $id ] ) ? self::from_array( $rows[ $id ] ) : null;
	}

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

	/** Bump valid_until on activity so a long-running push doesn't expire mid-stream. */
	public function touch(): void {
		$this->valid_until = time() + self::DEFAULT_TTL;
		$this->save();
	}

	public function mark_complete( string $backup_ext_id ): void {
		$this->state         = 'completed';
		$this->backup_ext_id = $backup_ext_id;
		$this->save();
	}

	public function mark_aborted(): void {
		$this->state = 'aborted';
		$this->save();
	}

	/** Public-facing array for any list/status endpoint. Excludes token_hash. */
	public function to_public_array(): array {
		return [
			'id'                => $this->id,
			'key_id'            => $this->key_id,
			'source_url'        => $this->source_url,
			'content'           => $this->content,
			'created_at'        => $this->created_at,
			'valid_until'       => $this->valid_until,
			'expected_size'     => $this->expected_size,
			'chunk_size_limit'  => $this->chunk_size_limit,
			'chunks_total'      => $this->chunks_total,
			'chunks_received'   => $this->chunks_received,
			'state'             => $this->state,
			'backup_ext_id'     => $this->backup_ext_id,
		];
	}

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

	private function to_array(): array {
		return [
			'id'                => $this->id,
			'token_hash'        => $this->token_hash,
			'key_id'            => $this->key_id,
			'source_url'        => $this->source_url,
			'source_ip'         => $this->source_ip,
			'content'           => $this->content,
			'created_at'        => $this->created_at,
			'valid_until'       => $this->valid_until,
			'expected_size'     => $this->expected_size,
			'chunk_size_limit'  => $this->chunk_size_limit,
			'chunks_total'      => $this->chunks_total,
			'chunks_received'   => $this->chunks_received,
			'state'             => $this->state,
			'backup_ext_id'     => $this->backup_ext_id,
		];
	}

	private static function from_array( array $a ): self {
		$rec = new self();
		$rec->id               = (string) ( $a['id'] ?? '' );
		$rec->token_hash       = (string) ( $a['token_hash'] ?? '' );
		$rec->key_id           = (string) ( $a['key_id'] ?? '' );
		$rec->source_url       = (string) ( $a['source_url'] ?? '' );
		$rec->source_ip        = (string) ( $a['source_ip'] ?? '' );
		$rec->content          = (string) ( $a['content'] ?? 'full' );
		$rec->created_at       = (int)    ( $a['created_at'] ?? 0 );
		$rec->valid_until      = (int)    ( $a['valid_until'] ?? 0 );
		$rec->expected_size    = (int)    ( $a['expected_size'] ?? 0 );
		$rec->chunk_size_limit = (int)    ( $a['chunk_size_limit'] ?? 0 );
		$rec->chunks_total     = (int)    ( $a['chunks_total'] ?? 0 );
		$rec->chunks_received  = (int)    ( $a['chunks_received'] ?? 0 );
		$rec->state            = (string) ( $a['state'] ?? 'active' );
		$rec->backup_ext_id    = isset( $a['backup_ext_id'] ) ? (string) $a['backup_ext_id'] : null;
		return $rec;
	}

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

	/** Drop sessions that are completed/aborted older than 7 days or expired. */
	private static function gc( array $rows ): array {
		$now  = time();
		$kept = [];
		foreach ( $rows as $id => $r ) {
			$state = (string) ( $r['state'] ?? 'active' );
			if ( 'active' === $state && (int) ( $r['valid_until'] ?? 0 ) < $now ) continue;
			if ( in_array( $state, [ 'completed', 'aborted' ], true ) &&
				 (int) ( $r['created_at'] ?? 0 ) < $now - 7 * DAY_IN_SECONDS ) continue;
			$kept[ $id ] = $r;
		}
		return $kept;
	}
}
