/**
 * Pure, dependency-free core for Source indexing (Pinchflat-style media
 * manager). Like buildArgs.ts / validation.ts this module imports
 * nothing from electron or the node runtime, so its URL-classification and
 * item-merge logic can be unit-tested without spinning up Electron.
 *
 * The impure orchestration (spawning yt-dlp, persisting to disk) lives in
 * indexer.ts, which composes these helpers.
 */

import type { MediaItem, SourceKind } from '@shared/ipc'

// --- yt-dlp --flat-playlist entry shape (shared with probe.ts) --------------

/** The slice of a flat-playlist entry we read (a video, or a nested playlist). */
export interface RawEntry {
  id?: string
  title?: string
  url?: string
  webpage_url?: string
  duration?: number
  uploader?: string
  channel?: string
}

/**
 * Resolve the canonical URL for a flat-playlist entry. Prefer an explicit http(s)
 * URL; otherwise build a YouTube watch URL from the id (the common case where flat
 * entries carry only an id). Returns null when nothing usable is present.
 *
 * Note: the returned value is always either an http(s) URL or null, so it can
 * never begin with '-' and be mis-read as a yt-dlp option (callers also pass `--`).
 */
export function entryUrl(e: RawEntry): string | null {
  const cand = e.url || e.webpage_url
  if (cand && /^https?:\/\//i.test(cand)) return cand
  // e.id is untrusted JSON from yt-dlp, so percent-encode it rather than splicing
  // it raw into the query string (audit T2). A normal 11-char id is unaffected.
  if (e.id) return `https://www.youtube.com/watch?v=${encodeURIComponent(e.id)}`
  return null
}

/** Seconds → 'M:SS' or 'H:MM:SS'. Flat entries give duration as a number. */
export function fmtDuration(sec?: number): string | undefined {
  if (sec == null || !Number.isFinite(sec)) return undefined
  const s = Math.max(0, Math.round(sec))
  const h = Math.floor(s / 3600)
  const m = Math.floor((s % 3600) / 60)
  const r = s % 60
  const mm = h ? String(m).padStart(2, '0') : String(m)
  return `${h ? `${h}:` : ''}${mm}:${String(r).padStart(2, '0')}`
}

// --- Source-URL classification ----------------------------------------------

/** A classified Source URL: the kind, plus a normalised base for tab probing. */
export interface SourceClass {
  kind: SourceKind
  /**
   * For 'channel': the channel root (e.g. https://www.youtube.com/@handle) onto
   * which '/videos' and '/playlists' tabs are appended. For 'playlist': the
   * canonical playlist URL.
   */
  base: string
}

/**
 * Classify a pasted URL as a YouTube channel, a playlist, or neither.
 *
 * Channel forms: /@handle, /channel/<id>, /c/<name>, /user/<name> — any trailing
 * tab (/videos, /playlists, /streams, /shorts, /featured) is stripped to the root.
 * Playlist form: any URL carrying a `list=` query param. Returns null for a lone
 * video or a non-YouTube URL (Phase F scopes the channel walk to YouTube, whose
 * /videos + /playlists tab structure this relies on).
 */
export function classifySource(raw: string): SourceClass | null {
  let u: URL
  try {
    u = new URL((raw ?? '').trim())
  } catch {
    return null
  }
  if (u.protocol !== 'http:' && u.protocol !== 'https:') return null

  const host = u.hostname.replace(/^www\./i, '').toLowerCase()
  const isYouTube = host === 'youtube.com' || host.endsWith('.youtube.com') || host === 'youtu.be'

  // A playlist is identified purely by its list= param (works on any youtube host).
  const list = u.searchParams.get('list')
  if (isYouTube && list) {
    return {
      kind: 'playlist',
      base: `https://www.youtube.com/playlist?list=${encodeURIComponent(list)}`
    }
  }

  if (!isYouTube) return null

  // Channel roots. The handle/id segment is captured; later path segments
  // (the tab) are discarded so we always probe from the channel root.
  const m = u.pathname.match(/^\/(@[^/]+|channel\/[^/]+|c\/[^/]+|user\/[^/]+)/i)
  if (m) return { kind: 'channel', base: `https://www.youtube.com/${m[1]}` }

  return null
}

/**
 * Channel tab titles from yt-dlp often look like "<Name> - Videos" or
 * "<Name> - Playlists". Strip a trailing known-tab suffix to recover the bare
 * channel name. Leaves anything else untouched.
 */
export function stripTabSuffix(title: string | undefined): string | undefined {
  if (!title) return title
  return title.replace(/\s[-–—]\s(Videos|Playlists|Shorts|Live|Streams|Home|Featured)$/i, '').trim()
}

/**
 * Deterministic 32-bit FNV-1a hash → 8 hex chars. Used to derive a stable Source
 * id from its normalised base URL so re-indexing the same channel updates the
 * existing record instead of creating a duplicate. Pure (no crypto import).
 */
export function stableSourceId(base: string): string {
  let h = 0x811c9dc5
  for (let i = 0; i < base.length; i++) {
    h ^= base.charCodeAt(i)
    h = Math.imul(h, 0x01000193)
  }
  return (h >>> 0).toString(16).padStart(8, '0')
}

// --- RSS fast-check (Phase J: watched sources) ------------------------------

/**
 * Build a YouTube RSS feed URL for cheap "anything new?" polling of a watched
 * source. Channels feed by `channel_id` (UC…), playlists by `playlist_id` (PL…).
 * Returns undefined when the id is missing (the sync then falls back to a full
 * re-index). The feed only carries the latest ~15 uploads, so it's a freshness
 * check, not a substitute for the initial full index.
 */
export function buildFeedUrl(kind: SourceKind, ytId: string | undefined): string | undefined {
  if (!ytId) return undefined
  const param = kind === 'channel' ? 'channel_id' : 'playlist_id'
  return `https://www.youtube.com/feeds/videos.xml?${param}=${encodeURIComponent(ytId)}`
}

/**
 * Guard for the watched-source RSS pre-check (audit T7). The only feed AeroFetch
 * ever builds is a YouTube videos.xml feed (see buildFeedUrl), so the sync refuses
 * to fetch anything else — this keeps a hand-edited / corrupted sources.json from
 * pointing fetch() at an internal service, a cloud-metadata endpoint, or any other
 * arbitrary host (SSRF). Requires https, the youtube.com host (www optional), and
 * the exact feed path; the channel_id/playlist_id query is free.
 */
export function isYouTubeFeedUrl(url: string): boolean {
  try {
    const u = new URL(url)
    const host = u.hostname.replace(/^www\./i, '').toLowerCase()
    return u.protocol === 'https:' && host === 'youtube.com' && u.pathname === '/feeds/videos.xml'
  } catch {
    return false
  }
}

/** Extract the video ids from a YouTube RSS/Atom feed body (the <yt:videoId> tags). */
export function parseRssVideoIds(xml: string): string[] {
  const ids: string[] = []
  const re = /<yt:videoId>\s*([\w-]+)\s*<\/yt:videoId>/g
  let m: RegExpExecArray | null
  while ((m = re.exec(xml)) !== null) if (m[1]) ids.push(m[1])
  return ids
}

// --- Entry merge / dedup ----------------------------------------------------

/** A named playlist and its (flat) video entries, ready to merge. */
export interface NamedPlaylist {
  title: string
  entries: RawEntry[]
}

/**
 * Merge a Source's playlists (and its catch-all uploads) into a deduped list of
 * MediaItem records. Dedup is by video id: the FIRST playlist a video appears in
 * wins its folder assignment, so videos grouped into a real playlist land there,
 * and only videos in no playlist fall through to the synthetic 'Uploads' folder.
 *
 * `playlistIndex` is the 1-based position WITHIN the winning playlist (not the
 * global upload order), so the on-disk "NNN - Title" numbering matches the
 * playlist the file is filed under.
 */
export function buildMediaItems(
  sourceId: string,
  playlists: NamedPlaylist[],
  uploads: RawEntry[]
): MediaItem[] {
  const byVideo = new Map<string, MediaItem>()

  const add = (e: RawEntry, playlistTitle: string, index: number): void => {
    const videoId = e.id
    if (!videoId) return
    if (byVideo.has(videoId)) return // first playlist wins
    const url = entryUrl(e)
    if (!url) return // not turnable into a downloadable URL
    byVideo.set(videoId, {
      id: `${sourceId}:${videoId}`,
      sourceId,
      videoId,
      title: e.title || `Video ${videoId}`,
      url,
      playlistTitle,
      playlistIndex: index,
      durationLabel: fmtDuration(e.duration),
      downloaded: false
    })
  }

  for (const p of playlists) p.entries.forEach((e, i) => add(e, p.title, i + 1))
  uploads.forEach((e, i) => add(e, 'Uploads', i + 1))

  return [...byVideo.values()]
}

/**
 * Merge a freshly-enumerated item list with the previously-persisted one for the
 * same source (incremental re-index). The fresh
 * list defines the current membership/ordering, but any video already marked
 * downloaded keeps its `downloaded`/`downloadedAt`/`filePath` so a re-sync never
 * loses that state or re-downloads it. Videos that vanished from the source (now
 * private/deleted) are dropped. `newCount` is how many fresh videos weren't in
 * the previous set — the "X new since last sync" figure.
 */
export function mergeItemsPreservingState(
  existing: MediaItem[],
  fresh: MediaItem[]
): { items: MediaItem[]; newCount: number } {
  const prevByVideo = new Map(existing.map((m) => [m.videoId, m]))
  let newCount = 0
  const items = fresh.map((f) => {
    const prev = prevByVideo.get(f.videoId)
    if (!prev) {
      newCount++
      return f
    }
    return prev.downloaded
      ? { ...f, downloaded: true, downloadedAt: prev.downloadedAt, filePath: prev.filePath }
      : f
  })
  return { items, newCount }
}
