// NOTE: This service worker is for the web/PWA build.
// For development we aggressively unregister SWs in `src/player.js`.
//
// This checked-in file stays stable. The build stamps the token below into
// `dist/sw.js` so deployed clients still refresh to the newest shell.
const CACHE_NAME = 'radioplayer-pwa-v5-__APP_BUILD_ID__';
const STATION_SYNC_CACHE_NAME = 'radioplayer-station-sync-v1-__APP_BUILD_ID__';
const MANAGED_CATALOG_CACHE_NAME = 'radioplayer-managed-catalog-v1';
const RADIO_BROWSER_API_ENDPOINT = 'https://de1.api.radio-browser.info/json/stations/search';
const STATION_SYNC_TAG = '__SW_STATION_SYNC_TAG__';
const STATION_PERIODIC_SYNC_TAG = '__SW_STATION_PERIODIC_SYNC_TAG__';
const MANAGED_CATALOG_PERIODIC_SYNC_TAG = '__SW_MANAGED_CATALOG_PERIODIC_SYNC_TAG__';
const STATION_SYNC_HEADERS = {
  'content-type': 'application/json; charset=utf-8',
};
const MANAGED_CATALOG_SOURCE_HEADER = 'x-radioplayer-managed-source';

const RADIO_COUNTRIES = __RADIO_COUNTRIES_JSON__;
const DEFAULT_SYNC_COUNTRY_CODES = RADIO_COUNTRIES.map((country) => country.code);
const MANAGED_COUNTRY_CODE = '__MANAGED_COUNTRY_CODE__';
const MAX_TAGS = 12;
const OBVIOUSLY_UNSUPPORTED_CODECS = new Set([
  'wma',
  'wmav2',
  'asf',
  'ra',
  'rm',
  'ape',
  'alac',
  'amr',
]);

const CORE_ASSETS = [
  './',
  'index.html',
  'privacy.html',
  'data/radio-stations.json',
  'data/radio-stations-sync.json',
  'stations.json',
  'manifest.json',
  'images/radio-placeholder.svg',
  'assets/radioplayer-logo-192.png',
  'assets/radioplayer-logo-512.png',
];

const CORE_PATHS = new Set(CORE_ASSETS.map((p) => new URL(p, self.registration.scope).pathname));
const DATA_PATHS = new Set([
  new URL('data/radio-stations.json', self.registration.scope).pathname,
  new URL('data/radio-stations-sync.json', self.registration.scope).pathname,
  new URL('stations.json', self.registration.scope).pathname,
  new URL('manifest.json', self.registration.scope).pathname,
]);
const IMAGE_FALLBACK_PATH = new URL('images/radio-placeholder.svg', self.registration.scope).pathname;
const SYNC_CATALOG_URL = new URL('data/radio-stations-sync.json', self.registration.scope).href;
const SYNC_CATALOG_PATH = new URL('data/radio-stations-sync.json', self.registration.scope).pathname;
const SYNC_META_URL = new URL('__SW_STATION_SYNC_META_PATH__', self.registration.scope).href;
const SYNC_SETTINGS_URL = new URL('data/radio-stations-sync-settings.json', self.registration.scope).href;
const SYNC_COUNTRY_PREFIX_PATH = new URL('data/countries/', self.registration.scope).pathname;
const BUNDLED_MANAGED_CATALOG_URL = new URL('stations.json', self.registration.scope).href;
const MANAGED_CATALOG_PATH = new URL('api/managed-stations.json', self.registration.scope).pathname;

function trimToNull(value) {
  if (typeof value !== 'string') return null;
  const trimmed = value.trim();
  return trimmed.length > 0 ? trimmed : null;
}

function toNumber(value) {
  const parsed = typeof value === 'number' ? value : Number(value ?? 0);
  return Number.isFinite(parsed) ? parsed : 0;
}

function toNullableNumber(value) {
  const parsed = typeof value === 'number' ? value : Number(value ?? NaN);
  return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
}

function parseTags(tags) {
  if (typeof tags !== 'string' || tags.trim().length === 0) return [];

  const seen = new Set();
  const parsed = [];

  for (const rawTag of tags.split(',')) {
    const tag = rawTag.trim();
    if (!tag) continue;
    const normalizedTag = tag.toLowerCase();
    if (seen.has(normalizedTag)) continue;
    seen.add(normalizedTag);
    parsed.push(tag);
    if (parsed.length >= MAX_TAGS) break;
  }

  return parsed;
}

