<?php
namespace EncypherProvenance;

use WP_Error;

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

/**
 * Attachment-bound writer for signed media.
 *
 * The target always comes from get_attached_file(), never from request data.
 * Local paths use realpath containment. WordPress VIP object-store paths use
 * the uploads stream wrapper and lexical containment because realpath is not
 * available there. Every successful write is checked against the complete
 * signed bytes. A failed or corrupt write restores the prior original before
 * returning an error.
 */
class MediaWriter
{
    /**
     * Replace an attachment's original with signed bytes.
     *
     * @param int    $attachment_id Attachment post ID.
     * @param string $bytes         Exact signed bytes to write.
     * @param string $original      Original bytes retained for recovery.
     * @return true|WP_Error
     */
    public static function write(int $attachment_id, string $bytes, string $original)
    {
        $resolved = self::resolve_target($attachment_id);
        if (is_wp_error($resolved)) {
            return $resolved;
        }

        $file_path = $resolved['path'];
        $stream_backed = $resolved['stream_backed'];
        if (! $stream_backed) {
            return self::write_local_locked($file_path, $bytes, $original);
        }
        if (! self::file_matches($file_path, $original)) {
            return new WP_Error(
                'source_changed',
                sprintf(
                    /* translators: %s: file name */
                    __('Media changed while signing and was not overwritten. File: %s', 'encypher-provenance'),
                    basename($file_path)
                )
            );
        }

        $written = self::write_bytes($file_path, $bytes, $stream_backed);
        if (! $written) {
            return self::recover_original($file_path, $original, $stream_backed, 'write_failed');
        }

        if (! self::file_matches($file_path, $bytes)) {
            return self::recover_original($file_path, $original, $stream_backed, 'write_verification_failed');
        }

        return true;
    }

    /**
     * Resolve and contain the attachment-owned target.
     *
     * @return array{path:string,stream_backed:bool}|WP_Error
     */
    private static function resolve_target(int $attachment_id)
    {
        $file_path = get_attached_file($attachment_id);
        $uploads = wp_get_upload_dir();
        $basedir = isset($uploads['basedir']) ? (string) $uploads['basedir'] : '';
        if (! is_string($file_path) || '' === $file_path || '' === $basedir) {
            return self::outside_uploads_error(is_string($file_path) ? $file_path : '');
        }
        if (str_contains($file_path, "\0") || str_contains($basedir, "\0")) {
            return self::outside_uploads_error($file_path);
        }

        $normalized_base = self::normalize_path($basedir);
        $normalized_target = self::normalize_path($file_path);
        if (
            null === $normalized_base
            || null === $normalized_target
            || ! str_starts_with($normalized_target, rtrim($normalized_base, '/') . '/')
        ) {
            return self::outside_uploads_error($file_path);
        }

        $real_base = realpath($basedir);
        $real_target = realpath($file_path);
        if (false === $real_target) {
            $real_dir = realpath(dirname($file_path));
            if (false !== $real_dir) {
                $real_target = rtrim($real_dir, '/\\') . DIRECTORY_SEPARATOR . basename($file_path);
            }
        }

        if (false !== $real_base) {
            if (
                false === $real_target
                || ! str_starts_with($real_target, rtrim($real_base, '/\\') . DIRECTORY_SEPARATOR)
            ) {
                return self::outside_uploads_error($file_path);
            }
            return ['path' => $file_path, 'stream_backed' => false];
        }

        $has_stream_scheme = (bool) preg_match('#^[a-z][a-z0-9+.-]*://#i', $basedir);
        $is_vip = defined('WPCOM_IS_VIP_ENV') || function_exists('wpcom_vip_purge_edge_cache_for_url');
        if (! $has_stream_scheme && ! $is_vip) {
            return self::outside_uploads_error($file_path);
        }

        return ['path' => $file_path, 'stream_backed' => true];
    }

