<?php

declare(strict_types=1);

namespace SiteFort\Modules\Firewall\GeoIP;

use SiteFort\Includes\DataDirectoryProtection;
use SiteFort\Includes\FilesystemMutation;
use SiteFort\Includes\GuardedDataFile;
use SiteFort\Includes\RemoteUrlValidator;

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

/**
 * MaxMind GeoLite2-Country database downloader.
 *
 * Writes are atomic (temp file, then rename) so a partially-written database
 * is never served. Requires a free MaxMind account:
 * https://www.maxmind.com/en/geolite2/signup
 */
class MaxMindDownloader {

    /**
     * MaxMind GeoLite2 download URL (current API, authenticates via HTTP Basic Auth).
     */
    private const DOWNLOAD_URL = 'https://download.maxmind.com/geoip/databases';

    /**
     * Maximum age in seconds before the database needs updating.
     *
     * The refresh cron runs weekly; this is deliberately below 7 days so a
     * run arriving seconds short of a full week still refreshes (a 7-day
     * threshold made every second run a no-op, stretching the real cadence
     * to 14 days).
     */
    private const MAX_AGE = 6 * DAY_IN_SECONDS;

    /**
     * Maximum accepted size for the extracted MMDB payload.
     *
     * GeoLite2-Country is ~9 MB; this caps decompression so a corrupted or
     * hostile archive cannot exhaust memory.
     */
    private const MAX_MMDB_SIZE = 67108864;

    /**
     * Download and install the GeoLite2-Country database.
     *
     * @param string $account_id  MaxMind account ID.
     * @param string $license_key MaxMind license key.
     * @return true|\WP_Error True on success, WP_Error on failure.
     */
    public function download( string $account_id, string $license_key ) {
        if ( empty( $account_id ) || empty( $license_key ) ) {
            return new \WP_Error(
                'sitefort_geoip_no_key',
                'MaxMind account ID and license key are both required to download the GeoIP database.'
            );
        }

        $url = self::DOWNLOAD_URL . '/GeoLite2-Country/download?suffix=tar.gz';

        $tmp_file = $this->download_archive( $url, $account_id, $license_key );

        if ( is_wp_error( $tmp_file ) ) {
            return $tmp_file;
        }

        $target_path = GeoIPService::db_path();
        $target_dir  = dirname( $target_path );
        if ( ! DataDirectoryProtection::ensure_local_protection( $target_dir ) ) {
            wp_delete_file( $tmp_file );
            return new \WP_Error(
                'sitefort_geoip_directory_failed',
                'SiteFort could not prepare the GeoIP runtime storage directory.'
            );
        }

        $mmdb_content = $this->extract_mmdb( $tmp_file );
        wp_delete_file( $tmp_file );

        if ( is_wp_error( $mmdb_content ) ) {
            return $mmdb_content;
        }

        $temp_path = $target_path . '.tmp.' . $this->temp_suffix();

        if ( ! GuardedDataFile::write_payload_atomic( $temp_path, $mmdb_content ) ) {
            wp_delete_file( $temp_path );
            return new \WP_Error(
                'sitefort_geoip_write_failed',
                'Failed to write GeoIP database to disk.'
            );
        }

        // Validate the written file ends with the MMDB metadata marker
        // (searched within the last 128KB, where the metadata section lives).
        $payload_offset = GuardedDataFile::payload_offset_for_path( $temp_path );
        $raw_size       = filesize( $temp_path );
        $payload_size   = ( false === $raw_size ) ? false : max( 0, (int) $raw_size - $payload_offset );

        $check = false;
        if ( false !== $payload_size ) {
            $check = file_get_contents( $temp_path, false, null, $payload_offset + max( 0, $payload_size - 131072 ) );
        }
        if ( $check === false || strpos( $check, GeoIPService::MMDB_METADATA_MARKER ) === false ) {
            wp_delete_file( $temp_path );
            return new \WP_Error(
                'sitefort_geoip_invalid',
                'Downloaded file does not appear to be a valid MMDB database.'
            );
        }

        // Promote the extracted database into place through the shared
        // filesystem mutation layer so all transports follow the same rules.
        if ( ! FilesystemMutation::move_path( $temp_path, $target_path ) ) {
            wp_delete_file( $temp_path );
            return new \WP_Error(
                'sitefort_geoip_rename_failed',
                'Failed to finalize GeoIP database installation.'
            );
        }

        \SiteFort\Includes\SettingsStore::set_field( \SiteFort\Includes\SettingsStore::COL_STATE, 'geoip_last_download', time() );

        return true;
    }

