<?php

declare(strict_types=1);

/**
 * 本文件属于 DBErp 进销存系统。
 *
 * 版权所有 © 2026 北京珑大钜商科技有限公司。保留所有权利。
 *
 * 未经官方授权，不得删除、篡改或隐藏本文件中的版权声明、产品标识与来源信息。
 * 商业授权、官方更新与技术支持服务，请以珑大官方支持中心记录为准。
 *
 * @link https://support.loongdom.cn
 * @see  https://www.dberp.com.cn
 */

namespace App\Services;

use App\Enums\OfficialNewsLevel;
use App\Models\DberpOfficialNewsRead;
use App\Models\User;
use App\Support\DberpSystemProfileSettings;
use App\Support\OfficialNews\OfficialNewsItem;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Throwable;

/**
 * 官方资讯：拉取公开 JSON、校验后缓存；仅将用户已读 ID 写入本地表。
 */
final class DberpOfficialNewsFeedService
{
    public const CACHE_KEY_FEED = 'dberp.official_news.feed';

    public const CACHE_KEY_LAST_GOOD = 'dberp.official_news.last_good';

    /**
     * @var list<OfficialNewsItem>|null
     */
    private ?array $requestFeedItems = null;

    public function isEnabled(): bool
    {
        return (bool) config('dberp.official_news.enabled', true);
    }

    /**
     * @return list<OfficialNewsItem>
     */
    public function feedItems(): array
    {
        if (! $this->isEnabled()) {
            return [];
        }

        return $this->requestFeedItems ??= $this->rememberedFeedItems();
    }

    /**
     * @return list<OfficialNewsItem>
     */
    public function itemsFor(?User $user): array
    {
        $items = $this->feedItems();

        if ($items === [] || $user === null) {
            return $items;
        }

        $readIds = $this->readNewsIdsFor($user, array_map(
            static fn (OfficialNewsItem $item): int => $item->id,
            $items,
        ));

        return array_map(
            static fn (OfficialNewsItem $item): OfficialNewsItem => $item->withRead(isset($readIds[$item->id])),
            $items,
        );
    }

    public function unreadCountFor(?User $user): int
    {
        if ($user === null) {
            return 0;
        }

        $count = 0;

        foreach ($this->itemsFor($user) as $item) {
            if (! $item->isRead) {
                $count++;
            }
        }

        return $count;
    }

    public function unreadCriticalItemFor(?User $user): ?OfficialNewsItem
    {
        if ($user === null) {
            return null;
        }

        foreach ($this->itemsFor($user) as $item) {
            if ($item->level === OfficialNewsLevel::Critical && ! $item->isRead) {
                return $item;
            }
        }

        return null;
    }

    /**
     * @return list<array{
     *     id: int,
     *     title: string,
     *     url: string,
     *     level: string,
     *     level_label: string,
     *     level_color: string,
     *     pinned: bool,
     *     is_read: bool,
     *     published_at_label: ?string,
     * }>
     */
    public function panelItemsFor(?User $user): array
    {
        $rows = [];

        foreach ($this->itemsFor($user) as $item) {
            $rows[] = [
                'id' => $item->id,
                'title' => $item->title,
                'url' => $item->url,
                'level' => $item->level->value,
                'level_label' => $item->level->label(),
                'level_color' => $item->level->color(),
                'pinned' => $item->pinned,
                'is_read' => $item->isRead,
                'published_at_label' => $this->publishedAtLabel($item),
            ];
        }

        return $rows;
    }

    public function dropdownLimit(): int
    {
        return max(1, (int) config('dberp.official_news.dropdown_limit', 8));
    }

    public function slideOverPerPage(): int
    {
        return max(1, (int) config('dberp.official_news.slide_over_per_page', 10));
    }

    public function markAsRead(User $user, int $newsId): bool
    {
        if (! $this->readsTableExists() || $newsId < 1) {
            return false;
        }

        if (! $this->feedContainsNewsId($newsId)) {
            return false;
        }

        DberpOfficialNewsRead::query()->updateOrCreate(
            [
                'user_id' => (int) $user->getKey(),
                'news_id' => $newsId,
            ],
            [
                'read_at' => now()->utc(),
            ],
        );

        return true;
    }

    public function markAllAsRead(User $user): int
    {
        if (! $this->readsTableExists()) {
            return 0;
        }

        $ids = [];

        foreach ($this->itemsFor($user) as $item) {
            if (! $item->isRead) {
                $ids[] = $item->id;
            }
        }

        if ($ids === []) {
            return 0;
        }

        $now = now()->utc();
        $userId = (int) $user->getKey();
        $rows = [];

        foreach ($ids as $newsId) {
            $rows[] = [
                'user_id' => $userId,
                'news_id' => $newsId,
                'read_at' => $now,
                'created_at' => $now,
                'updated_at' => $now,
            ];
        }

        DberpOfficialNewsRead::query()->upsert(
            $rows,
            ['user_id', 'news_id'],
            ['read_at', 'updated_at'],
        );

        return count($ids);
    }

