<?php

declare(strict_types=1);

namespace SiteFort\Modules\Hardening\Tweaks;

use SiteFort\Modules\AuditLog\AuditLog;

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

/**
 * Breached Password Checker using the Have I Been Pwned (HIBP) Pwned Passwords API.
 *
 * Uses the k-anonymity model: only the first 5 characters of the SHA-1 hash
 * are sent to the API, so full password hashes are never transmitted.
 *
 */
class BreachedPasswordChecker {

    /**
     * Hardening module settings.
     *
     * @var array
     */
    private array $settings;

    /**
     * @param array $settings Hardening module settings.
     */
    public function __construct( array $settings ) {
        $this->settings = $settings;
    }

    /**
     * Whether breached-password checking is enabled.
     *
     * @return bool
     */
    public function is_enabled(): bool {
        return ! empty( $this->settings['check_breached_passwords'] );
    }

    /**
     * Check a plaintext password against the HIBP Pwned Passwords API.
     *
     * @param string $password The plaintext password to check.
     * @param bool   $force_refresh Whether to bypass the cached HIBP range response.
     * @return array{breached: bool, count: int, checked: bool}
     */
    public function check_password( string $password, bool $force_refresh = false ): array {
        $hash   = strtoupper( sha1( $password ) );
        $prefix = substr( $hash, 0, 5 );
        $suffix = substr( $hash, 5 );

        $results = $this->query_hibp_api( $prefix, $force_refresh );
        if ( null === $results ) {
            return [
                'breached' => false,
                'count'    => 0,
                'checked'  => false,
            ];
        }

        $count = isset( $results[ $suffix ] ) ? (int) $results[ $suffix ] : 0;
        if ( $count > 0 ) {
            return [
                'breached' => true,
                'count'    => $count,
                'checked'  => true,
            ];
        }

        return [
            'breached' => false,
            'count'    => 0,
            'checked'  => true,
        ];
    }

    /**
     * Validate password on profile update.
     *
     * Hooked to `user_profile_update_errors` (priority 10).
     *
     * @param \WP_Error  $errors The WP_Error object.
     * @param bool       $update Whether this is an update (true) or create (false).
     * @param \stdClass  $user   The user object being updated.
     */
    public function validate_profile_update( \WP_Error $errors, bool $update, \stdClass $user ): void {
        if ( empty( $user->user_pass ) ) {
            return;
        }

        $this->maybe_block_password( $errors, $user->user_pass );
    }