    /**
     * Normalize a path without resolving a stream wrapper.
     */
    private static function normalize_path(string $path): ?string
    {
        $path = function_exists('wp_normalize_path')
            ? wp_normalize_path($path)
            : str_replace('\\', '/', $path);
        $scheme = '';
        if (preg_match('#^([a-z][a-z0-9+.-]*://)(.*)$#i', $path, $matches)) {
            $scheme = strtolower($matches[1]);
            $path = $matches[2];
        }
        $absolute = str_starts_with($path, '/');
        $parts = [];
        foreach (explode('/', $path) as $part) {
            if ('' === $part || '.' === $part) {
                continue;
            }
            if ('..' === $part) {
                return null;
            }
            $parts[] = $part;
        }
        return $scheme . ($absolute ? '/' : '') . implode('/', $parts);
    }

    /**
     * Replace a local attachment while holding an exclusive lock on the exact
     * inode whose source bytes were validated.
     *
     * @return true|WP_Error
     */
    private static function write_local_locked(string $file_path, string $bytes, string $original)
    {
        clearstatcache(true, $file_path);
        $path_stat = lstat($file_path);
        if (
            false === $path_stat
            || (($path_stat['mode'] ?? 0) & 0170000) === 0120000
        ) {
            return self::write_error('write_failed', $file_path);
        }

        // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged,WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- attachment target was resolved inside uploads; same-handle locking requires fopen.
        $handle = @fopen($file_path, 'r+b');
        if (false === $handle) {
            return self::write_error('write_failed', $file_path);
        }

        try {
            // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_flock -- local uploads write must revalidate under an exclusive inode lock.
            if (! flock($handle, LOCK_EX)) {
                return self::write_error('write_failed', $file_path);
            }
            $handle_stat = fstat($handle);
            if (! self::same_file_stat($path_stat, $handle_stat)) {
                return self::source_changed_error($file_path);
            }

            rewind($handle);
            $current = stream_get_contents($handle);
            if (false === $current || ! hash_equals($original, $current)) {
                return self::source_changed_error($file_path);
            }

            if (! self::write_locked_handle($handle, $bytes)) {
                return self::restore_locked_handle($handle, $original, $file_path, 'write_failed');
            }

            rewind($handle);
            $actual = stream_get_contents($handle);
            if (false === $actual || ! hash_equals($bytes, $actual)) {
                return self::restore_locked_handle($handle, $original, $file_path, 'write_verification_failed');
            }

            clearstatcache(true, $file_path);
            $current_path_stat = lstat($file_path);
            if (! self::same_file_stat($current_path_stat, fstat($handle))) {
                return self::source_changed_error($file_path);
            }

            return true;
        } finally {
            // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_flock -- release the local uploads inode lock.
            flock($handle, LOCK_UN);
            fclose($handle); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_fclose,WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- closes the exclusive local uploads inode lock; WP_Filesystem exposes no lockable handle.
        }
    }

    /**
     * @param array<string|int,mixed>|false $left
     * @param array<string|int,mixed>|false $right
     */
    private static function same_file_stat($left, $right): bool
    {
        return is_array($left)
            && is_array($right)
            && isset($left['dev'], $left['ino'], $right['dev'], $right['ino'])
            && (int) $left['dev'] === (int) $right['dev']
            && (int) $left['ino'] === (int) $right['ino'];
    }

    /**
     * Write and truncate while the caller holds an exclusive file lock.
     *
     * @param resource $handle
     */
    private static function write_locked_handle($handle, string $bytes): bool
    {
        if (! rewind($handle)) {
            return false;
        }
        $length = strlen($bytes);
        $offset = 0;
        while ($offset < $length) {
            // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_fwrite,WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- exact signed bytes must be written through the locked attachment handle.
            $written = fwrite($handle, substr($bytes, $offset));
            if (false === $written || 0 === $written) {
                return false;
            }
            $offset += $written;
        }

        // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_ftruncate -- remove any tail from the prior attachment while its inode remains locked.
        return ftruncate($handle, $length) && fflush($handle);
    }