    public function readsTableExists(): bool
    {
        return Schema::hasTable('dberp_official_news_reads');
    }

    /**
     * @return list<OfficialNewsItem>
     */
    private function rememberedFeedItems(): array
    {
        $cached = Cache::remember(
            self::CACHE_KEY_FEED,
            $this->cacheTtl(),
            fn (): array => $this->fetchNormalizedPayload(),
        );

        return $this->hydrateItems(is_array($cached) ? $cached : []);
    }

    /**
     * @return list<array{
     *     id: int,
     *     title: string,
     *     url: string,
     *     level: string,
     *     pinned: bool,
     *     publish_at: ?string,
     *     offline_at: ?string,
     * }>
     */
    private function fetchNormalizedPayload(): array
    {
        $parsed = $this->requestAndParseFeed();

        if ($parsed !== null) {
            Cache::put(self::CACHE_KEY_LAST_GOOD, $parsed, $this->lastGoodTtl());

            return $parsed;
        }

        $lastGood = Cache::get(self::CACHE_KEY_LAST_GOOD);

        return is_array($lastGood) ? $lastGood : [];
    }

    /**
     * @return list<array{
     *     id: int,
     *     title: string,
     *     url: string,
     *     level: string,
     *     pinned: bool,
     *     publish_at: ?string,
     *     offline_at: ?string,
     * }>|null
     */
    private function requestAndParseFeed(): ?array
    {
        $url = trim((string) config('dberp.official_news.url', ''));

        if ($url === '' || filter_var($url, FILTER_VALIDATE_URL) === false) {
            return null;
        }

        try {
            $response = Http::timeout($this->timeout())
                ->connectTimeout($this->connectTimeout())
                ->acceptJson()
                ->get($url);
        } catch (Throwable) {
            Log::warning('dberp.official_news.fetch_failed', [
                'reason' => 'connection',
            ]);

            return null;
        }

        if (! $response->successful()) {
            Log::warning('dberp.official_news.fetch_failed', [
                'reason' => 'http_status',
                'status' => $response->status(),
            ]);

            return null;
        }

        $payload = $response->json();

        if (! is_array($payload)) {
            Log::warning('dberp.official_news.fetch_failed', [
                'reason' => 'invalid_json',
            ]);

            return null;
        }

        $normalized = $this->normalizePayload($payload);

        if ($normalized === null) {
            Log::warning('dberp.official_news.fetch_failed', [
                'reason' => 'invalid_payload',
            ]);
        }

        return $normalized;
    }

    /**
     * @param  array<array-key, mixed>  $payload
     * @return list<array{
     *     id: int,
     *     title: string,
     *     url: string,
     *     level: string,
     *     pinned: bool,
     *     publish_at: ?string,
     *     offline_at: ?string,
     * }>|null
     */
    private function normalizePayload(array $payload): ?array
    {
        $schemaVersion = $payload['schema_version'] ?? null;

        if (! is_numeric($schemaVersion) || (int) $schemaVersion < 1) {
            return null;
        }

        $platform = $payload['platform'] ?? null;
        $expectedPlatform = trim((string) config('dberp.official_news.expected_platform', 'dberp'));

        if (! is_string($platform) || $platform !== $expectedPlatform) {
            return null;
        }

        $rawItems = $payload['items'] ?? null;

        if (! is_array($rawItems)) {
            return null;
        }

        $items = [];

        foreach ($rawItems as $rawItem) {
            if (! is_array($rawItem)) {
                continue;
            }

            $normalized = $this->normalizeItem($rawItem);

            if ($normalized === null) {
                continue;
            }

            $items[] = $normalized;
        }

        return $items;
    }

