<?php
/**
 * Backup manifest — a small JSON file written into every backup's bk_<ext_id>/
 * folder and uploaded alongside the archive parts.
 *
 * Why it exists: a backup's metadata (type, components, per-part categories +
 * checksums, the original site URL, encryption parameters) otherwise lives only
 * in the origin site's database. That makes importing a backup on a DIFFERENT
 * server a guessing game — filenames have to be reverse-engineered, encryption
 * details are lost. The manifest makes a backup fully self-describing, so it can
 * be discovered in a storage destination and imported elsewhere deterministically.
 *
 * The manifest is plain (unencrypted) JSON so it stays readable even for an
 * encrypted backup. It carries NO secrets — the passphrase is never stored. It
 * does carry the KDF salt, which is not secret and MUST travel with an encrypted
 * backup (the key is PBKDF2(passphrase, salt), so a different server with a
 * different salt could not derive the key even with the correct passphrase).
 *
 * @package Backreload\Core
 */

namespace Backreload\Core;

use Backreload\Models\BackupRecord;

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

class Manifest {

	/** Bumped if the manifest shape changes incompatibly. */
	const SCHEMA = 1;

	/** Fixed filename inside the bk_<ext_id>/ folder. */
	const FILENAME = 'manifest.json';

	/**
	 * Assemble the manifest array for a finished backup record.
	 *
	 * @param BackupRecord $rec   The backup whose parts are already built/uploaded.
	 * @param array        $extra Optional extras, e.g. [ 'encryption' => [...] ].
	 */
	public static function build( BackupRecord $rec, array $extra = [] ): array {
		$files = [];
		foreach ( (array) $rec->files as $f ) {
			$files[] = [
				'name'      => (string) ( $f['name'] ?? '' ),
				'category'  => (string) ( $f['category'] ?? '' ),
				'size'      => (int) ( $f['size'] ?? 0 ),
				'checksum'  => (string) ( $f['checksum'] ?? '' ),
				'encrypted' => ! empty( $f['encrypted'] ),
			];
		}

		global $wpdb;
		$data = [
			'backreload_manifest'   => self::SCHEMA,
			'ext_id'         => (string) $rec->ext_id,
			'created_at'     => (string) $rec->created_at,
			'completed_at'   => (string) ( $rec->completed_at ?: current_time( 'mysql', true ) ),
			'type'           => (string) $rec->type,
			'kind'           => (string) ( $rec->kind ?: 'manual' ),
			'components'     => array_values( (array) $rec->components ),
			'source_url'     => (string) $rec->source_url,
			'source_path'    => (string) $rec->source_path,
			'encrypted'      => (bool) $rec->encrypted,
			'size'           => (int) $rec->size,
			'wp_version'     => get_bloginfo( 'version' ),
			'plugin_version' => defined( 'BACKRELOAD_VERSION' ) ? BACKRELOAD_VERSION : '',
			'db_prefix'      => isset( $wpdb ) ? $wpdb->prefix : 'wp_',
			'files'          => $files,
		];

		if ( $rec->encrypted && ! empty( $extra['encryption'] ) ) {
			$data['encryption'] = $extra['encryption'];
		}

		return $data;
	}

	/** Write the manifest array as pretty JSON to $path. */
	public static function write( string $path, array $data ): bool {
		$json = wp_json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES );
		if ( false === $json ) {
			return false;
		}
		return false !== @file_put_contents( $path, $json );
	}

	/** Cap on the manifest file size. A real manifest is a few kilobytes; a 2 MB
	 *  ceiling stops a hostile remote destination from OOM-ing the import scan
	 *  with a gigabyte JSON file. */
	const MAX_BYTES = 2 * 1024 * 1024;

	/**
	 * Read + validate a manifest file. Returns null if missing, unreadable,
	 * oversized, or not a recognizable backreload manifest.
	 *
	 * Treat the source as untrusted — manifests can arrive from any
	 * destination an admin configured, including ones an attacker controls.
	 */
	public static function read( string $path ): ?array {
		if ( ! is_file( $path ) ) {
			return null;
		}
		// Size cap before reading — protects against a malicious remote that
		// hands us a multi-gigabyte JSON file.
		$size = (int) @filesize( $path );
		if ( $size <= 0 || $size > self::MAX_BYTES ) {
			return null;
		}
		$raw = @file_get_contents( $path, false, null, 0, self::MAX_BYTES );
		if ( false === $raw || '' === $raw ) {
			return null;
		}
		$data = json_decode( $raw, true );
		if ( ! is_array( $data ) || empty( $data['backreload_manifest'] ) ) {
			return null;
		}
		// Validate ext_id shape here, once — keeps every consumer from having
		// to repeat the check (and from forgetting to). The same shape is
		// enforced again when import_source_commit creates a record, but
		// rejecting early means a bad manifest never even surfaces.
		$ext_id = (string) ( $data['ext_id'] ?? '' );
		if ( ! preg_match( '/^bk_[A-Za-z0-9_-]{1,64}$/', $ext_id ) ) {
			return null;
		}
		// Sanity-check files[].name shapes so a crafted manifest can't smuggle
		// path traversal past consumers that forget to validate.
		if ( isset( $data['files'] ) && is_array( $data['files'] ) ) {
			foreach ( $data['files'] as $f ) {
				$name = (string) ( $f['name'] ?? '' );
				if (
					'' === $name
					|| '/' === ( $name[0] ?? '' )
					|| false !== strpos( $name, '\\' )
					|| false !== strpos( $name, "\0" )
					|| preg_match( '#(^|/)\.\.(/|$)#', $name )
				) {
					return null;
				}
			}
		}
		return $data;
	}
}