function isHttpsUrl(value) {
  if (!value) return false;

  try {
    const url = new URL(value);
    return url.protocol === 'https:';
  } catch {
    return false;
  }
}

function normalizeRadioBrowserStation(station, countryName) {
  const stationUuid = trimToNull(station?.stationuuid);
  if (!stationUuid) return null;

  const name = trimToNull(station?.name);
  if (!name) return null;

  const streamUrl = trimToNull(station?.url_resolved) ?? trimToNull(station?.url);
  if (!isHttpsUrl(streamUrl)) return null;

  const codec = trimToNull(station?.codec);
  if (codec && OBVIOUSLY_UNSUPPORTED_CODECS.has(codec.toLowerCase())) {
    return null;
  }

  return {
    id: stationUuid,
    name,
    country: trimToNull(station?.country) ?? countryName,
    countryCode: trimToNull(station?.countrycode) ?? '',
    language: trimToNull(station?.language),
    tags: parseTags(station?.tags),
    codec,
    bitrate: toNullableNumber(station?.bitrate),
    streamUrl,
    homepage: trimToNull(station?.homepage),
    logoUrl: trimToNull(station?.favicon),
    votes: toNumber(station?.votes),
    clickcount: toNumber(station?.clickcount),
    source: 'radio-browser',
    sourceStationUuid: stationUuid,
  };
}

function getCountryCatalogUrl(countryCode) {
  return new URL(`data/countries/${String(countryCode || '').toLowerCase()}.json`, self.registration.scope).href;
}

async function writeJsonToCache(cache, requestUrl, payload) {
  await cache.put(requestUrl, new Response(JSON.stringify(payload), {
    headers: STATION_SYNC_HEADERS,
  }));
}

function sanitizeSelectedCountryCodes(countryCodes) {
  const uniqueCodes = Array.isArray(countryCodes)
    ? Array.from(new Set(
        countryCodes
          .map((entry) => (typeof entry === 'string' ? entry.trim().toUpperCase() : ''))
          .filter((entry) => /^[A-Z]{2}$/.test(entry)),
      ))
    : [];

  if (!uniqueCodes.includes(MANAGED_COUNTRY_CODE)) {
    uniqueCodes.unshift(MANAGED_COUNTRY_CODE);
  }

  return uniqueCodes.length > 0 ? uniqueCodes : [...DEFAULT_SYNC_COUNTRY_CODES];
}

async function readSyncSettings(cache) {
  const cached = await cache.match(SYNC_SETTINGS_URL);
  if (!cached) {
    return { selectedCountryCodes: [...DEFAULT_SYNC_COUNTRY_CODES] };
  }

  try {
    const payload = await cached.json();
    return {
      selectedCountryCodes: sanitizeSelectedCountryCodes(payload?.selectedCountryCodes),
    };
  } catch {
    return { selectedCountryCodes: [...DEFAULT_SYNC_COUNTRY_CODES] };
  }
}

async function writeSyncSettings(cache, countryCodes) {
  const payload = {
    selectedCountryCodes: sanitizeSelectedCountryCodes(countryCodes),
    updatedAt: new Date().toISOString(),
  };
  await writeJsonToCache(cache, SYNC_SETTINGS_URL, payload);
  return payload;
}

function getSyncCountries(selectedCountryCodes) {
  return sanitizeSelectedCountryCodes(selectedCountryCodes).map((code) => {
    const existing = RADIO_COUNTRIES.find((country) => country.code === code);
    return existing || { name: code, code };
  });
}

async function fetchCountryStations(country) {
  const url = new URL(RADIO_BROWSER_API_ENDPOINT);
  url.search = new URLSearchParams({
    countrycode: country.code,
    hidebroken: 'true',
    is_https: 'true',
    order: 'clickcount',
    reverse: 'true',
    limit: '100',
  }).toString();

  const response = await fetch(url, {
    cache: 'no-store',
    headers: {
      accept: 'application/json',
    },
    mode: 'cors',
  });

  if (!response.ok) {
    throw new Error(`${response.status} ${response.statusText}`);
  }

  const payload = await response.json();
  if (!Array.isArray(payload)) {
    throw new Error('Expected an array response from Radio Browser.');
  }

  return payload
    .map((station) => normalizeRadioBrowserStation(station, country.name))
    .filter(Boolean);
}

