<?php

declare(strict_types=1);

namespace ProRank\SEO\Core\Performance\ObjectCache;

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

/**
 * Installs / updates / removes the ProRank-owned object-cache.php drop-in.
 *
 * Hard rules (enforced here, not assumed):
 *  - Touch the drop-in ONLY when it is absent or carries the ProRank owner
 *    marker. Foreign drop-ins are detected, reported, and never modified,
 *    backed up, or deleted.
 *  - Always create a timestamped backup before overwriting or removing a
 *    ProRank-owned drop-in.
 *  - Never throw to the caller; always return a structured result.
 *  - This class is plugin-side only. The drop-in template itself is fully
 *    standalone (see dropin-template.php).
 */
class ObjectCacheDropinManager {

    /** Unique ownership marker that must appear in a ProRank-owned drop-in. */
    private const OWNER_MARKER = 'owner:prorank';

    public function dropin_path(): string {
        $base = defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR : ABSPATH . 'wp-content';
        return rtrim( $base, '/\\' ) . '/object-cache.php';
    }

    public function bundled_template_path(): string {
        return __DIR__ . '/dropin-template.php';
    }

    /**
     * Parse the `v:N` value out of the ProRank marker line in a blob.
     */
    private function parse_marker_version( string $contents ): ?int {
        if ( stripos( $contents, self::OWNER_MARKER ) === false ) {
            return null;
        }
        if ( preg_match( '/owner:prorank\s*\|\s*v:(\d+)/i', $contents, $m ) === 1 ) {
            return (int) $m[1];
        }
        // Marker present but no explicit version → treat as v1.
        return 1;
    }

    private function is_owned_blob( string $contents ): bool {
        return $contents !== '' && stripos( $contents, self::OWNER_MARKER ) !== false;
    }

    public function bundled_version(): int {
        $tpl = $this->bundled_template_path();
        if ( ! is_readable( $tpl ) ) {
            return 0;
        }
        $head = (string) @file_get_contents( $tpl, false, null, 0, 4096 );
        $v    = $this->parse_marker_version( $head );
        return $v ?? 0;
    }

    /**
     * Inspect the installed drop-in.
     *
     * @return array{
     *   exists:bool, readable:bool, is_prorank_owned:bool,
     *   foreign:bool, installed_version:?int, bundled_version:int,
     *   update_available:bool, path:string
     * }
     */
    public function inspect(): array {
        $path = $this->dropin_path();
        $bundled = $this->bundled_version();

        if ( ! file_exists( $path ) ) {
            return array(
                'exists'            => false,
                'readable'          => false,
                'is_prorank_owned'  => false,
                'foreign'           => false,
                'installed_version' => null,
                'bundled_version'   => $bundled,
                'update_available'  => false,
                'path'              => $path,
            );
        }

        $readable = is_readable( $path );
        $contents = $readable ? (string) @file_get_contents( $path, false, null, 0, 4096 ) : '';
        $owned    = $this->is_owned_blob( $contents );
        $installed_v = $owned ? $this->parse_marker_version( $contents ) : null;

        return array(
            'exists'            => true,
            'readable'          => $readable,
            'is_prorank_owned'  => $owned,
            'foreign'           => ( $readable && ! $owned ),
            'installed_version' => $installed_v,
            'bundled_version'   => $bundled,
            'update_available'  => ( $owned && $installed_v !== null && $bundled > 0 && $installed_v < $bundled ),
            'path'              => $path,
        );
    }

    private function result( bool $success, string $code, string $message, array $extra = array() ): array {
        return array_merge(
            array(
                'success' => $success,
                'code'    => $code,
                'message' => $message,
            ),
            $extra
        );
    }

    /**
     * Best-effort timestamped backup of an EXISTING ProRank-owned drop-in.
     * Never called for foreign files.
     */
    private function backup_owned( string $path ): ?string {
        $backup = $path . '.prorank-bak-' . gmdate( 'Ymd-His' );
        try {
            if ( @copy( $path, $backup ) ) {
                return $backup;
            }
        } catch ( \Throwable $e ) {
            // fall through
        }
        return null;
    }

    private function invalidate_opcache( string $path ): void {
        if ( function_exists( 'opcache_invalidate' ) ) {
            @opcache_invalidate( $path, true );
        }
    }

    /**
     * Best-effort PHP syntax check of a staged file.
     *
     * @return bool|null true = valid, false = syntax error, null = could not
     *                   check (exec disabled / no php binary) → treated as OK.
     */
    private function php_lint( string $file ) {
        $disabled = array_map( 'trim', explode( ',', (string) ini_get( 'disable_functions' ) ) );
        if ( in_array( 'exec', $disabled, true ) || ! function_exists( 'exec' ) ) {
            return null;
        }
        $php = defined( 'PHP_BINARY' ) && PHP_BINARY ? PHP_BINARY : 'php';
        $out = array();
        $rc  = 0;
        @exec( escapeshellarg( $php ) . ' -l ' . escapeshellarg( $file ) . ' 2>&1', $out, $rc );
        if ( $rc === 0 ) {
            return true;
        }
        // Non-zero may also mean the binary was unavailable; only fail on a
        // clear syntax-error signal.
        $joined = strtolower( implode( "\n", $out ) );
        if ( strpos( $joined, 'syntax error' ) !== false || strpos( $joined, 'parse error' ) !== false ) {
            return false;
        }
        return null;
    }