    /**
     * @param  array<array-key, mixed>  $rawItem
     * @return array{
     *     id: int,
     *     title: string,
     *     url: string,
     *     level: string,
     *     pinned: bool,
     *     publish_at: ?string,
     *     offline_at: ?string,
     * }|null
     */
    private function normalizeItem(array $rawItem): ?array
    {
        $id = $rawItem['id'] ?? null;

        if (! is_numeric($id) || (int) $id < 1 || (string) (int) $id !== (string) $id) {
            return null;
        }

        $title = $this->nullableTrimmedString($rawItem['title'] ?? null);

        if ($title === null) {
            return null;
        }

        $url = $this->httpsUrl($rawItem['url'] ?? null);

        if ($url === null) {
            return null;
        }

        $level = OfficialNewsLevel::tryFromValue($rawItem['level'] ?? null) ?? OfficialNewsLevel::Info;
        $pinned = ($rawItem['pinned'] ?? false) === true;
        $publishAt = $this->parseOptionalTime($rawItem['publish_at'] ?? null);
        $offlineAt = $this->parseOptionalTime($rawItem['offline_at'] ?? null);

        if ($publishAt !== null && $publishAt->isFuture()) {
            return null;
        }

        if ($offlineAt !== null && $offlineAt->isPast()) {
            return null;
        }

        return [
            'id' => (int) $id,
            'title' => $title,
            'url' => $url,
            'level' => $level->value,
            'pinned' => $pinned,
            'publish_at' => $publishAt?->toIso8601String(),
            'offline_at' => $offlineAt?->toIso8601String(),
        ];
    }

    /**
     * @param  list<mixed>  $payload
     * @return list<OfficialNewsItem>
     */
    private function hydrateItems(array $payload): array
    {
        $items = [];

        foreach ($payload as $row) {
            if (! is_array($row)) {
                continue;
            }

            $item = $this->hydrateItem($row);

            if ($item === null) {
                continue;
            }

            $items[] = $item;
        }

        return $items;
    }

    /**
     * @param  array<array-key, mixed>  $row
     */
    private function hydrateItem(array $row): ?OfficialNewsItem
    {
        $id = $row['id'] ?? null;
        $title = $this->nullableTrimmedString($row['title'] ?? null);
        $url = $this->httpsUrl($row['url'] ?? null);
        $level = OfficialNewsLevel::tryFromValue($row['level'] ?? null) ?? OfficialNewsLevel::Info;

        if (! is_int($id) || $id < 1 || $title === null || $url === null) {
            return null;
        }

        return new OfficialNewsItem(
            id: $id,
            title: $title,
            url: $url,
            level: $level,
            pinned: ($row['pinned'] ?? false) === true,
            publishAt: $this->parseOptionalTime($row['publish_at'] ?? null),
            offlineAt: $this->parseOptionalTime($row['offline_at'] ?? null),
        );
    }

    /**
     * @param  list<int>  $newsIds
     * @return array<int, true>
     */
    private function readNewsIdsFor(User $user, array $newsIds): array
    {
        if ($newsIds === [] || ! $this->readsTableExists()) {
            return [];
        }

        $ids = DberpOfficialNewsRead::query()
            ->where('user_id', (int) $user->getKey())
            ->whereIn('news_id', $newsIds)
            ->pluck('news_id')
            ->all();

        $map = [];

        foreach ($ids as $id) {
            $map[(int) $id] = true;
        }

        return $map;
    }

    private function feedContainsNewsId(int $newsId): bool
    {
        foreach ($this->feedItems() as $item) {
            if ($item->id === $newsId) {
                return true;
            }
        }

        return false;
    }

    private function publishedAtLabel(OfficialNewsItem $item): ?string
    {
        if ($item->publishAt === null) {
            return null;
        }

        return $item->publishAt
            ->clone()
            ->timezone(DberpSystemProfileSettings::displayTimezone())
            ->format('Y-m-d H:i');
    }

    private function httpsUrl(mixed $value): ?string
    {
        $url = $this->nullableTrimmedString(is_string($value) ? $value : null);

        if ($url === null) {
            return null;
        }

        $parts = parse_url($url);

        if (! is_array($parts)) {
            return null;
        }

        $scheme = strtolower((string) ($parts['scheme'] ?? ''));
        $host = (string) ($parts['host'] ?? '');

        if ($scheme !== 'https' || $host === '') {
            return null;
        }

        return $url;
    }

    private function parseOptionalTime(mixed $value): ?Carbon
    {
        if ($value === null) {
            return null;
        }

        if (! is_string($value) || trim($value) === '') {
            return null;
        }

        try {
            return Carbon::parse($value);
        } catch (Throwable) {
            return null;
        }
    }

    private function nullableTrimmedString(mixed $value): ?string
    {
        if (! is_string($value)) {
            return null;
        }

        $trimmed = trim($value);

        return $trimmed === '' ? null : $trimmed;
    }

    private function cacheTtl(): int
    {
        return max(60, (int) config('dberp.official_news.cache_ttl', 300));
    }

    private function lastGoodTtl(): int
    {
        return max(300, (int) config('dberp.official_news.last_good_ttl', 86400));
    }

    private function timeout(): int
    {
        return max(1, (int) config('dberp.official_news.timeout', 5));
    }

    private function connectTimeout(): int
    {
        return max(1, (int) config('dberp.official_news.connect_timeout', 3));
    }
}
