<?php

// phpcs:disable

if ( !class_exists( 'MeowKitPro_WPMC_Licenser' ) ) {

  class MeowKitPro_WPMC_Licenser {  
    public $license = null;
    public $prefix;     // prefix used for actions, filters (mfrh)
    public $mainfile;   // plugin main file (media-file-renamer.php)
    public $domain;     // domain used for translation (media-file-renamer)
    public $item;       // name of the Pro plugin (Media File Renamer Pro)
    public $version;    // version of the plugin (Media File Renamer Pro)
    public $item_id;

    private $primary_url = 'https://check.meowapps.com/'; // Primary license check URL
    private $fallback_enabled = true; // Set to false to disable fallback to old URL

    /**
    * Constructor for the MeowKitPro_WPMC_Licenser class.
    *
    * @param string $prefix The prefix used for actions and filters.
    * @param string $mainfile The plugin main file.
    * @param string $domain The domain used for translation.
    * @param string $item The name of the Pro plugin.
    * @param string $version The version of the plugin.
    */
    public function __construct( $prefix, $mainfile, $domain, $item, $version ) {
      $this->prefix = $prefix;
      $this->mainfile = $mainfile;
      $this->domain = $domain;
      $this->item = $item;
      $this->version = $version;
      $item_id_key = strtoupper( $this->prefix ) . '_ITEM_ID';
      if ( defined( $item_id_key ) ) {
        $this->item_id = constant( $item_id_key );
      }

      if ( $this->is_registered() ) {
        add_filter( $this->prefix . '_meowapps_is_registered', [ $this, 'is_registered' ], 10 );
      }

      if ( MeowKit_WPMC_Helpers::is_rest() ) {
        new MeowKitPro_WPMC_Rest_License( $this );
      }
      else if ( is_admin() ) {
        $license_key = isset( $this->license['key'] ) ? $this->license['key'] : '';
        $updater_options = [
          'version' => $this->version,
          'license' => $license_key,
          'wp_override' => true,
          'author' => 'Jordy Meow',
          'url' => strtolower( home_url() ),
          'beta' => false
        ];
        if ( $this->item_id ) {
          $updater_options['item_id'] = $this->item_id;
        }
        else {
          $updater_options['item_name'] = $this->item;
        }
        $api_url = ( get_option( 'force_sslverify', false ) ? 'https' : 'http' ) . '://meowapps.com';
        new MeowKitPro_WPMC_Updater( $api_url, $this->mainfile, $updater_options );
        if ( class_exists( 'MeowKitPro_WPMC_Renewal' ) ) {
          new MeowKitPro_WPMC_Renewal( $this );
        }
      }
    }

    /**
    * Retry validation of the license.
    */
    public function retry_validation() {
      if ( isset( $_POST[$this->prefix . '_pro_serial'] ) ) {
        $serial = sanitize_text_field( $_POST[$this->prefix . '_pro_serial'] );
        $this->validate_pro( $serial );
      }
    }

    /**
    * Check if the plugin is registered.
    *
    * @param bool $force Force re-check.
    * @return bool
    */
    public function is_registered( $force = false ) {
      $constant_name = 'MEOWAPPS_' . strtoupper( $this->prefix ) . '_LICENSE';
      if ( defined( $constant_name ) ) {
        $license = strtolower( trim( (string) constant( $constant_name ) ) );
        if ( !empty( $license ) ) {
          $this->license = [
            'key' => $license,
            'logs' => 'Enabled by constant.'
          ];
          return true;
        }
      }

      if ( !$force && !empty( $this->license ) ) {
        $has_no_issues = empty( $this->license['issue'] );
        return $has_no_issues;
      }
      $this->license = get_option( $this->prefix . '_license', '' );
      if ( empty( $this->license ) || !empty( $this->license['issue'] ) ) {
        return false;
      }
      if ( $this->license['expires'] == 'lifetime' ) {
        return true;
      }
      $datediff = strtotime( (string) $this->license['expires'] ) - time();
      $days = floor( $datediff / ( 60 * 60 * 24 * 7 * 3 ) );
      if ( $days < 0 ) {
        return $this->revalidate_with_grace();
      }
      return true;
    }

    /**
    * Re-check a license that was valid but whose expiry date has passed.
    *
    * One failed synchronous check used to switch Pro off on the spot, with no retry:
    * a renewal that had not reached the store yet, or a server briefly unable to get
    * out, cost the customer Pro features and usually became a ticket or a refund.
    * A failure now opens a grace window: Pro stays on, the check is retried every
    * few hours, and only a failure that outlives the window turns Pro off.
    *
    * @return bool
    */
    private function revalidate_with_grace() {
      // validate_pro() ends with is_registered( true ), which lands back here if the
      // store ever answers "valid" with an expiry date already in the past.
      static $running = false;
      if ( $running ) {
        return true;
      }
      $grace_days = 7;
      $retry_hours = 12;
      $previous = $this->license;
      $now = time();

      if ( !empty( $previous['retry_after'] ) && $now < $previous['retry_after'] ) {
        return true;
      }
      // Claim the retry slot before the network call, so concurrent requests do not
      // all hit the license server (and validate_pro() deletes the option first).
      $previous['retry_after'] = $now + $retry_hours * HOUR_IN_SECONDS;
      update_option( $this->prefix . '_license', $previous );

      $running = true;
      $valid = $this->validate_pro( $previous['key'] );
      $running = false;
      if ( $valid ) {
        // validate_pro() rewrites the option without retry_after. If the "valid" answer
        // still reads as expired (expires missing, unparsable or stale), every page load
        // would check again: two sites looped for an hour each in October 2026 because of
        // this (5,000+ calls). Keep the retry slot until a real future expiry comes back.
        $fresh = get_option( $this->prefix . '_license', '' );
        if ( is_array( $fresh ) && ( !isset( $fresh['expires'] ) || $fresh['expires'] !== 'lifetime' ) ) {
          $expires_at = isset( $fresh['expires'] ) ? strtotime( (string) $fresh['expires'] ) : false;
          if ( !$expires_at || $expires_at < $now ) {
            $fresh['retry_after'] = $previous['retry_after'];
            update_option( $this->prefix . '_license', $fresh );
            $this->license = $fresh;
          }
        }
        return true;
      }

      $grace_until = !empty( $previous['grace_until'] ) ? $previous['grace_until'] : $now + $grace_days * DAY_IN_SECONDS;
      if ( $now >= $grace_until ) {
        return false;
      }
      // Keep the license as it was, and remember why the check failed so the license
      // block can tell the customer before the window closes.
      $previous['grace_until'] = $grace_until;
      $previous['grace_issue'] = isset( $this->license['issue'] ) ? $this->license['issue'] : null;
      update_option( $this->prefix . '_license', $previous );
      $this->license = $previous;
      return true;
    }

    /**
    * Validate the Pro license.
    *
    * @param string $subscr_id The subscription ID.
    * @param bool $override Whether to override existing validation.
    * @return bool
    */
    public function validate_pro( $subscr_id, $override = false ) {
      $prefix = $this->prefix;
      delete_option( $prefix . '_license', '' );

      // Keys are lowercase hex: a genuine key pasted in uppercase or with spaces must
      // not fail, nor be flagged as tampered by the license block.
      $subscr_id = strtolower( trim( $subscr_id ) );

      if ( empty( $subscr_id ) ) {
        $this->license = null;
        return false;
      }

      if ( $override ) {
        // This doesn't work with updates.
        $current_user = wp_get_current_user();
        delete_option( '_site_transient_update_plugins' );
        $url = $this->primary_url;
        if ( strpos( $url, '?' ) === false ) {
          $url .= '?';
        }
        else {
          $url .= '&';
        }
        $query_params = [
          'item_id' => $this->item_id ? $this->item_id : null,
          'item_name' => $this->item_id ? null : urlencode( $this->item ),
          'license' => $subscr_id,
          'url' => strtolower( home_url() ),
        ];
        $url .= http_build_query( array_filter( $query_params ) );
        update_option( $prefix . '_license', [ 'key' => $subscr_id, 'issue' => null,
          'logs' => sprintf( 'Forced by %s on %s.', $current_user->user_email, date( 'Y/m/d' ) ),
          'expires' => 'lifetime', 'license' => null, 'check_url' => $url ] );
      }
      else {
        $license_valid = false;
        $status = null;
        $license = null;
        $expires = null;
        $debug = null;
        $last_http_code = null;
        $last_body = null;
        $urls_to_try = [ $this->primary_url ];

        if ( $this->fallback_enabled ) {
          $fallback_url = $this->getFallbackUrl();
          if ( $fallback_url ) {
            $urls_to_try[] = $fallback_url;
          }
        }

        foreach ( $urls_to_try as $url_base ) {
          $url = $url_base;
          if ( strpos( $url, '?' ) === false ) {
            $url .= '?';
          }
          else {
            $url .= '&';
          }

          $query_params = [];

          // Include edd_action=activate_license only for the fallback URL
          if ( $url_base === $this->getFallbackUrl() ) {
            $query_params['edd_action'] = 'activate_license';
          }

          if ( $this->item_id ) {
            $query_params['item_id'] = $this->item_id;
          }
          else {
            $query_params['item_name'] = urlencode( $this->item );
          }
          $query_params['license'] = $subscr_id;
          $query_params['url'] = strtolower( home_url() );
          $query_params['cache'] = bin2hex( openssl_random_pseudo_bytes( 4 ) );

          $url .= http_build_query( $query_params );

          $response = wp_remote_get(
            $url,
            [
              'user-agent' => 'MeowApps',
              'sslverify' => get_option( 'force_sslverify', false ),
              // check.meowapps.com is a Worker: it answers in well under a second or
              // it errors. Nothing legitimate lives between 20 and 45 seconds, so the
              // old 45 only ever bought hang time on a server that cannot get out,
              // twice over since there is a fallback URL. Do not raise it back without
              // a case that actually needs it.
              'timeout' => 20,
              'method' => 'GET'
            ]
          );
          $body = is_array( $response ) ? $response['body'] : null;
          $http_code = is_array( $response ) ? $response['response']['code'] : null;
          $post = @json_decode( $body );

          if ( $post && !property_exists( $post, 'code' ) && isset( $post->license ) && $post->license === 'valid' ) {
            $license = $post->license;
            $expires = isset( $post->expires ) ? $post->expires : null;
            delete_option( '_site_transient_update_plugins' );
            $license_valid = true;
            break; // Exit the loop
          }
          else {
            // Record the error or status for debugging
            // The store reports a failure in one of two shapes: an `error` key for
            // activation problems (no_activations_left, expired, key_mismatch...), or
            // a plain `license` status such as site_inactive when the key is fine but
            // not valid for this install. Only reading `error` collapsed the second
            // case into 'invalid_response', which reads like a connection failure and
            // is not one, so check both before giving up.
            $attempt_status = 'no_response';
            if ( $post ) {
              if ( isset( $post->error ) ) {
                $attempt_status = $post->error;
              }
              else if ( isset( $post->license ) && is_string( $post->license ) ) {
                $attempt_status = $post->license;
              }
              else {
                $attempt_status = 'invalid_response';
              }
            }
            // A real answer from the store (no_activations_left, expired...) must win
            // over a later network failure: the fallback used to overwrite it, so a
            // buyer out of activations was told to check their firewall. Connection
            // level codes are only reported when no URL gave a real answer.
            if ( $status !== null && !in_array( $status, [ 'no_response', 'invalid_response' ], true ) ) {
              continue;
            }
            $status = $attempt_status;
            // A real store answer is final: asking the fallback again cannot change it,
            // and its direct ?edd_action call is what Cloudflare rate-limits (error 1015,
            // about an hour). The fallback is only for network or unreadable answers.
            if ( !in_array( $attempt_status, [ 'no_response', 'invalid_response' ], true ) ) {
              $last_http_code = $http_code;
              $last_body = $body;
              break;
            }
            // Only remember what this attempt saw. The probes that describe the
            // connection used to run right here, so a server that cannot get out paid
            // for them once per URL tried, on top of two 45 second license timeouts:
            // close to two minutes of a hanging admin page before any message showed
            // up. They say nothing about the attempt itself, so they now run once,
            // after the loop, and only when every URL has failed.
            $last_http_code = $http_code;
            $last_body = $body;

            // Continue to next URL in the list
          }
        } // End of foreach

        if ( !$license_valid ) {
          // License validation failed with all URLs
          $debug = $this->collect_debug( $last_http_code, $last_body );
          update_option(
            $prefix . '_license',
            [ 'key' => $subscr_id, 'issue' => $status,
              'debug' => $debug, 'expires' => null, 'license' => null ]
          );
        }
        else {
          // License validated successfully
          update_option(
            $prefix . '_license',
            [ 'key' => $subscr_id, 'issue' => null,
              'debug' => null, 'expires' => $expires, 'license' => $license ]
          );
        }
      }
      return $this->is_registered( true );
    }

    /**
    * Collect connection diagnostics after a failed license validation.
    *
    * This reaches out to google.com, meowapps.com and ipify.org and resolves the
    * server name, so it is only ever worth running once, when every URL has already
    * failed. The keys are read by the license block in the admin, do not rename them.
    *
    * @param int|null $http_code The HTTP code of the last license call.
    * @param string|null $body The body of the last license call.
    * @return array
    */
    private function collect_debug( $http_code, $body ) {
      $debug = [
        'resolved_ip' => null,
        'server_addr' => null,
        'server_host' => null,
        'google_response_code' => null,
        'meowapps_response_code' => null,
        'license_response_code' => $http_code,
        'google_body' => null,
        'meowapps_body' => null,
        'license_body' => null,
        'google_reason' => null,
        'meowapps_reason' => null,
        'license_reason' => null,
        'google_cf_ray' => null,
        'meowapps_cf_ray' => null,
        'license_cf_ray' => null,
      ];

      // Google response
      $google_response = wp_remote_get( 'https://google.com' );
      $debug['google_response_code'] = is_wp_error( $google_response ) ? print_r( $google_response, true ) : wp_remote_retrieve_response_code( $google_response );
      if ( $debug['google_response_code'] !== 200 ) {
        $debug['google_body'] = wp_remote_retrieve_body( $google_response );
        // Detect block in Google response
        $google_block_info = $this->detect_block( $debug['google_body'] );
        if ( $google_block_info ) {
          $debug['google_reason'] = $google_block_info['reason'];
          if ( isset( $google_block_info['cf_ray'] ) ) {
            $debug['google_cf_ray'] = $google_block_info['cf_ray'];
          }
        }
      }

      // MeowApps response
      $meowapps_response = wp_remote_get( 'https://meowapps.com' );
      $debug['meowapps_response_code'] = is_wp_error( $meowapps_response ) ? print_r( $meowapps_response, true ) : wp_remote_retrieve_response_code( $meowapps_response );
      if ( $debug['meowapps_response_code'] !== 200 ) {
        $debug['meowapps_body'] = wp_remote_retrieve_body( $meowapps_response );
        // Detect block in MeowApps response
        $meowapps_block_info = $this->detect_block( $debug['meowapps_body'] );
        if ( $meowapps_block_info ) {
          $debug['meowapps_reason'] = $meowapps_block_info['reason'];
          if ( isset( $meowapps_block_info['cf_ray'] ) ) {
            $debug['meowapps_cf_ray'] = $meowapps_block_info['cf_ray'];
          }
        }
      }

      // License response
      if ( $http_code !== 200 ) {
        $debug['license_body'] = $body;
        // Detect block in License response
        $license_block_info = $this->detect_block( $debug['license_body'] );
        if ( $license_block_info ) {
          $debug['license_reason'] = $license_block_info['reason'];
          if ( isset( $license_block_info['cf_ray'] ) ) {
            $debug['license_cf_ray'] = $license_block_info['cf_ray'];
          }
        }
      }

      // Resolve IP
      $resIp = wp_remote_get( 'https://api.ipify.org/' );
      if ( !is_wp_error( $resIp ) ) {
        $debug['resolved_ip'] = wp_remote_retrieve_body( $resIp );
      }
      // Both are missing on some setups (WP-CLI, cron, a few hosts). Reading them
      // blind raised an undefined key warning on PHP 8 in the exact situation this
      // block is meant to document.
      $debug['server_addr'] = isset( $_SERVER['SERVER_ADDR'] ) ? $_SERVER['SERVER_ADDR'] : null;
      if ( isset( $_SERVER['SERVER_NAME'] ) ) {
        $debug['server_host'] = gethostbyname( $_SERVER['SERVER_NAME'] );
      }

      return $debug;
    }

    /**
    * Get the fallback URL for license validation.
    *
    * @return string|null The fallback URL, or null if not available.
    */
    private function getFallbackUrl() {
      // Base64 encoded URL to hide it from direct view
      $encoded_url = 'aHR0cHM6Ly9tZW93YXBwcy5jb20v';
      return base64_decode( $encoded_url );
    }

    /**
    * Detect if the response body indicates a security block.
    *
    * @param string $body The response body.
    * @return array|null An array with block information or null if not blocked.
    */
    private function detect_block( $body ) {
      // A failed request has no body at all, and passing null to strpos() has been
      // deprecated since PHP 8.1. That is the unreachable-server case, so this guard
      // is on the most travelled path here, not an edge one.
      if ( !is_string( $body ) || $body === '' ) {
        return null;
      }

      $info = [];

      // Check for Cloudflare block
      if ( strpos( $body, 'Attention Required! | Cloudflare' ) !== false || strpos( $body, 'Cloudflare Ray ID' ) !== false ) {
        $info['reason'] = 'CLOUDFLARE_SECURITY_TRIGGER';
        // Extract Cloudflare Ray ID
        if ( preg_match( '/Cloudflare Ray ID:\s*<strong.*?>(.*?)<\/strong>/', $body, $matches ) ) {
          $info['cf_ray'] = trim( $matches[1] );
        }
      }
      // Check for Google block
      elseif ( strpos( $body, "We're sorry" ) !== false && strpos( $body, 'automated queries' ) !== false ) {
        $info['reason'] = 'GOOGLE_SECURITY_TRIGGER';
      }

      return !empty( $info ) ? $info : null;
    }

  }
}
