<?php

namespace App\Http\Controllers\Portal;

use App\Http\Controllers\Controller;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\File;

/**
 * The portal's service worker, generated per deploy.
 *
 * It is a route rather than a static file for one reason: the precache list is
 * built at runtime from public/build/manifest.json, and the cache name is a
 * hash of that manifest — so a deploy purges the previous cache instead of
 * leaving a worker serving asset URLs that no longer exist.
 *
 * Everything here is shaped by what a Livewire application actually is:
 *
 *  - It lives at /app/sw.js. A worker's scope is its own directory, and a root
 *    worker would control /dashboard and /api as well — serving admins a stale
 *    shell and leaving cached credentialed responses on a shared tablet.
 *
 *  - HTML is never precached. `wire:navigate` swaps <head> wholesale and
 *    prefetches on hover, so a CacheFirst HTML cache fills with unvisited pages
 *    and then injects @vite hashes from a build that no longer exists — a blank
 *    page with no error anywhere.
 *
 *  - /livewire/* is never cached at all. A Livewire request carries a snapshot
 *    whose checksum is bound to APP_KEY and the session; replaying a cached one
 *    produces a corrupt-snapshot error rather than a stale render.
 */
class ServiceWorkerController extends Controller
{
    public function __invoke(): Response
    {
        $manifestPath = public_path('build/manifest.json');
        $manifest = File::exists($manifestPath)
            ? (json_decode(File::get($manifestPath), true) ?: [])
            : [];

        $assets = [];

        foreach ($manifest as $entry) {
            // Only the portal's own bundle. Precaching the ERP's assets would
            // cost a member several hundred kilobytes they will never use.
            if (! str_contains($entry['src'] ?? '', 'portal.')) {
                continue;
            }

            $assets[] = '/build/' . $entry['file'];

            foreach ($entry['css'] ?? [] as $css) {
                $assets[] = '/build/' . $css;
            }
        }

        $assets[] = '/app/offline';

        $version = substr(hash('sha256', json_encode($manifest) . implode('', $assets)), 0, 12);

        $script = $this->script($version, array_values(array_unique($assets)));

        return response($script, 200, [
            'Content-Type' => 'text/javascript; charset=utf-8',
            // The worker itself is never cached: a cached worker cannot be
            // replaced, and then nothing can be.
            'Cache-Control' => 'no-cache, no-store, must-revalidate',
            'Service-Worker-Allowed' => '/app/',
        ]);
    }

    private function script(string $version, array $assets): string
    {
        $cacheName = "portal-{$version}";
        $precache = json_encode($assets, JSON_UNESCAPED_SLASHES);

        return <<<JS
        /* Generated per deploy. Cache name carries the build hash, so a new
           deploy evicts the old cache instead of layering on top of it. */
        const CACHE = '{$cacheName}';
        const PRECACHE = {$precache};
        const OFFLINE_URL = '/app/offline';

        self.addEventListener('install', (event) => {
            event.waitUntil(
                caches.open(CACHE)
                    .then((cache) => cache.addAll(PRECACHE))
                    .then(() => self.skipWaiting())
            );
        });

        self.addEventListener('activate', (event) => {
            event.waitUntil(
                caches.keys()
                    .then((keys) => Promise.all(
                        keys.filter((key) => key !== CACHE).map((key) => caches.delete(key))
                    ))
                    .then(() => self.clients.claim())
            );
        });

        self.addEventListener('fetch', (event) => {
            const request = event.request;

            /* Bail out first, before anything else runs. Each of these would be
               actively harmful to cache, not merely useless. */
            if (request.method !== 'GET') return;

            const url = new URL(request.url);

            if (url.origin !== self.location.origin) return;
            if (url.pathname.startsWith('/livewire/')) return;   /* snapshot is session-bound */
            if (url.pathname.startsWith('/api/')) return;
            if (url.pathname.startsWith('/storage/')) return;    /* private-ish member media */
            if (url.pathname.startsWith('/proofs/')) return;     /* bank screenshots */
            if (url.pathname === '/app/sw.js') return;
            if (url.pathname === '/app/manifest.webmanifest') return;
            if (!url.pathname.startsWith('/app')) return;        /* the ERP is not ours */

            /* Navigations: always try the network. The only offline artifact is
               a static page with no session and no CSRF token in it. */
            if (request.mode === 'navigate') {
                event.respondWith(
                    fetch(request).catch(() => caches.match(OFFLINE_URL))
                );
                return;
            }

            /* Build assets are content-hashed, so a hit is always correct. */
            if (url.pathname.startsWith('/build/')) {
                event.respondWith(
                    caches.match(request).then((hit) => hit || fetch(request).then((response) => {
                        if (response.ok) {
                            const copy = response.clone();
                            caches.open(CACHE).then((cache) => cache.put(request, copy));
                        }
                        return response;
                    }))
                );
            }
        });

        /* A push arriving with no window open still has to reach the member. */
        self.addEventListener('push', (event) => {
            if (!event.data) return;

            let payload = {};
            try { payload = event.data.json(); } catch (e) { payload = { body: event.data.text() }; }

            const notification = payload.notification || payload;
            const data = payload.data || {};

            event.waitUntil(self.registration.showNotification(
                notification.title || 'إشعار',
                {
                    body: notification.body || '',
                    icon: notification.icon || '/build/icon-192.png',
                    badge: notification.badge,
                    dir: 'rtl',
                    lang: 'ar',
                    tag: data.tag || undefined,
                    data: { url: data.url || '/app' },
                }
            ));
        });

        self.addEventListener('notificationclick', (event) => {
            event.notification.close();

            const target = (event.notification.data && event.notification.data.url) || '/app';

            event.waitUntil(
                self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
                    for (const client of clients) {
                        if (client.url.includes('/app') && 'focus' in client) {
                            client.navigate(target);
                            return client.focus();
                        }
                    }
                    return self.clients.openWindow(target);
                })
            );
        });
        JS;
    }
}
