import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, '..');

const readSrc = (relPath) => readFileSync(resolve(root, relPath), 'utf-8');

const liveNewsSrc = readSrc('src/components/LiveNewsPanel.ts');
const liveNewsSvc = readSrc('src/services/live-news.ts');
const youtubeApi = readSrc('api/youtube/live.js');
const sidecarSrc = readSrc('src-tauri/sidecar/local-api-server.mjs');
const vercelConfig = JSON.parse(readSrc('vercel.json'));
const tauriConfig = JSON.parse(readSrc('src-tauri/tauri.conf.json'));

const globalCsp = vercelConfig.headers
  .find((entry) => entry.source.includes('?!docs|embed'))
  ?.headers
  ?.find((header) => header.key === 'Content-Security-Policy')
  ?.value ?? '';
const tauriCsp = tauriConfig.app.security.csp;
const getCspDirective = (csp, directive) =>
  csp.match(new RegExp(`${directive}\\s+([^;]+)`))?.[1]?.split(/\s+/) ?? [];

// ── Extract channel IDs and DIRECT_HLS_MAP keys from source ──

const extractArrayIds = (arrayName) => {
  const pattern = new RegExp(`const ${arrayName}[^=]*=[^\\[]*\\[([\\s\\S]*?)\\];`);
  const match = liveNewsSrc.match(pattern);
  if (!match) return [];
  return [...match[1].matchAll(/id:\s*'([^']+)'/g)].map(m => m[1]);
};

const fullIds = extractArrayIds('FULL_LIVE_CHANNELS');
const techIds = extractArrayIds('TECH_LIVE_CHANNELS');
const optionalIds = extractArrayIds('OPTIONAL_LIVE_CHANNELS');
const allChannelIds = new Set([...fullIds, ...techIds, ...optionalIds]);

const hlsMapMatch = liveNewsSrc.match(/const DIRECT_HLS_MAP[^{]*\{([\s\S]*?)\};/);
const hlsMapEntries = hlsMapMatch
  ? [...hlsMapMatch[1].matchAll(/'([^']+)':\s*'([^']+)'/g)].map(m => ({ id: m[1], url: m[2] }))
  : [];

const hlsMapIds = new Set(hlsMapEntries.map(e => e.id));

// ── 1. DIRECT_HLS_MAP integrity ──

describe('DIRECT_HLS_MAP integrity', () => {
  it('has at least 6 entries', () => {
    assert.ok(hlsMapEntries.length >= 6, `Expected ≥6 entries, got ${hlsMapEntries.length}`);
  });

  it('every key maps to an existing channel definition', () => {
    for (const { id } of hlsMapEntries) {
      assert.ok(allChannelIds.has(id), `DIRECT_HLS_MAP key '${id}' has no matching channel`);
    }
  });

  it('every mapped channel has a fallbackVideoId or hlsUrl', () => {
    for (const { id } of hlsMapEntries) {
      const channelDef = liveNewsSrc.match(new RegExp(`id:\\s*'${id}'[^}]*}`));
      assert.ok(channelDef, `Channel '${id}' definition not found`);
      const hasFallback = /fallbackVideoId:\s*'[^']+'/.test(channelDef[0]);
      const hasHlsUrl = /hlsUrl:\s*'[^']+'/.test(channelDef[0]);
      const hasHandle = /handle:\s*'[^']+'/.test(channelDef[0]);
      assert.ok(hasFallback || hasHlsUrl || hasHandle,
        `Channel '${id}' in DIRECT_HLS_MAP lacks fallbackVideoId, hlsUrl, and handle`);
    }
  });

  it('all HLS URLs use HTTPS', () => {
    for (const { id, url } of hlsMapEntries) {
      assert.ok(url.startsWith('https://'), `HLS URL for '${id}' is not HTTPS: ${url}`);
    }
  });

  it('all HLS URLs contain .m3u8', () => {
    for (const { id, url } of hlsMapEntries) {
      assert.ok(url.includes('.m3u8'), `HLS URL for '${id}' does not contain .m3u8: ${url}`);
    }
  });

  it('no duplicate channel IDs in the map', () => {
    const ids = hlsMapEntries.map(e => e.id);
    assert.equal(ids.length, new Set(ids).size, 'Duplicate IDs in DIRECT_HLS_MAP');
  });
});

