<?php
/**
 * customer-auth.php
 * ---------------------------------------------------------------------------
 * Servizio REST/JSON standalone (fuori da Magento) per:
 *   1. autenticare i clienti Magento 2 di uno specifico store_id
 *   2. restituire / verificare i prodotti acquistati
 *
 * Non carica il framework Magento: legge solo le credenziali DB da app/etc/env.php
 * e interroga direttamente il database in sola lettura.
 *
 * ENDPOINT
 *   POST  customer-auth.php?action=login
 *         Body JSON: {"email":"...","password":"..."}
 *         -> token + dati cliente + prodotti acquistati
 *
 *   GET   customer-auth.php?action=products[&sku=ABC123]
 *         Header: Authorization: Bearer <token>
 *         -> prodotti acquistati (se passi sku: "purchased": true/false)
 *
 *   GET   customer-auth.php?action=me
 *         Header: Authorization: Bearer <token>
 *         -> dati cliente
 *
 * Requisiti: PHP >= 7.4, estensioni pdo_mysql e sodium (per gli hash Argon2 di Magento 2.4).
 * ---------------------------------------------------------------------------
 */

declare(strict_types=1);

/* ============================== CONFIGURAZIONE ============================== */

$CONFIG = [
    // Percorso di app/etc/env.php di Magento (da cui leggere host/db/user/password/prefisso)
    'magento_env_php' => '/var/www/ibiscus/app/etc/env.php',

    // Store view da cui accettare i clienti e le cui vendite considerare
    'store_id' => 1,

    // true = accetta solo clienti "creati in" quello store_id (customer_entity.store_id)
    // false = accetta tutti i clienti del website a cui appartiene lo store (consigliato se
    //         gli account clienti sono condivisi a livello di website, default Magento)
    'strict_customer_store' => false,

    // Stati ordine considerati "acquistato"
    'allowed_order_states' => ['complete', 'processing'],

    // Includi anche ordini fatti come ospite con la stessa email del cliente
    'include_guest_orders_by_email' => false,

    // Segreto per firmare i token (almeno 32 caratteri casuali!)
    // Generalo con: php -r "echo bin2hex(random_bytes(32));"
    'jwt_secret' => 'acddb702c0765e394ac602f284f5d187b063466524b8a1453240073120e0500e',

    // Durata token in secondi
    'token_ttl' => 3600 * 8,

    // Origini autorizzate per CORS (vuoto = nessun header CORS)
    'cors_allowed_origins' => [
        // 'https://www.tuosito.it',
    ],
];

/* ================================ BOOTSTRAP ================================= */

header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
header('Cache-Control: no-store');

handleCors($CONFIG['cors_allowed_origins']);

if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
    http_response_code(204);
    exit;
}

set_exception_handler(function (Throwable $e) {
    error_log('[customer-auth] ' . $e->getMessage());
    respond(500, ['success' => false, 'error' => 'internal_error']);
});

if (strlen($CONFIG['jwt_secret']) < 32 || strpos($CONFIG['jwt_secret'], 'CAMBIAMI') === 0) {
    respond(500, ['success' => false, 'error' => 'jwt_secret_not_configured']);
}

$action = $_GET['action'] ?? '';

switch ($action) {
    case 'login':
        requireMethod('POST');
        actionLogin($CONFIG);
        break;

    case 'products':
        requireMethod('GET');
        actionProducts($CONFIG);
        break;

    case 'me':
        requireMethod('GET');
        actionMe($CONFIG);
        break;

    default:
        respond(404, ['success' => false, 'error' => 'unknown_action']);
}

/* ================================= AZIONI ================================== */

function actionLogin(array $cfg): void
{
    $input = json_decode((string)file_get_contents('php://input'), true);
    if (!is_array($input)) {
        $input = $_POST; // fallback form-urlencoded
    }

    $email    = trim((string)($input['email'] ?? ''));
    $password = (string)($input['password'] ?? '');

    if ($email === '' || $password === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
        respond(400, ['success' => false, 'error' => 'invalid_input']);
    }

    $db       = db($cfg);
    $customer = findCustomer($db, $cfg, $email);

    // Risposta identica per utente inesistente e password errata (niente user enumeration)
    if (!$customer || !verifyMagentoPassword($password, (string)$customer['password_hash'])) {
        usleep(random_int(200000, 400000)); // rallenta brute force
        respond(401, ['success' => false, 'error' => 'invalid_credentials']);
    }

    if ((int)$customer['is_active'] !== 1) {
        respond(403, ['success' => false, 'error' => 'account_disabled']);
    }

    if (!empty($customer['lock_expires']) && strtotime($customer['lock_expires'] . ' UTC') > time()) {
        respond(403, ['success' => false, 'error' => 'account_locked']);
    }

    if (!empty($customer['confirmation'])) {
        respond(403, ['success' => false, 'error' => 'account_not_confirmed']);
    }

    $now   = time();
    $token = jwtEncode([
        'sub'   => (int)$customer['entity_id'],
        'email' => $customer['email'],
        'store' => (int)$cfg['store_id'],
        'iat'   => $now,
        'exp'   => $now + (int)$cfg['token_ttl'],
    ], $cfg['jwt_secret']);

    respond(200, [
        'success'    => true,
        'token'      => $token,
        'expires_at' => gmdate('c', $now + (int)$cfg['token_ttl']),
        'customer'   => publicCustomer($customer),
        'products'   => getPurchasedProducts($db, $cfg, (int)$customer['entity_id'], $customer['email']),
    ]);
}

