<?php

declare(strict_types=1);

namespace Grav\Plugin\Api\Controllers;

use Grav\Common\GPM\GPM;
use Grav\Common\HTTP\Response;
use Grav\Common\User\DataUser\User as DataUser;
use Grav\Plugin\Api\FlexBackend;
use Grav\Plugin\Api\Response\ApiResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use RocketTheme\Toolbox\Event\Event;
use RocketTheme\Toolbox\File\YamlFile;

class DashboardController extends AbstractApiController
{
    use FlexBackend;

    /**
     * GET /dashboard/notifications - Get system notifications.
     */
    public function notifications(ServerRequestInterface $request): ResponseInterface
    {
        $this->requirePermission($request, 'api.system.read');

        $query = $request->getQueryParams();
        $force = filter_var($query['force'] ?? false, FILTER_VALIDATE_BOOLEAN);

        $user = $this->getUser($request);
        $username = $user->get('username');

        // Load cached notifications (v2 schema — see notifications2.md on getgrav.org)
        $cacheFile = $this->grav['locator']->findResource(
            'user://data/notifications/' . md5($username) . '_v2.yaml',
            true,
            true
        );
        $userStatusFile = $this->grav['locator']->findResource(
            'user://data/notifications/' . $username . '.yaml',
            true,
            true
        );

        $notificationsFile = YamlFile::instance($cacheFile);
        $notificationsContent = (array) $notificationsFile->content();
        $userStatusContent = file_exists($userStatusFile)
            ? (array) YamlFile::instance($userStatusFile)->content()
            : [];

        $lastChecked = $notificationsContent['last_checked'] ?? null;
        $notifications = $notificationsContent['data'] ?? [];
        $timeout = $this->grav['config']->get('system.session.timeout', 1800);

        // Refresh from remote if needed
        if ($force || !$lastChecked || empty($notifications) || (time() - $lastChecked > $timeout)) {
            try {
                $body = Response::get('https://getgrav.org/notifications2.json?' . time());
                $rawNotifications = json_decode($body, true);

                if (is_array($rawNotifications)) {
                    // Sort by date descending
                    usort($rawNotifications, fn($a, $b) => strcmp($b['date'] ?? '', $a['date'] ?? ''));

                    // Group by location
                    $notifications = [];
                    foreach ($rawNotifications as $notification) {
                        foreach ($notification['location'] ?? [] as $location) {
                            $notifications[$location][] = $notification;
                        }
                    }

                    $notificationsFile->content(['last_checked' => time(), 'data' => $notifications]);
                    $notificationsFile->save();
                }
            } catch (\Exception $e) {
                // Use cached data on failure
            }
        }

        // Let plugins contribute notifications (grouped by location: `top`,
        // `dashboard`, `feed`). Fired after the remote refresh so plugin notices
        // are merged fresh every request (never cached) yet still flow through
        // the dismiss + reappear_after handling below — a plugin-provided `id`
        // is dismissed via the same /notifications/{id}/hide endpoint. This is
        // how a plugin can raise a persistent, dismissible admin banner.
        $event = new Event([
            'notifications' => $notifications,
            'user' => $user,
            'force' => $force,
        ]);
        $this->grav->fireEvent('onApiDashboardNotifications', $event);
        $contributed = $event['notifications'];
        if (is_array($contributed)) {
            $notifications = $contributed;
        }

        // Filter out hidden notifications
        foreach ($notifications as $location => &$list) {
            $list = array_values(array_filter($list, function ($notification) use ($userStatusContent) {
                $hidden = $userStatusContent[$notification['id']] ?? null;
                if ($hidden === null) {
                    return true;
                }

                // Check reappear_after
                if (isset($notification['reappear_after'])) {
                    $now = new \DateTime();
                    $hiddenOn = new \DateTime($hidden);
                    $hiddenOn->modify($notification['reappear_after']);
                    return $now >= $hiddenOn;
                }

                return false;
            }));
        }
        unset($list);

        // Filter by location if requested
        $filter = $query['location'] ?? null;
        if ($filter) {
            $notifications = [$filter => $notifications[$filter] ?? []];
        }

        return ApiResponse::create([
            'notifications' => $notifications,
            'last_checked' => $lastChecked ? date('c', $lastChecked) : null,
        ]);
    }

