<?php
declare(strict_types=1);

namespace Autogedal\BlackFriday\Model;

use Magento\Framework\App\ResourceConnection;
use Magento\Framework\Math\Random;

/**
 * Subscribers of the landing page and their favourites (SKUs), one row per email.
 * Every subscription gets a real token, so the answer is the same for a new or an existing email:
 * - the token of the first subscription ("owner") can replace the list (add and remove favourites);
 * - the tokens of later subscriptions of the same email (other device, subscribed again) can only add favourites;
 * - without a valid token nothing changes. Knowing an email is not enough to empty or edit someone's list.
 * Both kinds of token learn that the email was unsubscribed meanwhile (to show the form again).
 * email_status drives the launch email: pending (has favourites), skip (no favourites), sending, sent, error.
 */
class SubscriberStorage
{
    public const TABLE = 'autogedal_blackfriday_subscriber';
    public const STATUS_PENDING = 'pending';
    public const STATUS_SENDING = 'sending';
    public const STATUS_SENT = 'sent';
    public const STATUS_ERROR = 'error';
    public const STATUS_SKIP = 'skip';
    public const REASON_UNSUBSCRIBED = 'Dezabonat de la newsletter';
    public const REASON_NO_FAVORITES = 'Fara produse favorite';
    public const MAX_FAVORITES = 300;
    public const ROLE_OWNER = 'owner';
    public const ROLE_DEVICE = 'device';
    private const MAX_DEVICE_TOKENS = 20;

    public function __construct(
        private readonly ResourceConnection $resource,
        private readonly Random $random
    ) {
    }

    public function get(string $email): ?array
    {
        $connection = $this->resource->getConnection();
        $row = $connection->fetchRow($connection->select()->from($this->table())->where('email = ?', $this->normalize($email)));
        return $row ?: null;
    }

    public function getById(int $id): ?array
    {
        $connection = $this->resource->getConnection();
        $row = $connection->fetchRow($connection->select()->from($this->table())->where('entity_id = ?', $id));
        return $row ?: null;
    }

    /**
     * Saves the favourites of an email on subscription. A new email gets its owner token; for an email already saved
     * the favourites are added to its list and a new device token is returned (only adds favourites later).
     *
     * @param string[] $skus
     */
    public function subscribe(string $email, array $skus, int $storeId, ?string $utmSource, ?string $utmCampaign): array
    {
        $email = $this->normalize($email);
        $row = $this->get($email);
        if ($row) {
            $data = $this->favoritesData($this->merge($row, $skus), (string)$row['email_status']);
            // keeps the first campaign link when the new visit has no UTM
            $data += array_filter(['utm_source' => $utmSource, 'utm_campaign' => $utmCampaign], fn ($value) => $value !== null && $value !== '');
            // a device token for this browser: same kind of answer as for a new email
            $deviceToken = $this->random->getUniqueHash();
            $devices = $this->deviceTokens($row);
            $devices[] = hash('sha256', $deviceToken);
            $data['device_tokens'] = json_encode(array_slice($devices, -self::MAX_DEVICE_TOKENS));
            $this->resource->getConnection()->update($this->table(), $data, ['entity_id = ?' => (int)$row['entity_id']]);
            return ['token' => $deviceToken] + (array)$this->get($email);
        } else {
            $data = $this->favoritesData($skus, null);
            $data += [
                'email' => $email,
                'store_id' => $storeId,
                'token' => $this->random->getUniqueHash(),
                'utm_source' => $utmSource ? mb_substr($utmSource, 0, 255) : null,
                'utm_campaign' => $utmCampaign ? mb_substr($utmCampaign, 0, 255) : null,
            ];
            $this->resource->getConnection()->insert($this->table(), $data);
        }
        return (array)$this->get($email);
    }

    /** @return string|null ROLE_OWNER, ROLE_DEVICE, or null when the token does not belong to the email */
    public function tokenRole(string $email, string $token): ?string
    {
        $row = $token !== '' ? $this->get($email) : null;
        if ($row === null) {
            return null;
        }
        if (hash_equals((string)$row['token'], $token)) {
            return self::ROLE_OWNER;
        }
        $hash = hash('sha256', $token);
        foreach ($this->deviceTokens($row) as $deviceHash) {
            if (hash_equals((string)$deviceHash, $hash)) {
                return self::ROLE_DEVICE;
            }
        }
        return null;
    }

    /**
     * Favourites changed after the subscription: the owner token replaces the list, a device token only adds to it,
     * any other token changes nothing (false).
     *
     * @param string[] $skus
     */
    public function updateFavorites(string $email, string $token, array $skus): bool
    {
        $role = $this->tokenRole($email, $token);
        if ($role === null) {
            return false;
        }
        $row = (array)$this->get($email);
        $this->resource->getConnection()->update(
            $this->table(),
            $this->favoritesData($role === self::ROLE_OWNER ? $skus : $this->merge($row, $skus), (string)$row['email_status']),
            ['entity_id = ?' => (int)$row['entity_id']]
        );
        return true;
    }

