<?php

namespace Drupal\lingotek\Access;

use Drupal\Core\Access\AccessResult;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\Request;

/**
 * Checks Basic Auth credentials for Lingotek notification callbacks.
 */
class LingotekNotifyAccessCheck implements ContainerInjectionInterface {

  /**
   * The config factory.
   *
   * @var \Drupal\Core\Config\ConfigFactoryInterface
   */
  protected $configFactory;

  /**
   * The Lingotek logger.
   *
   * @var \Psr\Log\LoggerInterface
   */
  protected $logger;

  /**
   * Constructs a Lingotek notification access checker.
   *
   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
   *   The config factory.
   * @param \Psr\Log\LoggerInterface $logger
   *   The Lingotek logger.
   */
  public function __construct(ConfigFactoryInterface $config_factory, LoggerInterface $logger) {
    $this->configFactory = $config_factory;
    $this->logger = $logger;
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container) {
    return new static(
      $container->get('config.factory'),
      $container->get('logger.channel.lingotek')
    );
  }

  /**
   * Validates the callback Authorization header.
   *
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   The inbound request.
   *
   * @return \Drupal\Core\Access\AccessResult
   *   The access result.
   */
  public function access(Request $request) {
    $account_config = $this->configFactory->get('lingotek.account');
    $stored_username = (string) $account_config->get('notify_auth_username');
    $stored_password_hash = (string) $account_config->get('notify_auth_password_hash');

    $authorization_header = trim((string) $request->headers->get('Authorization', ''));
    $credentials = $this->parseBasicAuthorizationHeader($authorization_header);
    // Reverse proxies sometimes strip Authorization but PHP still exposes credentials.
    if ($credentials === NULL) {
      $php_user = $request->getUser();
      $php_pass = $request->getPassword();
      if (is_string($php_user) && $php_user !== '' && is_string($php_pass) && $php_pass !== '') {
        $credentials = [$php_user, $php_pass];
      }
    }

    if ($stored_username === '' || $stored_password_hash === '' || $credentials === NULL) {
      $this->logger->warning('Rejected Lingotek notification callback because Basic Auth credentials were missing or invalid. Headers: @headers', [
        '@headers' => $this->formatRequestHeaders($request),
      ]);
      return AccessResult::forbidden()->setCacheMaxAge(0);
    }

    [$username, $password] = $credentials;
    // The Basic Auth password must be the original plaintext secret sent to Lingotek when
    // credentials were pushed (random base64 from pushProjectCallbackCredentials), not the
    // bcrypt hash stored in notify_auth_password_hash.
    $username_ok = hash_equals($stored_username, $username);
    $password_ok = password_verify($password, $stored_password_hash);
    if (!$username_ok || !$password_ok) {
      $reasons = [];
      if (!$username_ok) {
        $reasons[] = 'username_mismatch';
      }
      if (!$password_ok) {
        $reasons[] = 'password_verify_failed';
      }
      $this->logger->warning('Rejected Lingotek notify access: @reasons (username_ok=@u_ok, password_ok=@p_ok). Headers: @headers', [
        '@reasons' => implode(', ', $reasons),
        '@u_ok' => $username_ok ? 'yes' : 'no',
        '@p_ok' => $password_ok ? 'yes' : 'no',
        '@headers' => $this->formatRequestHeaders($request),
      ]);
      return AccessResult::forbidden()->setCacheMaxAge(0);
    }

    return AccessResult::allowed()->setCacheMaxAge(0);
  }

  /**
   * Parses Basic Auth credentials from the Authorization header.
   *
   * @param string $authorization_header
   *   The Authorization header value.
   *
   * @return array|null
   *   A username/password tuple, or NULL when malformed.
   */
  protected function parseBasicAuthorizationHeader($authorization_header) {
    if (!preg_match('/^Basic\s+(.+)$/i', $authorization_header, $matches)) {
      return NULL;
    }

    $decoded = base64_decode($matches[1], TRUE);
    if ($decoded === FALSE || strpos($decoded, ':') === FALSE) {
      return NULL;
    }

    return explode(':', $decoded, 2);
  }

  /**
   * Formats request headers for temporary callback debugging.
   *
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   The inbound request.
   *
   * @return string
   *   JSON-encoded request headers.
   */
  protected function formatRequestHeaders(Request $request) {
    return json_encode($request->headers->all());
  }

}