    /**
     * POST /dashboard/notifications/{id}/hide - Dismiss a notification.
     */
    public function hideNotification(ServerRequestInterface $request): ResponseInterface
    {
        $this->requirePermission($request, 'api.system.write');

        $id = $this->getRouteParam($request, 'id');
        $user = $this->getUser($request);
        $username = $user->get('username');

        $userStatusFile = $this->grav['locator']->findResource(
            'user://data/notifications/' . $username . '.yaml',
            true,
            true
        );

        $file = YamlFile::instance($userStatusFile);
        $content = (array) $file->content();
        $content[$id] = date('Y-m-d H:i:s');
        $file->content($content);
        $file->save();

        return ApiResponse::noContent();
    }

    /**
     * GET /dashboard/feed - Get getgrav.org news feed as JSON.
     */
    public function feed(ServerRequestInterface $request): ResponseInterface
    {
        $this->requirePermission($request, 'api.system.read');

        $query = $request->getQueryParams();
        $force = filter_var($query['force'] ?? false, FILTER_VALIDATE_BOOLEAN);

        $user = $this->getUser($request);
        $username = $user->get('username');

        $cacheFile = $this->grav['locator']->findResource(
            'user://data/feed/' . md5($username) . '.yaml',
            true,
            true
        );

        $feedFile = YamlFile::instance($cacheFile);
        $feedContent = (array) $feedFile->content();

        $lastChecked = $feedContent['last_checked'] ?? null;
        $feed = $feedContent['data'] ?? [];
        $timeout = $this->grav['config']->get('system.session.timeout', 1800);

        // Refresh from remote if needed
        if ($force || !$lastChecked || empty($feed) || (time() - $lastChecked > $timeout)) {
            try {
                $body = Response::get('https://getgrav.org/blog.atom');
                $xml = simplexml_load_string($body);

                if ($xml) {
                    $feed = [];
                    $count = 0;
                    foreach ($xml->entry as $entry) {
                        if ($count >= 10) break;

                        $feed[] = [
                            'title' => (string) $entry->title,
                            'url' => (string) $entry->link['href'],
                            'date' => (string) $entry->updated,
                            'summary' => (string) ($entry->summary ?? ''),
                        ];
                        $count++;
                    }

                    $feedFile->content(['last_checked' => time(), 'data' => $feed]);
                    $feedFile->save();
                }
            } catch (\Exception $e) {
                // Use cached data on failure
            }
        }

        return ApiResponse::create([
            'feed' => $feed,
            'last_checked' => $lastChecked ? date('c', $lastChecked) : null,
        ]);
    }