async function syncRadioStations(reason = 'sync', selectedCountryCodesOverride = null) {
  const syncCache = await caches.open(STATION_SYNC_CACHE_NAME);
  const syncSettings = selectedCountryCodesOverride
    ? { selectedCountryCodes: sanitizeSelectedCountryCodes(selectedCountryCodesOverride) }
    : await readSyncSettings(syncCache);
  const syncCountries = getSyncCountries(syncSettings.selectedCountryCodes);
  const aggregatedStations = [];
  const seenStationIds = new Set();
  const seenStreamUrls = new Set();
  const failedCountries = [];
  let syncedCountries = 0;

  for (const country of syncCountries) {
    try {
      const stations = await fetchCountryStations(country);
      await writeJsonToCache(syncCache, getCountryCatalogUrl(country.code), stations);

      for (const station of stations) {
        if (seenStationIds.has(station.id)) continue;
        if (seenStreamUrls.has(station.streamUrl)) continue;

        seenStationIds.add(station.id);
        seenStreamUrls.add(station.streamUrl);
        aggregatedStations.push(station);
      }

      syncedCountries += 1;
    } catch (error) {
      failedCountries.push(country.code);
      console.debug(`[sw] Station sync failed for ${country.name} (${country.code})`, error);
    }
  }

  aggregatedStations.sort((left, right) => {
    const countryOrder = left.country.localeCompare(right.country, undefined, { sensitivity: 'base' });
    if (countryOrder !== 0) return countryOrder;
    if (right.clickcount !== left.clickcount) return right.clickcount - left.clickcount;
    return left.name.localeCompare(right.name, undefined, { sensitivity: 'base' });
  });

  const meta = {
    reason,
    syncedAt: new Date().toISOString(),
    countryCount: syncedCountries,
    selectedCountryCodes: syncSettings.selectedCountryCodes,
    failedCountries,
    stationCount: aggregatedStations.length,
  };

  if (aggregatedStations.length === 0) {
    const existingCatalog = await syncCache.match(SYNC_CATALOG_URL);
    if (existingCatalog) {
      await writeJsonToCache(syncCache, SYNC_META_URL, {
        ...meta,
        reusedCachedCatalog: true,
      });
      return {
        ...meta,
        reusedCachedCatalog: true,
      };
    }

    throw new Error('Station sync fetched no stations.');
  }

  await Promise.all([
    writeJsonToCache(syncCache, SYNC_CATALOG_URL, aggregatedStations),
    writeJsonToCache(syncCache, SYNC_META_URL, meta),
  ]);

  return meta;
}

async function respondWithSyncedCatalog(request) {
  const syncCache = await caches.open(STATION_SYNC_CACHE_NAME);
  const cached = await syncCache.match(request);
  if (cached) return cached;

  const bundled = await caches.match(new URL('data/radio-stations.json', self.registration.scope).href)
    || await caches.match('data/radio-stations.json');

  if (bundled) return bundled;

  return fetch(new Request('data/radio-stations.json', { cache: 'reload' }));
}

async function respondWithSyncedCountryCatalog(request) {
  const syncCache = await caches.open(STATION_SYNC_CACHE_NAME);
  const cached = await syncCache.match(request);
  if (cached) return cached;
  return new Response('[]', {
    headers: STATION_SYNC_HEADERS,
    status: 200,
  });
}

async function refreshManagedCatalogCache() {
  const managedCatalogCache = await caches.open(MANAGED_CATALOG_CACHE_NAME);
  const url = new URL('api/managed-stations.json', self.registration.scope).href;
  try {
    const response = await fetch(url, { cache: 'no-store' });
    if (isCacheableResponse(response)) {
      await managedCatalogCache.put(url, response);
      return { updated: true, error: null };
    }

    return {
      updated: false,
      error: `Unexpected managed catalog response: ${response.status} ${response.statusText}`.trim(),
    };
  } catch {
    // Network unavailable — keep existing cache as-is.
  }

  return { updated: false, error: 'Managed catalog request failed.' };
}

async function notifyClients(message) {
  const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
  await Promise.allSettled(clients.map((client) => client.postMessage(message)));
}

async function respondWithManagedCatalog(request) {
  const managedCatalogCache = await caches.open(MANAGED_CATALOG_CACHE_NAME);

  try {
    const networkResponse = await fetch(request);
    if (isCacheableResponse(networkResponse)) {
      await managedCatalogCache.put(request, networkResponse.clone());
      return withManagedCatalogSource(networkResponse, 'remote');
    }
  } catch {
    // Fall back to the last good remote response or bundled catalog below.
  }

  const cachedRemoteResponse = await managedCatalogCache.match(request);
  if (cachedRemoteResponse) {
    return withManagedCatalogSource(cachedRemoteResponse, 'cached-remote');
  }

  const bundledFallback = await caches.match(BUNDLED_MANAGED_CATALOG_URL);
  if (bundledFallback) {
    return withManagedCatalogSource(bundledFallback, 'bundled');
  }

  return withManagedCatalogSource(await fetch(BUNDLED_MANAGED_CATALOG_URL), 'bundled');
}