    /**
     * Verify MaxMind credentials without downloading the database.
     *
     * Performs only the authentication step of the download flow: MaxMind
     * answers a valid Basic Auth request with a redirect to a signed S3 URL
     * and rejects bad credentials with 401/403.
     *
     * @param string $account_id  MaxMind account ID.
     * @param string $license_key MaxMind license key.
     * @return true|\WP_Error True when MaxMind accepts the credentials.
     */
    public function verify_credentials( string $account_id, string $license_key ) {
        if ( empty( $account_id ) || empty( $license_key ) ) {
            return new \WP_Error(
                'sitefort_geoip_no_key',
                'MaxMind account ID and license key are both required.'
            );
        }

        $url    = self::DOWNLOAD_URL . '/GeoLite2-Country/download?suffix=tar.gz';
        $result = $this->request_signed_download_url( $url, $account_id, $license_key );

        return is_wp_error( $result ) ? $result : true;
    }

    /**
     * Get information about the current database file.
     *
     * @return array{exists: bool, last_modified: int|null, file_size: int|null, last_download: int|null}
     */
    public function get_db_info(): array {
        $path = GeoIPService::db_path();

        $info = [
            'exists'        => false,
            'last_modified' => null,
            'file_size'     => null,
            'last_download' => \SiteFort\Includes\SettingsStore::get_field( \SiteFort\Includes\SettingsStore::COL_STATE, 'geoip_last_download', null ),
        ];

        if ( is_file( $path ) && is_readable( $path ) ) {
            $raw_size       = filesize( $path );
            $payload_offset = GuardedDataFile::payload_offset_for_path( $path );
            $info['exists']        = true;
            $info['last_modified'] = filemtime( $path ) ?: null;
            $info['file_size']     = ( false !== $raw_size ) ? max( 0, (int) $raw_size - $payload_offset ) : null;
        }

        return $info;
    }

    /**
     * Remove the installed database file and its download timestamp.
     *
     * Used when MaxMind credentials are disconnected: without credentials the
     * file can never be refreshed, so a stale copy should not linger.
     */
    public function delete_database(): bool {
        $path = GeoIPService::db_path();

        if ( is_file( $path ) && ! wp_delete_file( $path ) && is_file( $path ) ) {
            return false;
        }

        \SiteFort\Includes\SettingsStore::delete_field( \SiteFort\Includes\SettingsStore::COL_STATE, 'geoip_last_download' );
        return true;
    }

    /**
     * Check if the database needs updating (missing or older than MAX_AGE).
     */
    public function needs_update(): bool {
        $info = $this->get_db_info();

        if ( ! $info['exists'] ) {
            return true;
        }

        if ( $info['last_modified'] === null ) {
            return true;
        }

        return ( time() - $info['last_modified'] ) > self::MAX_AGE;
    }