    /**
     * GET /dashboard/stats - Dashboard statistics snapshot.
     */
    public function stats(ServerRequestInterface $request): ResponseInterface
    {
        $this->requirePermission($request, 'api.system.read');

        // Count pages
        $pages = $this->grav['pages'];
        $pages->enablePages();
        $allPages = $pages->instances();
        $totalPages = 0;
        $publishedPages = 0;

        foreach ($allPages as $page) {
            // Skip the virtual pages-root container (no file on disk); the
            // home page IS a real file-backed page with route '/'.
            if (!$page->route() || !$page->exists()) {
                continue;
            }
            $totalPages++;
            if ($page->published()) {
                $publishedPages++;
            }
        }

        // Count users via the same Flex-aware path UsersController uses, so the
        // tally matches the Users listing regardless of account storage layout.
        $totalUsers = $this->countUsers();

        // Count plugins
        $plugins = $this->grav['plugins']->all();
        $activePlugins = 0;
        foreach ($plugins as $name => $plugin) {
            if ($this->grav['config']->get("plugins.{$name}.enabled", false)) {
                $activePlugins++;
            }
        }

        // Count themes
        $themes = $this->grav['themes']->all();
        $totalThemes = is_countable($themes) ? count($themes) : 0;

        // Active theme
        $activeTheme = $this->grav['config']->get('system.pages.theme');

        // Available-update counts for the sidebar badges. Read from Grav's cached
        // GPM data (new GPM(false)) so this stays a fast, network-free lookup — an
        // empty/never-checked cache simply reports zero updates. Wrapped so a GPM
        // hiccup can never take down the dashboard.
        $pluginUpdates = 0;
        $themeUpdates = 0;
        $gravUpdatable = false;
        $activeThemeUpdatable = false;
        try {
            $counts = self::extractUpdateCounts(new GPM(false), is_string($activeTheme) ? $activeTheme : null);
            $pluginUpdates = $counts['plugins'];
            $themeUpdates = $counts['themes'];
            $gravUpdatable = $counts['grav'];
            $activeThemeUpdatable = $counts['active_theme'];
        } catch (\Throwable $e) {
            $this->grav['log']->warning('[api] Dashboard stats could not read GPM update counts: ' . $e->getMessage());
        }

        // Count media files. The recursive walk is O(total files) and runs on a
        // dashboard endpoint, so the tally is cached for a few minutes — a
        // slightly stale count is invisible on a dashboard card, while walking
        // a 10k-file library per visit is not.
        $mediaDir = $this->grav['locator']->findResource('user://media', true)
            ?: $this->grav['locator']->findResource('user://images', true);
        $totalMedia = 0;
        if ($mediaDir && is_dir($mediaDir)) {
            $cache = $this->grav['cache'];
            $cacheKey = 'api-dashboard-media-count-' . md5($mediaDir);
            $cached = $cache->fetch($cacheKey);
            if (is_int($cached)) {
                $totalMedia = $cached;
            } else {
                $iterator = new \RecursiveIteratorIterator(
                    new \RecursiveDirectoryIterator($mediaDir, \FilesystemIterator::SKIP_DOTS)
                );
                foreach ($iterator as $file) {
                    if (!$file->isFile()) {
                        continue;
                    }
                    // Skip sidecars, not real media: `.meta.yaml` metadata files, the
                    // per-folder `media_order.yaml`, and hidden dotfiles — the same
                    // files the media listing excludes.
                    $name = $file->getFilename();
                    if (
                        str_starts_with($name, '.')
                        || str_ends_with($name, '.meta.yaml')
                        || $name === 'media_order.yaml'
                    ) {
                        continue;
                    }
                    $totalMedia++;
                }
                $cache->save($cacheKey, $totalMedia, 300);
            }
        }

        // Last backup
        $backupsDir = $this->grav['locator']->findResource('backup://', true);
        $lastBackup = null;
        if ($backupsDir && is_dir($backupsDir)) {
            $backups = glob($backupsDir . '/*.zip');
            if (!empty($backups)) {
                $latest = max(array_map('filemtime', $backups));
                $lastBackup = date('c', $latest);
            }
        }

        $data = [
            'pages' => [
                'total' => $totalPages,
                'published' => $publishedPages,
            ],
            'users' => [
                'total' => $totalUsers,
            ],
            'plugins' => [
                'total' => count($plugins),
                'active' => $activePlugins,
                'updatable' => $pluginUpdates,
            ],
            'themes' => [
                'total' => $totalThemes,
                'updatable' => $themeUpdates,
                'active_updatable' => $activeThemeUpdatable,
            ],
            'grav' => [
                'updatable' => $gravUpdatable,
            ],
            'media' => [
                'total' => $totalMedia,
            ],
            'theme' => $activeTheme,
            'grav_version' => GRAV_VERSION,
            'php_version' => PHP_VERSION,
            'last_backup' => $lastBackup,
        ];

        return ApiResponse::create($data);
    }

    /**
     * Reduce a GPM instance to the available-update counts the dashboard needs:
     * number of updatable plugins, number of updatable themes, whether Grav core
     * itself has an update, and whether the currently active theme specifically
     * has one (so the "Active Theme" card only flags an update for the theme it
     * actually names). Pure and side-effect free so it can be unit-tested against
     * a mocked GPM without booting Grav.
     *
     * @param string|null $activeThemeSlug Slug of the active theme, or null to skip the active-theme check
     * @return array{plugins: int, themes: int, grav: bool, active_theme: bool}
     */
    public static function extractUpdateCounts(GPM $gpm, ?string $activeThemeSlug = null): array
    {
        $updatable = $gpm->getUpdatable();
        $gravInfo = $gpm->getGrav();

        $updatableThemes = is_array($updatable['themes'] ?? null) ? $updatable['themes'] : [];

        return [
            'plugins' => is_countable($updatable['plugins'] ?? null) ? count($updatable['plugins']) : 0,
            'themes' => count($updatableThemes),
            'grav' => $gravInfo ? $gravInfo->isUpdatable() : false,
            'active_theme' => $activeThemeSlug !== null && isset($updatableThemes[$activeThemeSlug]),
        ];
    }