// ── 2. Channel data integrity ──

describe('channel data integrity', () => {
  it('all FULL_LIVE_CHANNELS have fallbackVideoId', () => {
    for (const id of fullIds) {
      const match = liveNewsSrc.match(new RegExp(`id:\\s*'${id}'[^}]*}`, 's'));
      assert.ok(match, `Channel '${id}' not found`);
      assert.match(match[0], /fallbackVideoId:\s*'[^']+'/,
        `FULL channel '${id}' missing fallbackVideoId`);
    }
  });

  it('no channel ID appears in multiple arrays with conflicting definitions', () => {
    const allIds = [...fullIds, ...techIds, ...optionalIds];
    const counts = {};
    for (const id of allIds) counts[id] = (counts[id] || 0) + 1;
    for (const [id, count] of Object.entries(counts)) {
      if (count > 1) {
        const defs = [...liveNewsSrc.matchAll(new RegExp(`id:\\s*'${id}'[^}]*}`, 'g'))].map(m => m[0]);
        const handles = defs.map(d => d.match(/handle:\s*'([^']+)'/)?.[1]);
        const uniqueHandles = new Set(handles);
        assert.equal(uniqueHandles.size, 1,
          `Channel '${id}' has conflicting handles across arrays: ${[...uniqueHandles].join(', ')}`);
      }
    }
  });

  it('TRT World handle is @TRTWorld (not @taborrtworld)', () => {
    const trt = liveNewsSrc.match(/id:\s*'trt-world'[^}]*}/);
    assert.ok(trt, 'trt-world channel not found');
    assert.match(trt[0], /handle:\s*'@TRTWorld'/,
      'TRT World handle should be @TRTWorld');
  });

  it('euronews handle is @euronews (not typo)', () => {
    const match = liveNewsSrc.match(/id:\s*'euronews'[^}]*}/);
    assert.ok(match, 'euronews channel not found');
    assert.match(match[0], /handle:\s*'@euronews'/,
      'euronews handle should be @euronews');
  });
});

// ── 3. renderNativeHlsPlayer safety checks ──