function actionProducts(array $cfg): void
{
    $claims   = requireToken($cfg);
    $db       = db($cfg);
    $customer = findCustomerById($db, $cfg, (int)$claims['sub']);

    if (!$customer || (int)$customer['is_active'] !== 1) {
        respond(401, ['success' => false, 'error' => 'invalid_token']);
    }

    $products = getPurchasedProducts($db, $cfg, (int)$customer['entity_id'], $customer['email']);
    $response = ['success' => true, 'products' => $products];

    // Verifica di un singolo prodotto: ?sku=XXX  oppure  ?product_id=123
    if (isset($_GET['sku']) || isset($_GET['product_id'])) {
        $sku = isset($_GET['sku']) ? (string)$_GET['sku'] : null;
        $pid = isset($_GET['product_id']) ? (int)$_GET['product_id'] : null;

        $response['purchased'] = false;
        foreach ($products as $p) {
            if (($sku !== null && strcasecmp($p['sku'], $sku) === 0)
                || ($pid !== null && $p['product_id'] === $pid)) {
                $response['purchased'] = true;
                break;
            }
        }
    }

    respond(200, $response);
}

function actionMe(array $cfg): void
{
    $claims   = requireToken($cfg);
    $customer = findCustomerById(db($cfg), $cfg, (int)$claims['sub']);

    if (!$customer || (int)$customer['is_active'] !== 1) {
        respond(401, ['success' => false, 'error' => 'invalid_token']);
    }

    respond(200, ['success' => true, 'customer' => publicCustomer($customer)]);
}

/* ============================== QUERY DATABASE ============================== */

function db(array $cfg): PDO
{
    static $pdo = null;
    if ($pdo) {
        return $pdo;
    }

    $env = include $cfg['magento_env_php'];
    $c   = $env['db']['connection']['default'] ?? null;
    if (!$c) {
        throw new RuntimeException('Connessione DB non trovata in env.php');
    }

    $host = $c['host'];
    $port = null;
    if (strpos($host, ':') !== false && strpos($host, '/') === false) {
        [$host, $port] = explode(':', $host, 2);
    }

    $dsn = strpos($host, '/') === 0
        ? "mysql:unix_socket={$host};dbname={$c['dbname']};charset=utf8mb4"
        : "mysql:host={$host};" . ($port ? "port={$port};" : '') . "dbname={$c['dbname']};charset=utf8mb4";

    $pdo = new PDO($dsn, $c['username'], $c['password'] ?? '', [
        PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES   => false,
    ]);

    $GLOBALS['TABLE_PREFIX'] = (string)($env['db']['table_prefix'] ?? '');

    return $pdo;
}

/** Nome tabella con eventuale prefisso di Magento */
function t(string $name): string
{
    return '`' . ($GLOBALS['TABLE_PREFIX'] ?? '') . $name . '`';
}

function getWebsiteId(PDO $db, int $storeId): int
{
    static $cache = [];
    if (!isset($cache[$storeId])) {
        $st = $db->prepare('SELECT website_id FROM ' . t('store') . ' WHERE store_id = ?');
        $st->execute([$storeId]);
        $wid = $st->fetchColumn();
        if ($wid === false) {
            throw new RuntimeException("store_id {$storeId} inesistente");
        }
        $cache[$storeId] = (int)$wid;
    }
    return $cache[$storeId];
}

function customerScopeSql(PDO $db, array $cfg, array &$params): string
{
    if ($cfg['strict_customer_store']) {
        $params[] = (int)$cfg['store_id'];
        return 'store_id = ?';
    }
    // Account condivisi a livello website (website_id NULL = account globali)
    $params[] = getWebsiteId($db, (int)$cfg['store_id']);
    return '(website_id = ? OR website_id IS NULL)';
}