    /**
     * Validate password on password reset.
     *
     * Hooked to `validate_password_reset` (priority 10).
     *
     * @param \WP_Error $errors The WP_Error object.
     * @param \WP_User  $user   The user resetting their password.
     */
    public function validate_password_reset( \WP_Error $errors, \WP_User $user ): void {
        // phpcs:disable WordPress.Security.NonceVerification.Missing
        $password = isset( $_POST['pass1'] ) ? wp_unslash( $_POST['pass1'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- plaintext password must not be sanitized before HIBP hash check
        // phpcs:enable WordPress.Security.NonceVerification.Missing

        if ( empty( $password ) ) {
            return;
        }

        $this->maybe_block_password( $errors, $password );
    }

    /**
     * Validate password on user registration.
     *
     * Hooked to `registration_errors` (priority 10).
     *
     * @param \WP_Error $errors              The WP_Error object.
     * @param string    $sanitized_user_login The sanitized username.
     * @param string    $user_email           The user email.
     * @return \WP_Error
     */
    public function validate_registration( \WP_Error $errors, string $sanitized_user_login, string $user_email ): \WP_Error {
        // phpcs:disable WordPress.Security.NonceVerification.Missing
        $password = isset( $_POST['user_pass'] ) ? wp_unslash( $_POST['user_pass'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- plaintext password must not be sanitized before HIBP hash check
        // phpcs:enable WordPress.Security.NonceVerification.Missing

        if ( empty( $password ) ) {
            return $errors;
        }

        $this->append_error_if_breached( $errors, $password );

        return $errors;
    }

    /**
     * Append a breached-password error to the provided WP_Error object.
     *
     * This is shared across core WordPress and WooCommerce registration flows.
     *
     * @param \WP_Error $errors   Error collection to append to.
     * @param string    $password Plaintext password to check.
     */
    public function append_error_if_breached( \WP_Error $errors, string $password ): void {
        if ( $password === '' ) {
            return;
        }

        $this->maybe_block_password( $errors, $password );
    }

    /**
     * Check a password and add an error if it has been found in any data breach.
     *
     * @param \WP_Error $errors   The WP_Error object to append to.
     * @param string    $password The plaintext password.
     */
    private function maybe_block_password( \WP_Error $errors, string $password ): void {
        $result = $this->check_password( $password );

        if ( ! empty( $result['checked'] ) && $result['breached'] ) {
            $errors->add(
                'breached_password',
                sprintf(
                    /* translators: %s: number of data breaches the password appeared in */
                    __( 'This password has appeared in %s data breaches. Please choose a different password.', 'sitefort' ),
                    number_format_i18n( $result['count'] )
                )
            );
        }
    }

    /**
     * Query the HIBP Pwned Passwords range API.
     *
     * Results are cached in a transient for 1 hour.
     * On failure (timeout, HTTP error), returns null so callers can distinguish
     * "upstream unavailable" from a completed lookup with zero matches.
     *
     * @param string $prefix        The first 5 characters of the SHA-1 hash (uppercase).
     * @param bool   $force_refresh Whether to bypass the cached range response.
     * @return array<string, int>|null Map of hash suffixes (uppercase) to breach counts,
     *                                 or null when the upstream check could not complete.
     */
    private function query_hibp_api( string $prefix, bool $force_refresh = false ): ?array {
        $transient_key = 'sitefort_hibp_' . $prefix;
        $cached        = $force_refresh ? false : get_transient( $transient_key );

        if ( false !== $cached && is_array( $cached ) ) {
            return $cached;
        }

        $url      = 'https://api.pwnedpasswords.com/range/' . $prefix;
        $response = wp_remote_get( $url, [
            'timeout'    => 3,
            'sslverify'  => true,
            'headers'    => [
                'User-Agent' => 'SiteFort-WordPress-Security-Plugin',
            ],
        ] );

        // Fail open: if the API is unreachable, allow the password.
        // Log so admins know breach checking is degraded.
        if ( is_wp_error( $response ) ) {
            AuditLog::add_log( 'HIBP API Unreachable', [ 'error' => $response->get_error_message() ], 'warning', 'hardening' );
            return null;
        }

        $status_code = wp_remote_retrieve_response_code( $response );
        if ( 200 !== $status_code ) {
            AuditLog::add_log( 'HIBP API Error', [ 'status' => $status_code ], 'warning', 'hardening' );
            return null;
        }

        $body    = wp_remote_retrieve_body( $response );
        $results = $this->parse_response( $body );

        // Cache for 1 hour.
        set_transient( $transient_key, $results, HOUR_IN_SECONDS );

        return $results;
    }

    /**
     * Parse the HIBP API response body into an associative array.
     *
     * The API returns lines in the format: SUFFIX:COUNT\r\n
     *
     * @param string $body The raw response body.
     * @return array<string, int> Map of hash suffixes to breach counts.
     */
    private function parse_response( string $body ): array {
        $results = [];
        $lines   = explode( "\n", $body );

        foreach ( $lines as $line ) {
            $line = trim( $line, "\r\n " );
            if ( empty( $line ) ) {
                continue;
            }

            $parts = explode( ':', $line, 2 );
            if ( 2 !== count( $parts ) ) {
                continue;
            }

            $suffix = strtoupper( trim( $parts[0] ) );
            $count  = (int) trim( $parts[1] );

            if ( preg_match( '/\A[A-F0-9]{35}\z/', $suffix ) && $count > 0 ) {
                $results[ $suffix ] = $count;
            }
        }

        return $results;
    }
}