function withManagedCatalogSource(response, source) {
  const headers = new Headers(response.headers);
  headers.set(MANAGED_CATALOG_SOURCE_HEADER, source);

  return new Response(response.body, {
    status: response.status,
    statusText: response.statusText,
    headers,
  });
}

function isCacheableResponse(response) {
  return Boolean(response && response.ok && response.type === 'basic');
}

async function putInCache(request, response) {
  if (!isCacheableResponse(response)) return;
  const cache = await caches.open(CACHE_NAME);
  await cache.put(request, response.clone());
}

async function precacheBuiltAssets(cache) {
  try {
    const indexResp = await fetch(new Request('./', { cache: 'reload' }));
    if (isCacheableResponse(indexResp)) {
      await cache.put('./', indexResp.clone());
    }

    const indexText = await indexResp.text();
    const assetUrls = [...indexText.matchAll(/(?:src|href)="([^"]+)"/g)]
      .map((match) => match[1])
      .filter((assetPath) => assetPath.startsWith('./assets/') || assetPath.startsWith('assets/'))
      .map((assetPath) => new URL(assetPath, self.registration.scope).href);

    await Promise.allSettled(assetUrls.map(async (assetUrl) => {
      const assetResp = await fetch(new Request(assetUrl, { cache: 'reload' }));
      if (!isCacheableResponse(assetResp)) return;
      await cache.put(assetUrl, assetResp);
    }));
  } catch (e) {
    // If HTML parsing fails, runtime caching below still catches assets.
  }
}

async function networkFirst(request, fallbackRequest) {
  try {
    const networkResp = await fetch(request);
    await putInCache(request, networkResp);
    return networkResp;
  } catch (error) {
    const cached = await caches.match(request);
    if (cached) return cached;
    if (fallbackRequest) {
      const fallback = await caches.match(fallbackRequest);
      if (fallback) return fallback;
    }
    throw error;
  }
}

async function staleWhileRevalidate(request, fallbackRequest) {
  const cached = await caches.match(request);

  const networkPromise = fetch(request)
    .then(async (networkResp) => {
      await putInCache(request, networkResp);
      return networkResp;
    })
    .catch(async () => {
      if (fallbackRequest) {
        const fallback = await caches.match(fallbackRequest);
        if (fallback) return fallback;
      }
      return null;
    });

  if (cached) {
    return cached;
  }

  const networkResp = await networkPromise;
  if (networkResp) return networkResp;

  return new Response('', { status: 503, statusText: 'Service Unavailable' });
}

self.addEventListener('install', (event) => {
  // Activate updated SW as soon as it's installed.
  self.skipWaiting();
  event.waitUntil(
    caches.open(CACHE_NAME).then(async (cache) => {
      const reqs = CORE_ASSETS.map((p) => new Request(p, { cache: 'reload' }));
      await Promise.allSettled(reqs.map(async (request) => {
        const response = await fetch(request);
        if (!isCacheableResponse(response)) return;
        await cache.put(request, response);
      }));

      // Vite fingerprints JS/CSS assets in production. Parse the built HTML so
      // the installed PWA can launch offline after its first install.
      await precacheBuiltAssets(cache);
    })
  );
});

self.addEventListener('activate', (event) => {
  event.waitUntil(
    Promise.all([
      self.clients.claim(),
      caches.keys().then((keys) => Promise.all(
        keys.map((k) => {
          if (k !== CACHE_NAME && k !== STATION_SYNC_CACHE_NAME && k !== MANAGED_CATALOG_CACHE_NAME) return caches.delete(k);
          return null;
        })
      )),
    ])
  );
});

self.addEventListener('sync', (event) => {
  if (event.tag !== STATION_SYNC_TAG) return;
  event.waitUntil(
    syncRadioStations('background-sync')
      .then((meta) => notifyClients({
        type: 'station-catalog-updated',
        reason: 'background-sync',
        meta,
      }))
      .catch(async (error) => {
        await notifyClients({
          type: 'station-catalog-update-failed',
          reason: 'background-sync',
          error: error instanceof Error ? error.message : 'Unknown error',
          failedAt: new Date().toISOString(),
        });
        throw error;
      })
  );
});