function findCustomer(PDO $db, array $cfg, string $email): ?array
{
    $params = [$email];
    $scope  = customerScopeSql($db, $cfg, $params);

    $st = $db->prepare(
        'SELECT entity_id, email, firstname, lastname, password_hash, is_active,
                store_id, website_id, group_id, confirmation, lock_expires
           FROM ' . t('customer_entity') . "
          WHERE email = ? AND {$scope}
          LIMIT 1"
    );
    $st->execute($params);
    return $st->fetch() ?: null;
}

function findCustomerById(PDO $db, array $cfg, int $id): ?array
{
    $params = [$id];
    $scope  = customerScopeSql($db, $cfg, $params);

    $st = $db->prepare(
        'SELECT entity_id, email, firstname, lastname, is_active,
                store_id, website_id, group_id
           FROM ' . t('customer_entity') . "
          WHERE entity_id = ? AND {$scope}
          LIMIT 1"
    );
    $st->execute($params);
    return $st->fetch() ?: null;
}

function getPurchasedProducts(PDO $db, array $cfg, int $customerId, string $email): array
{
    $states       = array_values($cfg['allowed_order_states']);
    $placeholders = implode(',', array_fill(0, count($states), '?'));

    $params = [(int)$cfg['store_id']];
    $who    = 'o.customer_id = ?';
    $params[] = $customerId;

    if ($cfg['include_guest_orders_by_email']) {
        $who = '(o.customer_id = ? OR (o.customer_id IS NULL AND o.customer_email = ?))';
        $params[] = $email;
    }

    $params = array_merge($params, $states);

    // parent_item_id IS NULL => una riga per prodotto (niente figli duplicati di configurabili/bundle)
    $sql = 'SELECT i.product_id,
                   i.sku,
                   i.name,
                   i.product_type,
                   SUM(i.qty_ordered - i.qty_refunded - i.qty_canceled) AS qty,
                   MIN(o.created_at)  AS first_purchase_at,
                   MAX(o.created_at)  AS last_purchase_at,
                   GROUP_CONCAT(DISTINCT o.increment_id ORDER BY o.created_at SEPARATOR \',\') AS orders
              FROM ' . t('sales_order') . ' o
              JOIN ' . t('sales_order_item') . " i ON i.order_id = o.entity_id
             WHERE o.store_id = ?
               AND {$who}
               AND o.state IN ({$placeholders})
               AND i.parent_item_id IS NULL
          GROUP BY i.product_id, i.sku, i.name, i.product_type
            HAVING qty > 0
          ORDER BY last_purchase_at DESC";

    $st = $db->prepare($sql);
    $st->execute($params);

    $out = [];
    foreach ($st->fetchAll() as $r) {
        $out[] = [
            'product_id'        => $r['product_id'] !== null ? (int)$r['product_id'] : null,
            'sku'               => $r['sku'],
            'name'              => $r['name'],
            'type'              => $r['product_type'],
            'qty'               => (float)$r['qty'],
            'first_purchase_at' => $r['first_purchase_at'],
            'last_purchase_at'  => $r['last_purchase_at'],
            'orders'            => $r['orders'] ? explode(',', $r['orders']) : [],
        ];
    }
    return $out;
}

function publicCustomer(array $c): array
{
    return [
        'id'         => (int)$c['entity_id'],
        'email'      => $c['email'],
        'firstname'  => $c['firstname'],
        'lastname'   => $c['lastname'],
        'group_id'   => (int)$c['group_id'],
        'store_id'   => (int)$c['store_id'],
        'website_id' => $c['website_id'] !== null ? (int)$c['website_id'] : null,
    ];
}

/* ========================= VERIFICA PASSWORD MAGENTO ========================= */

/**
 * Replica Magento\Framework\Encryption\Encryptor::isValidHash()
 *
 * Formato customer_entity.password_hash:  hash:salt:versione[:versione...]
 *   0                 = MD5(salt . password)            (Magento 1 migrato)
 *   1                 = SHA256(salt . password)
 *   2                 = Argon2ID13 (sodium, parametri "interactive")
 *   3_<len>_<ops>_<mem> = Argon2ID13 con parametri espliciti (Magento >= 2.4.7)
 * Versioni multiple = hash "aggiornati" a catena (es. "0:2" = MD5 poi Argon2).
 */
function verifyMagentoPassword(string $password, string $stored): bool
{
    if ($stored === '') {
        return false;
    }

    $parts = explode(':', $stored, 3);

    // Hash senza salt/versione: SHA256 semplice
    if (count($parts) < 3) {
        return hash_equals($stored, hash('sha256', $password));
    }

    [$hash, $salt, $versionString] = $parts;
    $recreated = $password;

    foreach (explode(':', $versionString) as $version) {
        if ($version === '0') {
            $recreated = md5($salt . $recreated);
        } elseif ($version === '1') {
            $recreated = hash('sha256', $salt . $recreated);
        } elseif ($version === '2') {
            $recreated = argonHash(
                $recreated,
                $salt,
                SODIUM_CRYPTO_SIGN_SEEDBYTES,
                SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE,
                SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE
            );
        } elseif (strpos($version, '3') === 0) {
            // "3_32_2_67108864" -> lunghezza, opslimit, memlimit
            $p = explode('_', $version);
            $recreated = argonHash(
                $recreated,
                $salt,
                (int)($p[1] ?? SODIUM_CRYPTO_SIGN_SEEDBYTES),
                (int)($p[2] ?? SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE),
                (int)($p[3] ?? SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE)
            );
        } else {
            return false; // versione sconosciuta
        }
    }

    return hash_equals($hash, $recreated);
}

function argonHash(string $data, string $salt, int $length, int $ops, int $mem): string
{
    if (!function_exists('sodium_crypto_pwhash')) {
        throw new RuntimeException('Estensione sodium mancante (necessaria per hash Argon2)');
    }

    $salt = substr($salt, 0, SODIUM_CRYPTO_PWHASH_SALTBYTES);
    if (strlen($salt) < SODIUM_CRYPTO_PWHASH_SALTBYTES) {
        $salt = str_pad($salt, SODIUM_CRYPTO_PWHASH_SALTBYTES, $salt);
    }

    return bin2hex(sodium_crypto_pwhash(
        $length,
        $data,
        $salt,
        $ops,
        $mem,
        SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13
    ));
}

/* ================================ TOKEN (JWT) =============================== */

function b64url(string $s): string
{
    return rtrim(strtr(base64_encode($s), '+/', '-_'), '=');
}

function b64urlDecode(string $s): string
{
    return (string)base64_decode(strtr($s, '-_', '+/'));
}

function jwtEncode(array $payload, string $secret): string
{
    $h = b64url(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
    $p = b64url(json_encode($payload));
    $s = b64url(hash_hmac('sha256', "$h.$p", $secret, true));
    return "$h.$p.$s";
}

function jwtDecode(string $jwt, string $secret): ?array
{
    $parts = explode('.', $jwt);
    if (count($parts) !== 3) {
        return null;
    }
    [$h, $p, $s] = $parts;

    $expected = b64url(hash_hmac('sha256', "$h.$p", $secret, true));
    if (!hash_equals($expected, $s)) {
        return null;
    }

    $header = json_decode(b64urlDecode($h), true);
    if (($header['alg'] ?? '') !== 'HS256') {
        return null;
    }

    $payload = json_decode(b64urlDecode($p), true);
    if (!is_array($payload) || ($payload['exp'] ?? 0) < time()) {
        return null;
    }
    return $payload;
}

function requireToken(array $cfg): array
{
    $auth = $_SERVER['HTTP_AUTHORIZATION']
        ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION']
        ?? (function_exists('getallheaders') ? (getallheaders()['Authorization'] ?? '') : '');

    if (!preg_match('/^Bearer\s+(\S+)$/i', (string)$auth, $m)) {
        respond(401, ['success' => false, 'error' => 'missing_token']);
    }

    $claims = jwtDecode($m[1], $cfg['jwt_secret']);
    if (!$claims || (int)($claims['store'] ?? -1) !== (int)$cfg['store_id']) {
        respond(401, ['success' => false, 'error' => 'invalid_token']);
    }
    return $claims;
}

/* ================================== UTILITY ================================= */

function respond(int $status, array $data): void
{
    http_response_code($status);
    echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    exit;
}

function requireMethod(string $method): void
{
    if (($_SERVER['REQUEST_METHOD'] ?? '') !== $method) {
        header('Allow: ' . $method);
        respond(405, ['success' => false, 'error' => 'method_not_allowed']);
    }
}

function handleCors(array $allowed): void
{
    $origin = $_SERVER['HTTP_ORIGIN'] ?? '';
    if ($origin !== '' && in_array($origin, $allowed, true)) {
        header('Access-Control-Allow-Origin: ' . $origin);
        header('Vary: Origin');
        header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
        header('Access-Control-Allow-Headers: Content-Type, Authorization');
        header('Access-Control-Max-Age: 600');
    }
}