    /**
     * Download a file via wp_remote_get() with HTTP Basic Auth and stream to a temp file.
     *
     * @param string $url         The download URL.
     * @param string $account_id  MaxMind account ID (Basic Auth username).
     * @param string $license_key MaxMind license key (Basic Auth password).
     * @return string|\WP_Error Path to the downloaded temp file, or WP_Error on failure.
     */
    private function download_archive( string $url, string $account_id, string $license_key ) {
        $redirect_url = $this->request_signed_download_url( $url, $account_id, $license_key );
        if ( is_wp_error( $redirect_url ) ) {
            return $redirect_url;
        }

        // wp_tempnam() lives in wp-admin/includes/file.php, which is not
        // loaded in REST or cron context.
        if ( ! function_exists( 'wp_tempnam' ) ) {
            require_once ABSPATH . 'wp-admin/includes/file.php';
        }
        $tmpfname = wp_tempnam( 'GeoLite2-Country.tar.gz' );
        if ( ! $tmpfname ) {
            return new \WP_Error(
                'sitefort_geoip_download_failed',
                'Could not create temporary file for GeoIP download.'
            );
        }

        // The HTTP timeout below does not extend PHP's own limit; without this,
        // hosts with a 30-60s max_execution_time can die mid-stream.
        if ( function_exists( 'set_time_limit' ) ) {
            @set_time_limit( 300 ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged, WordPress.PHP.NoSilencedErrors.Discouraged -- Streaming the GeoIP database can outlast max_execution_time; best effort, some hosts disable set_time_limit().
        }

        $download_args = [
            'timeout'  => 300,
            'stream'   => true,
            'filename' => $tmpfname,
            'reject_unsafe_urls' => true,
        ];
        $response = function_exists( 'wp_safe_remote_get' )
            ? wp_safe_remote_get( $redirect_url, $download_args )
            : wp_remote_get( $redirect_url, $download_args );

        if ( is_wp_error( $response ) ) {
            wp_delete_file( $tmpfname );
            return new \WP_Error(
                'sitefort_geoip_download_failed',
                'Failed to download GeoIP database: ' . $response->get_error_message()
            );
        }

        $dl_code = wp_remote_retrieve_response_code( $response );
        if ( 200 !== $dl_code ) {
            wp_delete_file( $tmpfname );
            return new \WP_Error(
                'sitefort_geoip_download_failed',
                'Download failed (HTTP ' . $dl_code . '). Please try again.'
            );
        }

        return $tmpfname;
    }

    /**
     * Authenticate against MaxMind and return the signed S3 download URL.
     *
     * Does NOT follow redirects: MaxMind redirects to an S3 pre-signed URL,
     * and if we follow the redirect with the Basic Auth header attached, S3
     * rejects it (missing x-amz-content-sha256).
     *
     * @param string $url         The MaxMind download URL.
     * @param string $account_id  MaxMind account ID (Basic Auth username).
     * @param string $license_key MaxMind license key (Basic Auth password).
     * @return string|\WP_Error The trusted redirect URL, or WP_Error on failure.
     */
    private function request_signed_download_url( string $url, string $account_id, string $license_key ) {
        $auth_response = wp_remote_get( $url, [
            'timeout'     => 30,
            'redirection' => 0,
            'headers'     => [
                'Authorization' => 'Basic ' . base64_encode( $account_id . ':' . $license_key ),
            ],
        ] );

        if ( is_wp_error( $auth_response ) ) {
            return new \WP_Error(
                'sitefort_geoip_download_failed',
                'Failed to connect to MaxMind: ' . $auth_response->get_error_message()
            );
        }

        $code = wp_remote_retrieve_response_code( $auth_response );

        if ( $code === 401 || $code === 403 ) {
            return new \WP_Error(
                'sitefort_geoip_auth_failed',
                'MaxMind did not accept these credentials. Please confirm the Account ID and License Key in your MaxMind account, then try again.'
            );
        }

        // Expect a 3xx redirect to the S3 download URL.
        $redirect_url = wp_remote_retrieve_header( $auth_response, 'location' );
        if ( empty( $redirect_url ) || $code < 300 || $code >= 400 ) {
            $body = wp_remote_retrieve_body( $auth_response );
            return new \WP_Error(
                'sitefort_geoip_download_failed',
                'Unexpected response from MaxMind (HTTP ' . $code . ').'
                . ( $body !== '' ? ' ' . substr( wp_strip_all_tags( $body ), 0, 200 ) : '' )
            );
        }
        $redirect_url = is_string( $redirect_url ) ? trim( $redirect_url ) : '';
        if ( ! RemoteUrlValidator::is_trusted_maxmind_redirect_url( $redirect_url ) ) {
            return new \WP_Error(
                'sitefort_geoip_untrusted_redirect',
                'MaxMind returned an untrusted GeoIP download URL.'
            );
        }

        return $redirect_url;
    }

    /**
     * Extract the .mmdb file content from a tar.gz archive.
     *
     * @param string $archive_path Path to the tar.gz file.
     * @return string|\WP_Error The raw MMDB file contents, or WP_Error on failure.
     */
    private function extract_mmdb( string $archive_path ) {
        if ( ! class_exists( '\PharData' ) ) {
            return new \WP_Error(
                'sitefort_geoip_no_phar',
                'PharData extension is required to extract the GeoIP database.'
            );
        }

        try {
            $phar = new \PharData( $archive_path );

            foreach ( new \RecursiveIteratorIterator( $phar ) as $file ) {
                /** @var \PharFileInfo $file */
                if ( str_ends_with( $file->getFilename(), '.mmdb' ) ) {
                    if ( $file->getSize() > self::MAX_MMDB_SIZE ) {
                        return new \WP_Error(
                            'sitefort_geoip_too_large',
                            'The downloaded GeoIP database exceeds the expected size.'
                        );
                    }
                    $content = $file->getContent();
                    if ( $content !== '' ) {
                        return $content;
                    }
                }
            }

            return new \WP_Error(
                'sitefort_geoip_no_mmdb',
                'No .mmdb file found in the downloaded archive.'
            );
        } catch ( \Throwable $e ) {
            return new \WP_Error(
                'sitefort_geoip_extract_failed',
                'Failed to extract GeoIP database: ' . $e->getMessage()
            );
        }
    }

    /**
     * Generate a filesystem-safe temporary suffix.
     */
    private function temp_suffix(): string {
        try {
            return bin2hex( random_bytes( 6 ) );
        } catch ( \Throwable $e ) {
            return dechex( wp_rand( 0, PHP_INT_MAX ) );
        }
    }
}
