/**
 * Awwwards gallery crawler.
 *
 * Parses the SOTY/SOTM/SOTD gallery pages and extracts per-site metadata
 * from the `data-collectable-model-value` JSON attribute that Awwwards
 * attaches to every gallery card. Uses `fetch` with polite delays +
 * user-agent; no scraping beyond what any browser would naturally see.
 *
 * Tier assignment is derived from the gallery URL we queried — every
 * card on `/sites_of_the_day/` is SOTD, every card on `/sites_of_the_month/`
 * is SOTM, etc. No per-card tier detection needed.
 */

import {
  awwwardsSiteSchema,
  type AwardTier,
  type AwwwardsSite,
  type IngestRunConfig,
} from './types.js'

const BASE = 'https://www.awwwards.com'

const TIER_PATHS: Readonly<Record<AwardTier, string>> = {
  SOTY: '/websites/sites_of_the_year/',
  SOTM: '/websites/sites_of_the_month/',
  SOTD: '/websites/sites_of_the_day/',
}

/**
 * Shape of the `data-collectable-model-value` JSON attribute Awwwards
 * attaches to each gallery card. Fields not used by the ingester are
 * accepted but dropped.
 */
interface CollectableModel {
  collectableIdentifier?: string
  slug?: string
  title?: string
  tags?: string[]
  type?: string
  createdAt?: number
}

/**
 * Fetch a single gallery page and return its parsed site cards.
 * Every returned card is tagged with the gallery's tier.
 */
export async function fetchGalleryPage(
  tier: AwardTier,
  page: number,
  config: IngestRunConfig,
): Promise<AwwwardsSite[]> {
  const url = page <= 1 ? `${BASE}${TIER_PATHS[tier]}` : `${BASE}${TIER_PATHS[tier]}?page=${page}`
  const html = await fetchText(url, config)
  return parseCards(html, tier)
}

async function fetchText(url: string, config: IngestRunConfig): Promise<string> {
  const response = await fetch(url, {
    headers: {
      'User-Agent': config.userAgent,
      Accept: 'text/html,application/xhtml+xml',
    },
  })

  if (!response.ok) {
    throw new Error(`Awwwards gallery fetch failed: ${response.status} ${url}`)
  }

  return response.text()
}

/**
 * Count how many pages a tier's gallery has by scanning the pagination block.
 */
export async function discoverPageCount(tier: AwardTier, config: IngestRunConfig): Promise<number> {
  const html = await fetchText(`${BASE}${TIER_PATHS[tier]}`, config)
  const matches = [...html.matchAll(/[?&]page=(\d+)/g)]
  const max = matches.reduce((n, m) => Math.max(n, Number(m[1])), 1)
  return max
}

/**
 * Extract sites from gallery HTML.
 *
 * Strategy: each card is an `<li ... data-controller="collectable"
 * data-collectable-model-value="{...JSON...}">` block. We split on that
 * opening pattern and walk each block. The nearby `figure-rollover__bt`
 * anchor with `target="_blank"` carries the external live URL.
 *
 * Because the gallery URL determines the tier, we assign it unconditionally.
 */
function parseCards(html: string, tier: AwardTier): AwwwardsSite[] {
  const blocks = splitCards(html)
  const sites: AwwwardsSite[] = []

  for (const block of blocks) {
    const model = extractCollectableModel(block)
    if (!model?.slug) continue

    const liveUrl = extractLiveUrl(block)
    if (!liveUrl) continue

    const title = decodeEntities(model.title ?? model.slug)
    const { industryTags, techTags, styleTags } = classifyTags(model.tags ?? [])
    const awardDate = toIsoDate(model.createdAt) ?? new Date().toISOString().slice(0, 10)

    const parsed = awwwardsSiteSchema.safeParse({
      slug: model.slug,
      title,
      liveUrl,
      detailUrl: `${BASE}/sites/${model.slug}`,
      tier,
      awardDate,
      industryTags,
      techTags,
      styleTags,
      colorTags: [],
      country: undefined,
      agency: undefined,
    })

    if (parsed.success) {
      sites.push(parsed.data)
    }
  }

  return sites
}

/**
 * Split the page HTML into per-card substrings.
 *
 * Each card opens with `<li ... class="... js-collectable ..."
 * data-controller="collectable" data-collectable-model-value="...">`.
 * We use the next card opening (or the gallery's closing `</ul>`) as the
 * terminator so every block is self-contained.
 */