describe('renderNativeHlsPlayer safety', () => {
  it('validates HLS URL starts with https://', () => {
    assert.match(liveNewsSrc, /hlsUrl\.startsWith\('https:\/\/'\)/,
      'Must validate HLS URL is HTTPS before creating video element');
  });

  it('captures activeChannel ref for race safety in error handler', () => {
    assert.match(liveNewsSrc, /const failedChannel\s*=\s*this\.activeChannel/,
      'Error handler must capture channel ref to avoid race conditions');
  });

  it('sets cooldown on HLS failure', () => {
    assert.match(liveNewsSrc, /this\.hlsFailureCooldown\.set\(failedChannel\.id/,
      'Must set cooldown timestamp on failure');
  });

  it('checks current live media session before fallback', () => {
    assert.match(liveNewsSrc, /this\.ownsLiveMediaSession\(failedChannel\.id,\s*sessionToken\)/,
      'Must verify the HLS callback still belongs to the active media session before falling back');
  });

  it('explicitly stops video element on error', () => {
    assert.match(liveNewsSrc, /video\.pause\(\);\s*\n\s*video\.removeAttribute\('src'\)/,
      'Must pause and clear src on error for explicit cleanup');
  });
});

// ── 4. getDirectHlsUrl cooldown logic ──

describe('getDirectHlsUrl cooldown', () => {
  it('checks cooldown map before returning URL', () => {
    assert.match(liveNewsSrc, /this\.hlsFailureCooldown\.get\(channelId\)/,
      'Must check cooldown before returning HLS URL');
  });

  it('uses HLS_COOLDOWN_MS for timeout comparison', () => {
    assert.match(liveNewsSrc, /Date\.now\(\)\s*-\s*failedAt\s*<\s*this\.HLS_COOLDOWN_MS/,
      'Must compare against HLS_COOLDOWN_MS');
  });

  it('cooldown is at least 1 minute', () => {
    const match = liveNewsSrc.match(/HLS_COOLDOWN_MS\s*=\s*(\d+)\s*\*\s*(\d+)\s*\*\s*(\d+)/);
    assert.ok(match, 'HLS_COOLDOWN_MS not found');
    const ms = Number(match[1]) * Number(match[2]) * Number(match[3]);
    assert.ok(ms >= 60_000, `Cooldown too short: ${ms}ms (need ≥60s)`);
  });
});

// ── 5. Player decision tree ordering ──

describe('player decision tree', () => {
  it('switchChannel defers media resolution until playback is active or requested', () => {
    const switchMethod = liveNewsSrc.slice(
      liveNewsSrc.indexOf('private async switchChannel'),
      liveNewsSrc.indexOf('private showOfflineMessage'),
    );
    const shouldStartPos = switchMethod.indexOf('const shouldStartMedia');
    const previewOnlyPos = switchMethod.indexOf('if (!shouldStartMedia)');
    const placeholderPos = switchMethod.indexOf('this.renderPlaceholder()');
    const resolvePos = switchMethod.indexOf('await this.resolveChannelVideo(channel)');
    const requestPos = switchMethod.indexOf('this.requestPlaybackForActiveChannel()');
    assert.ok(shouldStartPos > 0, 'switchChannel must compute whether media should start');
    assert.ok(previewOnlyPos > shouldStartPos, 'switchChannel must branch on shouldStartMedia');
    assert.ok(placeholderPos > previewOnlyPos, 'switchChannel preview-only branch must render the placeholder');
    assert.ok(resolvePos > placeholderPos, 'switchChannel must not resolve live media before preview-only return');
    assert.ok(requestPos > resolvePos, 'switchChannel must request playback only after resolving the selected channel');
  });

  it('switchChannel re-checks playback intent after async channel resolution', () => {
    const switchMethod = liveNewsSrc.slice(
      liveNewsSrc.indexOf('private async switchChannel'),
      liveNewsSrc.indexOf('private showOfflineMessage'),
    );
    const resolvePos = switchMethod.indexOf('await this.resolveChannelVideo(channel)');
    const intentRecheckPos = switchMethod.indexOf('if (!this.hasPlaybackIntent())');
    const requestPos = switchMethod.indexOf('this.requestPlaybackForActiveChannel()');
    assert.ok(resolvePos > 0, 'switchChannel must resolve the selected channel');
    assert.ok(intentRecheckPos > resolvePos, 'switchChannel must re-check playback intent after async resolution');
    assert.ok(intentRecheckPos < requestPos, 'switchChannel must re-check intent before requesting playback');
  });

  it('YouTube player callbacks are guarded by the current media session', () => {
    const initMethod = liveNewsSrc.slice(
      liveNewsSrc.indexOf('private async initializePlayer'),
      liveNewsSrc.indexOf('private startBotCheckTimeout'),
    );
    assert.match(initMethod, /const playerSessionToken\s*=\s*this\.liveMediaSessionToken/,
      'initializePlayer must capture the current media session before registering YouTube callbacks');
    assert.match(initMethod, /onReady:\s*\(\)\s*=>\s*\{\s*if \(!this\.ownsLiveMediaSession\(playerChannelId,\s*playerSessionToken\)\) return;/,
      'YouTube onReady callback must be guarded by current media session ownership');
    assert.match(initMethod, /onError:\s*\(event\)\s*=>\s*\{\s*if \(!this\.ownsLiveMediaSession\(playerChannelId,\s*playerSessionToken\)\) return;/,
      'YouTube onError callback must be guarded by current media session ownership');
  });

  it('desktop embed bridge messages are guarded by the current media session', () => {
    const bridgeMethod = liveNewsSrc.slice(
      liveNewsSrc.indexOf('private setupBridgeMessageListener'),
      liveNewsSrc.indexOf('private static resolveYouTubeOrigin'),
    );
    const desktopEmbedMethod = liveNewsSrc.slice(
      liveNewsSrc.indexOf('private async renderDesktopEmbedAsync'),
      liveNewsSrc.indexOf('private async renderNativeHlsPlayer'),
    );
    const destroyMethod = liveNewsSrc.slice(
      liveNewsSrc.indexOf('private destroyPlayer'),
      liveNewsSrc.indexOf('private resumeFromIdle'),
    );
    assert.match(bridgeMethod, /const session\s*=\s*this\.desktopEmbedSession/,
      'desktop bridge handler must capture the iframe session before processing messages');
    assert.match(bridgeMethod, /this\.ownsLiveMediaSession\(session\.channelId,\s*session\.sessionToken\)/,
      'desktop bridge handler must ignore stale iframe messages after ownership changes');
    assert.match(desktopEmbedMethod, /this\.desktopEmbedSession\s*=\s*\{\s*iframe,\s*channelId,\s*sessionToken\s*\}/,
      'desktop embed creation must record channel and session ownership');
    assert.match(destroyMethod, /this\.desktopEmbedSession\s*=\s*null/,
      'destroyPlayer must clear desktop embed session ownership');
  });

  it('initializePlayer checks direct HLS before videoId validation', () => {
    const initMethod = liveNewsSrc.slice(
      liveNewsSrc.indexOf('private async initializePlayer'),
      liveNewsSrc.indexOf('private startBotCheckTimeout'),
    );
    const hlsPos = initMethod.indexOf('getDirectHlsUrl(this.activeChannel.id)');
    const videoIdPos = initMethod.indexOf("!/^[\\w-]{10,12}$/.test(this.activeChannel.videoId)");
    assert.ok(hlsPos > 0, 'getDirectHlsUrl not found in initializePlayer');
    assert.ok(videoIdPos > 0, 'videoId validation not found in initializePlayer');
    assert.ok(hlsPos < videoIdPos,
      'Direct HLS check must come BEFORE videoId validation in initializePlayer');
  });
});

// ── 6. resolveChannelVideo skips YouTube API for direct HLS ──

describe('resolveChannelVideo optimization', () => {
  it('skips fetchLiveVideoInfo for desktop direct-HLS channels', () => {
    const resolve = liveNewsSrc.slice(
      liveNewsSrc.indexOf('private async resolveChannelVideo'),
      liveNewsSrc.indexOf('private async switchChannel'),
    );
    const directHlsPos = resolve.indexOf('getDirectHlsUrl(channel.id)');
    const fetchPos = resolve.indexOf('fetchLiveVideoInfo');
    assert.ok(directHlsPos > 0, 'getDirectHlsUrl not in resolveChannelVideo');
    assert.ok(fetchPos > 0, 'fetchLiveVideoInfo not in resolveChannelVideo');
    assert.ok(directHlsPos < fetchPos,
      'Direct HLS early return must come before fetchLiveVideoInfo call');
  });
});

// ── 7. YouTube API: hlsUrl extraction ──

describe('YouTube API hlsManifestUrl extraction', () => {
  it('extracts hlsManifestUrl from page HTML', () => {
    assert.match(youtubeApi, /hlsManifestUrl/,
      'API must extract hlsManifestUrl');
  });

  it('unescapes \\u0026 in HLS URL', () => {
    assert.match(youtubeApi, /\\\\u0026/,
      'Must unescape \\u0026 to & in HLS URLs');
  });

  it('only sets hlsUrl when videoId is present', () => {
    assert.match(youtubeApi, /hlsMatch\s*&&\s*videoId/,
      'hlsUrl must only be set when a live videoId was found');
  });

  it('includes hlsUrl in response JSON', () => {
    assert.match(youtubeApi, /JSON\.stringify\(\{[^}]*hlsUrl/,
      'Response must include hlsUrl field');
  });
});

// ── 8. live-news.ts service ──

describe('fetchLiveVideoInfo service', () => {
  it('exports fetchLiveVideoInfo function', () => {
    assert.match(liveNewsSvc, /export async function fetchLiveVideoInfo/,
      'Must export fetchLiveVideoInfo');
  });

  it('returns hlsUrl from API response', () => {
    assert.match(liveNewsSvc, /hlsUrl\s*=\s*data\.hlsUrl/,
      'Must propagate hlsUrl from API response');
  });

  it('caches hlsUrl alongside videoId', () => {
    assert.match(liveNewsSvc, /liveVideoCache\.set\([^)]*hlsUrl/,
      'Cache must store hlsUrl');
  });

  it('returns null hlsUrl on error', () => {
    assert.match(liveNewsSvc, /return\s*\{\s*videoId:\s*null,\s*hlsUrl:\s*null\s*\}/,
      'Error path must return null for both videoId and hlsUrl');
  });

  it('keeps deprecated fetchLiveVideoId for backwards compat', () => {
    assert.match(liveNewsSvc, /@deprecated/,
      'fetchLiveVideoId should be marked deprecated');
    assert.match(liveNewsSvc, /export async function fetchLiveVideoId/,
      'fetchLiveVideoId must still be exported');
  });
});

// ── 9. Sidecar YouTube embed endpoint ──

describe('sidecar youtube-embed endpoint', () => {
  it('registers /api/youtube-embed route', () => {
    assert.match(sidecarSrc, /\/api\/youtube-embed/,
      'Sidecar must handle /api/youtube-embed');
  });

  it('validates videoId format', () => {
    assert.match(sidecarSrc, /\[A-Za-z0-9_-\]\{11\}/,
      'Must validate videoId is exactly 11 chars');
  });

  it('rejects invalid videoId with 400', () => {
    assert.match(sidecarSrc, /status:\s*400/,
      'Invalid videoId must return 400');
  });

  it('whitelists video quality values', () => {
    assert.match(sidecarSrc, /small.*medium.*large.*hd720.*hd1080/,
      'Must whitelist quality parameter values');
  });

  it('is exempt from auth gate (before auth middleware)', () => {
    const embedPos = sidecarSrc.indexOf('/api/youtube-embed');
    const authPos = sidecarSrc.indexOf('Global auth gate');
    assert.ok(embedPos > 0 && authPos > 0, 'Both positions must exist');
    assert.ok(embedPos < authPos,
      'youtube-embed must be BEFORE auth gate (iframe src cannot carry auth headers)');
  });

  it('uses mute param (not hardcoded) in playerVars', () => {
    const embedSection = sidecarSrc.slice(
      sidecarSrc.indexOf('/api/youtube-embed'),
      sidecarSrc.indexOf('Global auth gate'),
    );
    assert.match(embedSection, /mute:\$\{mute\}/,
      'playerVars.mute must use the mute param, not hardcoded mute:1');
    assert.doesNotMatch(embedSection, /playerVars:\{[^}]*mute:1[^}]*\}/,
      'playerVars must NOT hardcode mute:1');
  });

  it('has postMessage bridge for play/pause/mute/unmute', () => {
    const embedSection = sidecarSrc.slice(
      sidecarSrc.indexOf('/api/youtube-embed'),
      sidecarSrc.indexOf('Global auth gate'),
    );
    assert.match(embedSection, /case'play':.*playVideo/,
      'postMessage bridge must handle play command');
    assert.match(embedSection, /case'pause':.*pauseVideo/,
      'postMessage bridge must handle pause command');
    assert.match(embedSection, /case'mute':.*\.mute\(\)/,
      'postMessage bridge must handle mute command');
    assert.match(embedSection, /case'unmute':.*\.unMute\(\)/,
      'postMessage bridge must handle unmute command');
  });

  it('has play overlay for autoplay failures', () => {
    const embedSection = sidecarSrc.slice(
      sidecarSrc.indexOf('/api/youtube-embed'),
      sidecarSrc.indexOf('Global auth gate'),
    );
    assert.match(embedSection, /play-overlay/,
      'Embed must include a play overlay for WKWebView autoplay fallback');
    assert.match(embedSection, /setTimeout.*started.*overlay/s,
      'Play overlay must show after timeout if video has not started');
  });

  it('sends yt-ready postMessage to parent on ready', () => {
    const embedSection = sidecarSrc.slice(
      sidecarSrc.indexOf('/api/youtube-embed'),
      sidecarSrc.indexOf('Global auth gate'),
    );
    assert.match(embedSection, /postMessage\(\{type:'yt-ready'\}/,
      'Must send yt-ready message to parent window');
  });
});

// ── 10. Optional channels with fallbackVideoId ──

describe('optional channels fallback coverage', () => {
  const highPriorityOptional = ['abc-news', 'nbc-news', 'wion', 'rt'];

  for (const id of highPriorityOptional) {
    it(`${id} has a fallback path`, () => {
      const match = liveNewsSrc.match(new RegExp(`id:\\s*'${id}'[^}]*}`));
      assert.ok(match, `Channel '${id}' not found in OPTIONAL_LIVE_CHANNELS`);
      const hasFallback = /fallbackVideoId:\s*'[A-Za-z0-9_-]{11}'/.test(match[0]);
      const hasHlsUrl = /hlsUrl:\s*'[^']+'/.test(match[0]);
      const hasHandle = /handle:\s*'[^']+'/.test(match[0]);
      assert.ok(hasFallback || hasHlsUrl || hasHandle,
        `Optional channel '${id}' must have fallbackVideoId, hlsUrl, or handle`);
    });
  }

  it('channels with useFallbackOnly also have fallbackVideoId or hlsUrl', () => {
    const useFallbackMatches = [...liveNewsSrc.matchAll(/id:\s*'([^']+)'[^}]*useFallbackOnly:\s*true[^}]*}/g)];
    for (const m of useFallbackMatches) {
      const channelId = m[1];
      const hasFallback = /fallbackVideoId:\s*'[^']+'/.test(m[0]);
      const hasHlsUrl = /hlsUrl:\s*'[^']+'/.test(m[0]);
      assert.ok(hasFallback || hasHlsUrl,
        `Channel '${channelId}' has useFallbackOnly but no fallbackVideoId or hlsUrl`);
    }
  });
});