self.addEventListener('message', (event) => {
  if (event.data?.type !== 'set-sync-countries') return;

  event.waitUntil((async () => {
    try {
      const syncCache = await caches.open(STATION_SYNC_CACHE_NAME);
      const syncSettings = await writeSyncSettings(syncCache, event.data?.countryCodes);
      const syncResult = event.data?.syncNow
        ? await syncRadioStations('country-selection-update', syncSettings.selectedCountryCodes)
        : null;

      if (syncResult) {
        await notifyClients({
          type: 'station-catalog-updated',
          reason: 'country-selection-update',
          meta: syncResult,
        });
      }

      event.ports?.[0]?.postMessage({
        ok: true,
        selectedCountryCodes: syncSettings.selectedCountryCodes,
        syncResult,
      });
    } catch (error) {
      event.ports?.[0]?.postMessage({
        ok: false,
        error: error instanceof Error ? error.message : 'Unknown error',
      });
    }
  })());
});

self.addEventListener('periodicsync', (event) => {
  if (event.tag === STATION_PERIODIC_SYNC_TAG) {
    event.waitUntil(
      syncRadioStations('periodic-sync')
        .then((meta) => notifyClients({
          type: 'station-catalog-updated',
          reason: 'periodic-sync',
          meta,
        }))
        .catch(async (error) => {
          await notifyClients({
            type: 'station-catalog-update-failed',
            reason: 'periodic-sync',
            error: error instanceof Error ? error.message : 'Unknown error',
            failedAt: new Date().toISOString(),
          });
          throw error;
        })
    );
    return;
  }
  if (event.tag === MANAGED_CATALOG_PERIODIC_SYNC_TAG) {
    event.waitUntil(
      refreshManagedCatalogCache().then((result) => {
        if (!result.updated) {
          return notifyClients({
            type: 'managed-catalog-update-failed',
            reason: 'periodic-sync',
            error: result.error || 'Unknown error',
            failedAt: new Date().toISOString(),
          });
        }

        return notifyClients({
          type: 'managed-catalog-updated',
          reason: 'periodic-sync',
        });
      })
    );
  }
});

self.addEventListener('fetch', (event) => {
  // Only handle GET requests
  if (event.request.method !== 'GET') return;
  if (event.request.headers.has('range')) return;

  const url = new URL(event.request.url);

  // Don't cache cross-origin requests (station logos, APIs, etc.).
  if (url.origin !== self.location.origin) {
    return;
  }

  const isCore = CORE_PATHS.has(url.pathname);
  const isDataRequest = DATA_PATHS.has(url.pathname);
  const isHtmlNavigation = event.request.mode === 'navigate' || (event.request.headers.get('accept') || '').includes('text/html');
  const isStaticAsset = ['script', 'style', 'font', 'worker'].includes(event.request.destination)
    || url.pathname.includes('/assets/');
  const isImage = event.request.destination === 'image';

  if (url.pathname.endsWith('/sw.js')) {
    return;
  }

  if (url.pathname === SYNC_CATALOG_PATH) {
    event.respondWith(respondWithSyncedCatalog(event.request));
    return;
  }

  if (url.pathname === MANAGED_CATALOG_PATH) {
    event.respondWith(respondWithManagedCatalog(event.request));
    return;
  }

  if (url.pathname.startsWith(SYNC_COUNTRY_PREFIX_PATH) && url.pathname.endsWith('.json')) {
    event.respondWith(respondWithSyncedCountryCatalog(event.request));
    return;
  }

  // Network-first for navigations and core assets to prevent "old UI" issues.
  if (isHtmlNavigation) {
    event.respondWith(
      networkFirst(event.request, './').catch(
        () => caches.match('./') || caches.match('index.html') || new Response('', { status: 503, statusText: 'Offline' })
      )
    );
    return;
  }

  if (isCore || isDataRequest) {
    event.respondWith(
      networkFirst(event.request).catch(
        () => caches.match(event.request) || new Response('', { status: 503, statusText: 'Offline' })
      )
    );
    return;
  }

  if (isStaticAsset) {
    event.respondWith(staleWhileRevalidate(event.request));
    return;
  }

  if (isImage) {
    event.respondWith(staleWhileRevalidate(event.request, IMAGE_FALLBACK_PATH));
    return;
  }

  event.respondWith(
    staleWhileRevalidate(event.request)
  );
});