function splitCards(html: string): string[] {
  const opener = /<li\b[^>]*data-controller="collectable"[^>]*>/g
  const matches = [...html.matchAll(opener)]
  if (matches.length === 0) return []

  const blocks: string[] = []
  for (let i = 0; i < matches.length; i++) {
    const start = matches[i]!.index ?? 0
    const end = i + 1 < matches.length ? (matches[i + 1]!.index ?? html.length) : html.length
    blocks.push(html.slice(start, end))
  }
  return blocks
}

/**
 * Pull the `data-collectable-model-value="..."` JSON off a card block.
 *
 * The attribute value is HTML-entity-encoded (`&quot;` for `"`, etc.),
 * so we decode before JSON.parse. Returns undefined on any failure —
 * the caller drops the card silently in that case.
 */
function extractCollectableModel(block: string): CollectableModel | undefined {
  const match = block.match(/data-collectable-model-value="([^"]+)"/)
  if (!match?.[1]) return undefined
  const decoded = decodeEntities(match[1])
  try {
    return JSON.parse(decoded) as CollectableModel
  } catch {
    return undefined
  }
}

function extractLiveUrl(block: string): string | undefined {
  const candidates = [
    /class="figure-rollover__bt"[^>]*href="(https?:\/\/[^"]+)"/,
    /<a[^>]*target="_blank"[^>]*rel="[^"]*noopener[^"]*"[^>]*href="(https?:\/\/[^"]+)"/,
    /href="(https?:\/\/[^"]+)"[^>]*target="_blank"[^>]*rel="[^"]*noopener[^"]*"/,
  ]
  for (const pat of candidates) {
    const url = block.match(pat)?.[1]
    if (url && !url.includes('awwwards.com')) return url
  }
  return undefined
}

/**
 * Split Awwwards' flat tag list into the three buckets SANDCODE cares about.
 * Anything we don't recognize lands in styleTags — the harness can refine
 * classification later without losing the signal.
 */
function classifyTags(tags: readonly string[]): {
  industryTags: string[]
  techTags: string[]
  styleTags: string[]
} {
  const industryTags: string[] = []
  const techTags: string[] = []
  const styleTags: string[] = []

  for (const raw of tags) {
    const tag = raw.trim()
    if (!tag) continue
    const lower = tag.toLowerCase()

    if (INDUSTRY_TAGS.has(lower)) {
      industryTags.push(tag)
    } else if (TECH_TAGS.has(lower)) {
      techTags.push(tag)
    } else {
      styleTags.push(tag)
    }
  }

  return { industryTags, techTags, styleTags }
}

const INDUSTRY_TAGS = new Set<string>([
  'architecture',
  'art & illustration',
  'art',
  'business & corporate',
  'business',
  'culture & education',
  'culture',
  'design agencies',
  'e-commerce',
  'ecommerce',
  'events',
  'experimental',
  'fashion',
  'film & tv',
  'film',
  'food & drink',
  'games & entertainment',
  'games',
  'hotel/restaurant',
  'institutions',
  'luxury',
  'magazine/newspaper/blog',
  'magazine',
  'mobile & apps',
  'music & sound',
  'music',
  'photography',
  'portfolio',
  'promotional',
  'real estate',
  'social responsibility',
  'sports',
  'startups',
  'technology',
  'web & interactive',
])

const TECH_TAGS = new Set<string>([
  'react',
  'next.js',
  'nextjs',
  'vue.js',
  'vue',
  'svelte',
  'angular',
  'node.js',
  'nodejs',
  'express',
  'three.js',
  'threejs',
  'webgl',
  'gsap',
  'framer',
  'webflow',
  'wordpress',
  'shopify',
  'figma',
  'blender',
  'cinema 4d',
  'tailwind',
  'bootstrap',
  'laravel',
  'python',
  'docker',
  'firebase',
  'graphql',
  'astro',
  'sanity',
  'contentful',
  'strapi',
])

function decodeEntities(s: string): string {
  return s
    .replace(/&#(\d+);/g, (_, dec: string) => String.fromCodePoint(Number(dec)))
    .replace(/&#x([0-9a-fA-F]+);/g, (_, hex: string) =>
      String.fromCodePoint(Number.parseInt(hex, 16)),
    )
    .replace(/&quot;/g, '"')
    .replace(/&amp;/g, '&')
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/&apos;/g, "'")
    .replace(/&nbsp;/g, ' ')
}

function toIsoDate(epochSeconds: number | undefined): string | undefined {
  if (!epochSeconds || !Number.isFinite(epochSeconds)) return undefined
  const ms = epochSeconds * 1000
  if (ms < 0) return undefined
  return new Date(ms).toISOString().slice(0, 10)
}