// ── 11. CSP allows HLS media and desktop sidecar iframe ──

describe('CSP configuration', () => {
  it('web header media-src allows https: for CDN HLS streams', () => {
    assert.ok(getCspDirective(globalCsp, 'media-src').includes('https:'),
      'web CSP media-src must allow HTTPS for direct HLS CDN streams');
  });

  it('Tauri CSP frame-src allows localhost sidecar iframe origins', () => {
    const frameSrc = getCspDirective(tauriCsp, 'frame-src');
    assert.ok(frameSrc.includes('http://127.0.0.1:*'),
      'Tauri CSP frame-src must allow 127.0.0.1 sidecar iframe origins');
    assert.ok(frameSrc.includes('http://localhost:*'),
      'Tauri CSP frame-src must allow localhost sidecar iframe origins');
  });

  it('Tauri CSP media-src allows localhost sidecar and HTTPS HLS media', () => {
    const mediaSrc = getCspDirective(tauriCsp, 'media-src');
    assert.ok(mediaSrc.includes('https:'),
      'Tauri CSP media-src must allow HTTPS for direct HLS CDN streams');
    assert.ok(mediaSrc.includes('http://127.0.0.1:*'),
      'Tauri CSP media-src must allow 127.0.0.1 sidecar media origins');
    assert.ok(mediaSrc.includes('http://localhost:*'),
      'Tauri CSP media-src must allow localhost sidecar media origins');
  });
});