    /** @return array<int, array> subscribers waiting for the launch email */
    public function getPending(int $limit): array
    {
        $connection = $this->resource->getConnection();
        return $connection->fetchAll($connection->select()
            ->from($this->table())
            ->where('email_status = ?', self::STATUS_PENDING)
            ->order('entity_id ASC')
            ->limit($limit));
    }

    /** Takes a pending row for sending; false when another run already took it (no double emails). */
    public function claim(int $id): bool
    {
        return (bool)$this->resource->getConnection()->update(
            $this->table(),
            ['email_status' => self::STATUS_SENDING],
            ['entity_id = ?' => $id, 'email_status = ?' => self::STATUS_PENDING]
        );
    }

    public function markSent(int $id): void
    {
        $this->resource->getConnection()->update(
            $this->table(),
            ['email_status' => self::STATUS_SENT, 'email_sent_at' => gmdate('Y-m-d H:i:s'), 'email_error' => null],
            ['entity_id = ?' => $id]
        );
    }

    /** Unsubscribed from the newsletter (any channel): the launch email is not sent. */
    public function skipUnsubscribed(string $email): void
    {
        $this->resource->getConnection()->update(
            $this->table(),
            ['email_status' => self::STATUS_SKIP, 'email_error' => self::REASON_UNSUBSCRIBED],
            [
                'email = ?' => $this->normalize($email),
                'email_status IN (?)' => [self::STATUS_PENDING, self::STATUS_SKIP, self::STATUS_ERROR],
            ]
        );
    }

    /** Subscribed again (homepage, admin, this page): a row skipped at unsubscribe waits for the launch email again. */
    public function restoreSubscribed(string $email): void
    {
        $connection = $this->resource->getConnection();
        $where = [
            'email = ?' => $this->normalize($email),
            'email_status = ?' => self::STATUS_SKIP,
            'email_error = ?' => self::REASON_UNSUBSCRIBED,
        ];
        $connection->update(
            $this->table(),
            ['email_status' => self::STATUS_PENDING, 'email_error' => null],
            $where + [0 => 'favorites_count > 0']
        );
        $connection->update($this->table(), ['email_error' => self::REASON_NO_FAVORITES], $where);
    }

    public function markSkipped(int $id, string $reason): void
    {
        $this->resource->getConnection()->update(
            $this->table(),
            ['email_status' => self::STATUS_SKIP, 'email_error' => mb_substr($reason, 0, 2000)],
            ['entity_id = ?' => $id]
        );
    }

    public function markError(int $id, string $error): void
    {
        $this->resource->getConnection()->update(
            $this->table(),
            ['email_status' => self::STATUS_ERROR, 'email_error' => mb_substr($error, 0, 2000)],
            ['entity_id = ?' => $id]
        );
    }

    /**
     * Rows left in "sending" by an interrupted run become errors and are not sent again automatically:
     * the email may already have left, so a resend could duplicate it.
     */
    public function expireStuck(int $minutes): int
    {
        return (int)$this->resource->getConnection()->update(
            $this->table(),
            ['email_status' => self::STATUS_ERROR, 'email_error' => 'Trimitere intrerupta; verificati daca emailul a plecat inainte de a-l retrimite.'],
            ['email_status = ?' => self::STATUS_SENDING, 'updated_at < ?' => gmdate('Y-m-d H:i:s', time() - $minutes * 60)]
        );
    }

    /** @return string[] SHA-256 of the device tokens of the row */
    private function deviceTokens(array $row): array
    {
        $tokens = json_decode((string)($row['device_tokens'] ?? ''), true);
        return is_array($tokens) ? array_values(array_filter($tokens, 'is_string')) : [];
    }

    /**
     * Saved favourites + new ones (saved first), within the limit.
     *
     * @param string[] $skus
     * @return string[]
     */
    private function merge(array $row, array $skus): array
    {
        $saved = array_filter(explode(',', (string)$row['favorites']), 'strlen');
        return array_slice(array_values(array_unique(array_merge($saved, $skus))), 0, self::MAX_FAVORITES);
    }

    /** @param string[] $skus */
    private function favoritesData(array $skus, ?string $currentStatus): array
    {
        $skus = array_values(array_unique($skus));
        $data = ['favorites' => implode(',', $skus), 'favorites_count' => count($skus)];
        if ($currentStatus === self::STATUS_SENT || $currentStatus === self::STATUS_SENDING) {
            return $data;   // the launch email went (or is going) out: later changes do not send a new one
        }
        return $data + [
            'email_status' => $skus ? self::STATUS_PENDING : self::STATUS_SKIP,
            'email_error' => $skus ? null : self::REASON_NO_FAVORITES,
        ];
    }

    private function normalize(string $email): string
    {
        return mb_strtolower(trim($email));
    }

    private function table(): string
    {
        return $this->resource->getTableName(self::TABLE);
    }
}