    /**
     * Install (or update) the ProRank drop-in.
     *
     * Refuses if a FOREIGN drop-in is present. Backs up an existing owned
     * drop-in first. Never throws.
     */
    public function install(): array {
        try {
            $tpl = $this->bundled_template_path();
            if ( ! is_readable( $tpl ) ) {
                return $this->result( false, 'template_missing', 'Bundled drop-in template is missing or unreadable.' );
            }
            $template = (string) @file_get_contents( $tpl );
            if ( $template === '' || ! $this->is_owned_blob( $template ) ) {
                return $this->result( false, 'template_invalid', 'Bundled drop-in template failed the ownership-marker self-check.' );
            }

            $path = $this->dropin_path();
            $state = $this->inspect();

            // BLOCKER 1: refuse whenever a drop-in exists that ProRank does
            // not own — including unreadable files (readable=false →
            // foreign=false but is_prorank_owned=false). Never overwrite it.
            if ( $state['exists'] && ! $state['is_prorank_owned'] ) {
                $code = ( isset( $state['readable'] ) && ! $state['readable'] )
                    ? 'unreadable_dropin'
                    : 'foreign_dropin';
                return $this->result(
                    false,
                    $code,
                    'An existing object-cache.php drop-in is not owned by ProRank (or is unreadable). It will not be modified. Remove or replace it via the plugin that owns it before installing the ProRank drop-in.',
                    array( 'inspect' => $state )
                );
            }

            // BLOCKER 2: if an owned drop-in already exists we MUST back it up
            // before overwriting; a failed backup aborts the operation.
            $backup = null;
            if ( $state['exists'] && $state['is_prorank_owned'] ) {
                $backup = $this->backup_owned( $path );
                if ( $backup === null ) {
                    return $this->result(
                        false,
                        'backup_failed',
                        'Refusing to overwrite the existing ProRank object-cache.php because a safety backup could not be created.',
                        array( 'inspect' => $state )
                    );
                }
            }

            // BLOCKER (atomic write): write a temp file in the SAME directory,
            // validate it, then atomically rename() over the live drop-in.
            // Concurrent requests never see a partially-written PHP file; a
            // failed rename leaves the original untouched.
            $dir = dirname( $path );
            $tmp = @tempnam( $dir, '.prorank-oc-' );
            if ( $tmp === false ) {
                return $this->result( false, 'write_failed', 'Could not create a temp file for atomic install (directory not writable?).', array( 'backup' => $backup ) );
            }

            $written = @file_put_contents( $tmp, $template, LOCK_EX );
            if ( $written === false ) {
                @unlink( $tmp );
                return $this->result( false, 'write_failed', 'Could not write the temp drop-in file.', array( 'backup' => $backup ) );
            }

            // Validate the staged file before it can ever go live.
            $staged = (string) @file_get_contents( $tmp, false, null, 0, 4096 );
            if ( ! $this->is_owned_blob( $staged ) ) {
                @unlink( $tmp );
                return $this->result( false, 'stage_invalid', 'Staged drop-in failed the ownership-marker check; not activated.', array( 'backup' => $backup ) );
            }
            $lint = $this->php_lint( $tmp );
            if ( $lint === false ) {
                @unlink( $tmp );
                return $this->result( false, 'stage_lint_failed', 'Staged drop-in failed a PHP syntax check; not activated.', array( 'backup' => $backup ) );
            }

            @chmod( $tmp, 0644 );

            if ( ! @rename( $tmp, $path ) ) {
                @unlink( $tmp );
                return $this->result( false, 'rename_failed', 'Atomic rename into place failed; the existing drop-in was left untouched.', array( 'backup' => $backup ) );
            }

            $this->invalidate_opcache( $path );

            return $this->result(
                true,
                $state['exists'] ? 'updated' : 'installed',
                $state['exists'] ? 'ProRank object-cache drop-in updated.' : 'ProRank object-cache drop-in installed.',
                array( 'backup' => $backup, 'inspect' => $this->inspect() )
            );
        } catch ( \Throwable $e ) {
            return $this->result( false, 'exception', 'Install failed: ' . $e->getMessage() );
        }
    }

    /**
     * Remove the ProRank drop-in. Refuses for foreign / non-owned files.
     * Backs up the owned file before deletion. Never throws.
     */
    public function remove(): array {
        try {
            $state = $this->inspect();
            $path  = $state['path'];

            if ( ! $state['exists'] ) {
                return $this->result( true, 'not_present', 'No object-cache.php drop-in to remove.' );
            }
            if ( ! $state['is_prorank_owned'] ) {
                return $this->result(
                    false,
                    'not_owned',
                    'object-cache.php is not owned by ProRank. It will not be touched.',
                    array( 'inspect' => $state )
                );
            }

            // BLOCKER 2: a failed backup aborts removal — never delete an
            // owned drop-in without a recoverable copy.
            $backup = $this->backup_owned( $path );
            if ( $backup === null ) {
                return $this->result(
                    false,
                    'backup_failed',
                    'Refusing to remove the ProRank object-cache.php because a safety backup could not be created.',
                    array( 'inspect' => $state )
                );
            }
            if ( ! @unlink( $path ) ) {
                return $this->result( false, 'delete_failed', 'Could not delete the ProRank object-cache.php (filesystem permissions?).', array( 'backup' => $backup ) );
            }
            $this->invalidate_opcache( $path );

            return $this->result(
                true,
                'removed',
                'ProRank object-cache drop-in removed. WordPress reverted to its default non-persistent cache.',
                array( 'backup' => $backup )
            );
        } catch ( \Throwable $e ) {
            return $this->result( false, 'exception', 'Remove failed: ' . $e->getMessage() );
        }
    }
}