    /**
     * GET /dashboard/security/exposure-probe
     *
     * Returns the public URL of a sentinel file under user/data plus the
     * random token it contains. The dashboard fetches that URL directly from
     * the browser: a 200 whose body matches the token means the sensitive
     * user/ folders are reachable over the web (a misconfigured webserver),
     * while a 403/404 means they are correctly blocked.
     *
     * The sentinel uses a `.dat` extension on purpose — that extension is not
     * in the legacy per-extension blocklist, so it is only refused when the
     * folder-wide block (Grav 2.0 / 1.7.53+) is actually in place. A plain
     * `.txt`/`.yaml` probe would read as "safe" on installs that still expose
     * certificates, keys and databases stored with other extensions.
     */
    public function securityProbe(ServerRequestInterface $request): ResponseInterface
    {
        $this->requirePermission($request, 'api.system.read');

        $dataDir = $this->grav['locator']->findResource('user://data', true, true);
        $available = false;
        $token = '';

        if ($dataDir) {
            if (!is_dir($dataDir)) {
                @mkdir($dataDir, 0770, true);
            }
            $probeFile = $dataDir . '/grav-security-probe.dat';

            // Reuse a stable token so concurrent dashboards don't race each
            // other into writing different tokens.
            if (is_file($probeFile)) {
                $existing = trim((string) @file_get_contents($probeFile));
                if (preg_match('/^[a-f0-9]{32,}$/', $existing)) {
                    $token = $existing;
                }
            }
            if ($token === '') {
                $token = bin2hex(random_bytes(16));
                @file_put_contents($probeFile, $token);
            }
            $available = is_file($probeFile);
        }

        // Public URL to the sentinel, relative to the site web root (honours a
        // custom GRAV_USER_PATH and a subfolder install).
        $userPath = defined('GRAV_USER_PATH') ? trim(GRAV_USER_PATH, '/') : 'user';
        $rootUrl = rtrim($this->grav['uri']->rootUrl(true), '/');
        $url = $rootUrl . '/' . $userPath . '/data/grav-security-probe.dat';

        return ApiResponse::create([
            'url' => $url,
            'token' => $token,
            'available' => $available,
        ]);
    }

    /**
     * GET /dashboard/popularity - Page view statistics.
     *
     * Reads from PopularityStore (single-file flat JSON, ISO date keys).
     * On first read after an upgrade from admin-classic, the store imports
     * the legacy four-JSON-file layout transparently.
     */
    public function popularity(ServerRequestInterface $request): ResponseInterface
    {
        $this->requirePermission($request, 'api.system.read');

        $store = new \Grav\Plugin\Api\Popularity\PopularityStore();
        $daily = $store->getDaily(365);
        $monthly = $store->getMonthly(24);

        $todayKey = date('Y-m-d');
        $thisMonthKey = date('Y-m');

        $todayViews = (int) ($daily[$todayKey] ?? 0);

        // Sum last 7 days from ISO-keyed daily map
        $weekViews = 0;
        for ($i = 0; $i < 7; $i++) {
            $day = date('Y-m-d', strtotime("-{$i} days"));
            $weekViews += (int) ($daily[$day] ?? 0);
        }

        $monthViews = (int) ($monthly[$thisMonthKey] ?? 0);

        // 14-day chart, oldest first
        $chartData = [];
        for ($i = 13; $i >= 0; $i--) {
            $day = date('Y-m-d', strtotime("-{$i} days"));
            $chartData[] = [
                'date' => date('M j', strtotime("-{$i} days")),
                'views' => (int) ($daily[$day] ?? 0),
            ];
        }

        $topPages = [];
        foreach ($store->getTopPages(10) as $route => $views) {
            $topPages[] = ['route' => $route, 'views' => (int) $views];
        }

        return ApiResponse::create([
            'summary' => [
                'today' => $todayViews,
                'week' => $weekViews,
                'month' => $monthViews,
            ],
            'chart' => $chartData,
            'top_pages' => $topPages,
        ]);
    }

    /**
     * Count user accounts using the same backend resolution as UsersController.
     *
     * When the Flex accounts backend is active (the default, and required for
     * custom/nested storage where files live at user/accounts/<name>/user.yaml)
     * we count via the directory index. Otherwise we fall back to scanning the
     * flat top-level account YAML files.
     */
    private function countUsers(): int
    {
        $directory = $this->getFlexDirectory('user-accounts');
        if ($directory) {
            // Mirror UsersController::indexViaFlex — Flex indexes every file in
            // user/accounts/ without an extension filter, so drop keys that
            // aren't valid usernames or that carry a stored-file extension
            // (revisions-pro/backup strays) before counting.
            $keys = array_filter(
                $directory->getIndex()->getKeys(),
                static fn($k) => is_string($k)
                    && DataUser::isValidUsername($k)
                    && !preg_match('/\.(ya?ml|json)(\.|$)/i', $k),
            );

            return count($keys);
        }

        // Flat-file fallback: top-level account YAML files.
        $accountDir = $this->grav['locator']->findResource('account://', true)
            ?: $this->grav['locator']->findResource('user://accounts', true);
        if ($accountDir && is_dir($accountDir)) {
            return count(glob($accountDir . '/*.yaml') ?: []);
        }

        return 0;
    }
}