    /**
     * @param resource $handle
     */
    private static function restore_locked_handle($handle, string $original, string $file_path, string $failure_code): WP_Error
    {
        if (! self::write_locked_handle($handle, $original)) {
            return self::write_error('restore_failed', $file_path);
        }
        rewind($handle);
        $restored = stream_get_contents($handle);
        if (false === $restored || ! hash_equals($original, $restored)) {
            return self::write_error('restore_failed', $file_path);
        }

        return self::write_error($failure_code, $file_path);
    }

    private static function source_changed_error(string $file_path): WP_Error
    {
        return new WP_Error(
            'source_changed',
            sprintf(
                /* translators: %s: file name */
                __('Media changed while signing and was not overwritten. File: %s', 'encypher-provenance'),
                basename($file_path)
            )
        );
    }

    private static function outside_uploads_error(string $file_path): WP_Error
    {
        return new WP_Error(
            'outside_uploads',
            sprintf(
                /* translators: %s: file name */
                __('Refusing to write signed media outside the attachment uploads path: %s', 'encypher-provenance'),
                basename($file_path)
            )
        );
    }

    private static function recover_original(
        string $file_path,
        string $original,
        bool $stream_backed,
        string $failure_code
    ): WP_Error {
        if (! self::file_matches($file_path, $original)) {
            if (
                ! self::write_bytes($file_path, $original, $stream_backed)
                || ! self::file_matches($file_path, $original)
            ) {
                return self::write_error('restore_failed', $file_path);
            }
        }
        return self::write_error($failure_code, $file_path);
    }

    private static function write_error(string $code, string $file_path): WP_Error
    {
        $message = 'restore_failed' === $code
            ? __('Signed media replacement failed and the prior original could not be restored.', 'encypher-provenance')
            : __('Signed media replacement failed; the prior original was preserved.', 'encypher-provenance');
        return new WP_Error(
            $code,
            sprintf(
                /* translators: 1: error detail, 2: file name */
                __('%1$s File: %2$s', 'encypher-provenance'),
                $message,
                basename($file_path)
            )
        );
    }

    private static function write_bytes(string $file_path, string $bytes, bool $stream_backed): bool
    {
        if ($stream_backed) {
            // WordPress VIP documents standard PHP writes through its uploads stream wrapper.
            // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_file_put_contents -- required VIP uploads stream-wrapper write.
            return file_put_contents($file_path, $bytes) === strlen($bytes);
        }

        $filesystem = self::get_filesystem();
        if (null !== $filesystem) {
            $chmod = defined('FS_CHMOD_FILE') ? FS_CHMOD_FILE : false;
            return (bool) $filesystem->put_contents($file_path, $bytes, $chmod);
        }

        if (! self::is_direct_writable($file_path)) {
            return false;
        }
        // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_file_put_contents -- guarded plain-host fallback outside VIP.
        return file_put_contents($file_path, $bytes) === strlen($bytes);
    }

    private static function file_matches(string $file_path, string $expected): bool
    {
        // phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown -- complete read-back of the attachment just written, required for C2PA hard-binding integrity.
        $actual = file_get_contents($file_path);
        return false !== $actual && hash_equals($expected, $actual);
    }

    /**
     * Initialize the local WP_Filesystem handle, or null when unavailable.
     *
     * @return object|null
     */
    private static function get_filesystem()
    {
        if (! function_exists('WP_Filesystem')) {
            $file_php = ABSPATH . 'wp-admin/includes/file.php';
            if (file_exists($file_php)) {
                require_once $file_php;
            }
        }
        if (! function_exists('WP_Filesystem') || ! WP_Filesystem()) {
            return null;
        }
        global $wp_filesystem;
        if (! is_object($wp_filesystem) || ! method_exists($wp_filesystem, 'put_contents')) {
            return null;
        }
        return $wp_filesystem;
    }

    private static function is_direct_writable(string $file_path): bool
    {
        return wp_is_writable(file_exists($file_path) ? $file_path : dirname($file_path));
    }
}
