<?php

/**
 * Main plugin orchestrator (singleton).
 *
 * @package Leadite
 */
namespace Leadite;

use Leadite\Capture\AdapterRegistry;
use Leadite\Admin\Admin;
use Leadite\Pro;
defined( 'ABSPATH' ) || exit;
class Plugin {
    /** @var Plugin|null */
    protected static $instance = null;

    /** @var AdapterRegistry */
    protected $registry;

    /**
     * Singleton accessor.
     *
     * @return Plugin
     */
    public static function instance() {
        if ( null === self::$instance ) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    /**
     * Wire everything up.
     */
    public function boot() {
        // Translations loaded automatically by WordPress since WP 4.6 (no manual call needed).
        // Self-heal: create tables if the plugin was updated/copied without activation.
        add_action( 'admin_init', array($this, 'maybe_upgrade') );
        // Capture layer (front + ajax).
        $this->registry = new AdapterRegistry();
        $this->registry->boot();
        // Integrations Manager.
        ( new \Leadite\Integrations\IntegrationsManager() )->boot();
        // WordPress privacy tools (GDPR): personal-data exporter & eraser.
        \Leadite\Privacy\Privacy::register();
        // Data retention: daily auto-purge of old leads (opt-in).
        \Leadite\Support\AutoPurge::register();
        // Admin UI.
        if ( is_admin() ) {
            ( new Admin($this->registry) )->boot();
        }
        // Pro status revalidation: run once daily so the transient stays fresh.
        add_action( 'leadite_revalidate_pro', array('Leadite\\Pro', 'invalidate_cache') );
        if ( !wp_next_scheduled( 'leadite_revalidate_pro' ) ) {
            wp_schedule_event( time(), 'daily', 'leadite_revalidate_pro' );
        }
        // Background remote-attachment copy: fires via WP-Cron after a form
        // submission that included a file hosted on an external URL (CDN/S3).
        // The handler fetches the file with TLS enabled and moves it into the
        // private Leadite uploads directory, then updates the file row.
        add_action( 'leadite_fetch_remote_attachment', array($this, 'handle_fetch_remote_attachment') );
        // f-pro-end
    }

    /**
     * Run table creation if the stored DB version is behind.
     */
    public function maybe_upgrade() {
        if ( get_option( 'leadite_db_version' ) !== \leadite_DB_VERSION ) {
            Activator::create_tables();
            Activator::add_capabilities();
            Activator::harden_upload_dir();
            // re-create .htaccess/web.config/index.php if missing
            Activator::check_upload_dir_privacy();
            // Re-apply stored role permissions so roles don't lose access after a plugin update.
            $stored_role_access = \Leadite\Config::get( 'role_access', array() );
            if ( !empty( $stored_role_access ) ) {
                Activator::apply_role_access( $stored_role_access );
            }
            update_option( 'leadite_db_version', \leadite_DB_VERSION );
        }
    }

    /**
     * Access the adapter registry.
     *
     * @return AdapterRegistry
     */
    public function registry() {
        return $this->registry;
    }

    /**
     * WP-Cron callback: fetch a remote attachment URL and copy it into the
     * private Leadite uploads directory, then update the provisional file row.
     *
     * Triggered by leadite_fetch_remote_attachment (single event scheduled in
     * LeadsRepository::insert() when a non-local attachment URL is encountered).
     *
     * Lifecycle:
     *  1. SSRF guard — url must pass UrlValidator::is_safe_url() before the
     *     request is made (consistent with every other outbound request in the
     *     plugin; guards against DNS-rebinding).
     *  2. On success — update the DB row with real mime / size_bytes.
     *  3. On first failure — mark mime='pending' (unchanged), schedule one retry
     *     in 5 minutes (args include retry=1 so the retry branch can distinguish
     *     from the original attempt).
     *  4. On second failure — mark mime='failed', clean up the empty
     *     per-submission directory stub (only index.php inside) so no orphan
     *     directory is left behind.
     *
     * TLS verification is intentionally left ENABLED (sslverify defaults to true).
     * The request runs in a background cron process so it never delays the form
     * visitor's HTTP response.
     *
     * @param array $args { url: string, dest: string, file_row_id: int, retry?: int }
     * @return void
     */
    public function handle_fetch_remote_attachment( array $args ) {
        $url = ( isset( $args['url'] ) ? (string) $args['url'] : '' );
        $dest = ( isset( $args['dest'] ) ? (string) $args['dest'] : '' );
        $file_row_id = ( isset( $args['file_row_id'] ) ? (int) $args['file_row_id'] : 0 );
        $retry = ( isset( $args['retry'] ) ? (int) $args['retry'] : 0 );
        // 0 = first attempt
        if ( !$url || !$dest || !$file_row_id ) {
            return;
        }
        // SSRF guard: re-validate the URL immediately before the request to
        // catch DNS-rebinding (same pattern used by IntegrationsManager).
        if ( !\Leadite\Support\UrlValidator::is_safe_url( $url ) ) {
            $this->mark_attachment_failed( $file_row_id, $dest );
            return;
        }
        $response = wp_remote_get( $url, array(
            'timeout'     => 30,
            'redirection' => 0,
            'stream'      => true,
            'filename'    => $dest,
        ) );
        $http_ok = !is_wp_error( $response ) && 200 === (int) wp_remote_retrieve_response_code( $response ) && file_exists( $dest );
        if ( !$http_ok ) {
            if ( 0 === $retry ) {
                // First failure: schedule a single retry in 5 minutes.
                wp_schedule_single_event( time() + 5 * MINUTE_IN_SECONDS, 'leadite_fetch_remote_attachment', array(array(
                    'url'         => $url,
                    'dest'        => $dest,
                    'file_row_id' => $file_row_id,
                    'retry'       => 1,
                )) );
            } else {
                // Second failure: give up — mark the row and clean up the empty dir stub.
                $this->mark_attachment_failed( $file_row_id, $dest );
            }
            return;
        }
        global $wpdb;
        $files_table = $wpdb->prefix . 'leadite_files';
        // Use the file on disk as the first argument so WP can inspect real content
        // (not just the extension). This is Blocker #4: passing '' to this function
        // only does an extension-name check, which is weaker than content inspection.
        $clean_name = sanitize_file_name( basename( $dest ) );
        $ext_data = wp_check_filetype_and_ext( $dest, $clean_name );
        $mime = ( !empty( $ext_data['type'] ) ? $ext_data['type'] : 'application/octet-stream' );
        $size_bytes = (int) filesize( $dest );
        // Blocker #5: Post-download policy enforcement (size + allowlist) when
        // att_filtering_enabled is on. An attacker can pass the SSRF check with a
        // valid public URL that redirects (caught by redirection=>0 above), or serve
        // a different file than the extension suggests. We must check AFTER the file
        // has landed on disk, not before.
        if ( \Leadite\Config::get( 'att_filtering_enabled', false ) ) {
            $policy_fail = '';
            $max_bytes = (int) \Leadite\Config::get( 'att_max_size_kb', 10240 ) * 1024;
            if ( $size_bytes > $max_bytes ) {
                $policy_fail = sprintf( 
                    /* translators: 1: file size, 2: max size */
                    __( 'File too large: %1$s (max %2$s)', 'leadite' ),
                    size_format( $size_bytes ),
                    size_format( $max_bytes )
                 );
            }
            if ( '' === $policy_fail ) {
                $allowed_exts_str = \Leadite\Config::get( 'att_allowed_extensions', 'jpg,jpeg,png,gif,webp,pdf,doc,docx,xls,xlsx,ppt,pptx,txt,csv,zip,odt,ods' );
                $allowed_exts = array_map( 'trim', explode( ',', strtolower( $allowed_exts_str ) ) );
                $detected_ext = ( !empty( $ext_data['ext'] ) ? strtolower( $ext_data['ext'] ) : '' );
                if ( '' === $detected_ext || !in_array( $detected_ext, $allowed_exts, true ) ) {
                    $policy_fail = sprintf( 
                        /* translators: %s: file extension */
                        __( 'Extension not allowed: .%s', 'leadite' ),
                        ( $detected_ext ?: 'none' )
                     );
                }
            }
            if ( '' !== $policy_fail ) {
                // Remove the file that was streamed to disk — it violates policy.
                wp_delete_file( $dest );
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
                $wpdb->update(
                    $files_table,
                    array(
                        'mime'        => 'failed_policy',
                        'size_bytes'  => 0,
                        'skip_reason' => mb_substr( $policy_fail, 0, 500 ),
                    ),
                    array(
                        'id' => $file_row_id,
                    ),
                    array('%s', '%d', '%s'),
                    array('%d')
                );
                return;
            }
        }
        // Update the provisional row with real mime / size now that the file exists.
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
        $wpdb->update(
            $files_table,
            array(
                'mime'       => mb_substr( $mime, 0, 100 ),
                'size_bytes' => $size_bytes,
            ),
            array(
                'id' => $file_row_id,
            ),
            array('%s', '%d'),
            array('%d')
        );
    }

    /**
     * Mark a file row as permanently failed and clean up the empty directory stub.
     *
     * Called after a remote attachment fetch fails on its final attempt.
     * Only removes the per-submission subdirectory if it is completely empty
     * (i.e. contains only the silence index.php created during insert).
     *
     * @param int    $file_row_id Primary key in leadite_files.
     * @param string $dest        Absolute path where the file was intended to land.
     * @return void
     */
    protected function mark_attachment_failed( int $file_row_id, string $dest ) {
        global $wpdb;
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
        $wpdb->update(
            $wpdb->prefix . 'leadite_files',
            array(
                'mime'       => 'failed',
                'size_bytes' => 0,
            ),
            array(
                'id' => $file_row_id,
            ),
            array('%s', '%d'),
            array('%d')
        );
        // Clean up the empty per-submission directory stub.
        // The directory was created during insert() but the file copy never
        // completed, so it may contain only index.php.  Leave it alone if any
        // other file already landed there (parallel uploads within same lead).
        $dir = dirname( $dest );
        if ( is_dir( $dir ) ) {
            $contents = array_diff( scandir( $dir ), array('.', '..', 'index.php') );
            if ( empty( $contents ) ) {
                $idx = $dir . '/index.php';
                if ( file_exists( $idx ) ) {
                    wp_delete_file( $idx );
                }
                global $wp_filesystem;
                if ( !$wp_filesystem ) {
                    require_once ABSPATH . 'wp-admin/includes/file.php';
                    WP_Filesystem();
                }
                if ( $wp_filesystem ) {
                    $wp_filesystem->rmdir( $dir );
                }
            }
        }
    }

}
