'use strict'

/**
 * VS Code Marketplace color-theme fetcher (main process).
 *
 * Resolves an extension's latest version via the (undocumented but stable)
 * gallery ExtensionQuery API, downloads the `.vsix` (a zip), and extracts the
 * color-theme JSON files it contributes. No theme code is ever executed — we
 * only read `package.json` + the referenced `*.json` theme files out of the
 * archive and hand their text back to the renderer to convert.
 *
 * Dependency-free on purpose: a `.vsix` is a plain zip, so we parse the central
 * directory and inflate just the entries we need with `zlib`. Avoids pulling a
 * zip library into the desktop bundle for a feature this small.
 */

const https = require('node:https')
const zlib = require('node:zlib')

const GALLERY_QUERY_URL = 'https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery'
const VSIX_ASSET_TYPE = 'Microsoft.VisualStudio.Services.VSIXPackage'
const MAX_VSIX_BYTES = 40 * 1024 * 1024 // 40 MB — themes are tiny; this is paranoia.
const MAX_REDIRECTS = 5
const REQUEST_TIMEOUT_MS = 20_000

const ID_RE = /^[\w-]+\.[\w-]+$/

/** Minimal HTTPS helper with redirect-following, timeout, and a size cap. */
function request(url, { method = 'GET', headers = {}, body = null, maxBytes = MAX_VSIX_BYTES } = {}, redirectsLeft = MAX_REDIRECTS) {
  return new Promise((resolve, reject) => {
    const req = https.request(url, { method, headers }, res => {
      const status = res.statusCode ?? 0

      if (status >= 300 && status < 400 && res.headers.location) {
        if (redirectsLeft <= 0) {
          res.resume()
          reject(new Error('Too many redirects.'))

          return
        }

        const next = new URL(res.headers.location, url).toString()
        res.resume()
        // Redirects to the CDN are plain GETs (drop the POST body).
        resolve(request(next, { method: 'GET', headers: { 'User-Agent': headers['User-Agent'] }, maxBytes }, redirectsLeft - 1))

        return
      }

      if (status < 200 || status >= 300) {
        res.resume()
        reject(new Error(`Request failed (${status}) for ${url}`))

        return
      }

      const chunks = []
      let total = 0

      res.on('data', chunk => {
        total += chunk.length

        if (total > maxBytes) {
          req.destroy()
          reject(new Error('Response exceeded the size limit.'))

          return
        }

        chunks.push(chunk)
      })
      res.on('end', () => resolve(Buffer.concat(chunks)))
    })

    req.on('error', reject)
    req.setTimeout(REQUEST_TIMEOUT_MS, () => req.destroy(new Error('Request timed out.')))

    if (body) {
      req.write(body)
    }

    req.end()
  })
}

/** Resolve `{ displayName, vsixUrl }` for the latest version of `id`. */
async function resolveExtension(id) {
  const json = await queryGallery({
    // FilterType 7 = ExtensionName (the full publisher.extension id).
    filters: [{ criteria: [{ filterType: 7, value: id }], pageNumber: 1, pageSize: 1 }],
    // Flags: IncludeFiles | IncludeVersionProperties | IncludeAssetUri |
    // IncludeCategoryAndTags | IncludeLatestVersionOnly = 914.
    flags: 914
  })
  const extension = json?.results?.[0]?.extensions?.[0]

  if (!extension) {
    throw new Error(`Extension "${id}" was not found on the Marketplace.`)
  }

  const version = extension.versions?.[0]

  if (!version) {
    throw new Error(`Extension "${id}" has no published versions.`)
  }

  const asset = (version.files ?? []).find(file => file.assetType === VSIX_ASSET_TYPE)
  const vsixUrl = asset?.source

  if (!vsixUrl) {
    throw new Error(`Could not find a downloadable package for "${id}".`)
  }

  return { displayName: extension.displayName || id, vsixUrl }
}

/** POST an ExtensionQuery payload and return the parsed gallery response. */
async function queryGallery(payload, { maxBytes = 4 * 1024 * 1024 } = {}) {
  const body = JSON.stringify(payload)
  const raw = await request(GALLERY_QUERY_URL, {
    method: 'POST',
    headers: {
      Accept: 'application/json;api-version=3.0-preview.1',
      'Content-Type': 'application/json',
      'Content-Length': Buffer.byteLength(body),
      'User-Agent': 'Hermes-Desktop'
    },
    body,
    maxBytes
  })

  return JSON.parse(raw.toString('utf8'))
}

/**
 * Search the Marketplace for color-theme extensions. With an empty query this
 * returns the most-installed themes; with a query it's a full-text search
 * scoped to the Themes category. Returns lightweight cards (no download).
 */
/**
 * The "Themes" category also contains file-icon and product-icon themes (the
 * gallery has no color-only category). We can't see an extension's actual
 * contributions without downloading it, so filter the obvious icon packs out by
 * tag + name/description. Color themes that also ship icons are rare; worst case
 * a user installs them by exact id from settings.
 */
function looksLikeIconTheme(extension) {
  const tags = (extension.tags ?? []).map(tag => String(tag).toLowerCase())

  if (tags.includes('icon-theme') || tags.includes('product-icon-theme')) {
    return true
  }

  const text = `${extension.displayName ?? ''} ${extension.shortDescription ?? ''}`.toLowerCase()

  return /\b(icon theme|file icons?|product icons?|icon pack|fileicons)\b/.test(text)
}

async function searchMarketplaceThemes(query, limit = 20) {
  const text = String(query || '').trim()
  const pageSize = Math.min(Math.max(Number(limit) || 20, 1), 50)

  // FilterType: 8=Target, 5=Category, 10=SearchText, 12=ExcludeWithFlags.
  const criteria = [
    { filterType: 8, value: 'Microsoft.VisualStudio.Code' },
    { filterType: 5, value: 'Themes' },
    { filterType: 12, value: '4096' } // Exclude unpublished (Unpublished = 0x1000).
  ]

  if (text) {
    criteria.push({ filterType: 10, value: text })
  }

  const json = await queryGallery({
    // Over-fetch so the icon-theme filter below still leaves a full page.
    filters: [{ criteria, pageNumber: 1, pageSize: Math.min(pageSize * 2, 50), sortBy: 4, sortOrder: 0 }],
    // IncludeStatistics (0x100) | IncludeLatestVersionOnly (0x200) | IncludeCategoryAndTags (0x4).
    flags: 772
  })

  const extensions = json?.results?.[0]?.extensions ?? []

  return extensions
    .filter(extension => !looksLikeIconTheme(extension))
    .slice(0, pageSize)
    .map(extension => {
      const publisherName = extension.publisher?.publisherName ?? ''
      const installStat = (extension.statistics ?? []).find(stat => stat.statisticName === 'install')

      return {
        extensionId: `${publisherName}.${extension.extensionName}`,
        displayName: extension.displayName || extension.extensionName,
        publisher: extension.publisher?.displayName || publisherName,
        description: extension.shortDescription || '',
        installs: Math.round(installStat?.value ?? 0)
      }
    })
}

// ─── Minimal zip reader ─────────────────────────────────────────────────────

function findEndOfCentralDirectory(buf) {
  // EOCD signature 0x06054b50, scanning back from the end (comment is rare).
  for (let i = buf.length - 22; i >= 0; i--) {
    if (buf.readUInt32LE(i) === 0x06054b50) {
      return i
    }
  }

  throw new Error('Not a valid zip archive (no end-of-central-directory).')
}

/** Parse the central directory into a name → record map. */
function readCentralDirectory(buf) {
  const eocd = findEndOfCentralDirectory(buf)
  const count = buf.readUInt16LE(eocd + 10)
  let offset = buf.readUInt32LE(eocd + 16)
  const records = new Map()

  for (let i = 0; i < count; i++) {
    if (buf.readUInt32LE(offset) !== 0x02014b50) {
      break
    }

    const method = buf.readUInt16LE(offset + 10)
    const compressedSize = buf.readUInt32LE(offset + 20)
    const nameLen = buf.readUInt16LE(offset + 28)
    const extraLen = buf.readUInt16LE(offset + 30)
    const commentLen = buf.readUInt16LE(offset + 32)
    const localOffset = buf.readUInt32LE(offset + 42)
    const name = buf.toString('utf8', offset + 46, offset + 46 + nameLen)

    records.set(name, { method, compressedSize, localOffset })
    offset += 46 + nameLen + extraLen + commentLen
  }

  return records
}

/** Inflate a single entry to a string. */
function extractEntry(buf, record) {
  // The local header's name/extra lengths can differ from the central record,
  // so re-read them here to locate the compressed payload.
  if (buf.readUInt32LE(record.localOffset) !== 0x04034b50) {
    throw new Error('Corrupt zip: bad local file header.')
  }

  const nameLen = buf.readUInt16LE(record.localOffset + 26)
  const extraLen = buf.readUInt16LE(record.localOffset + 28)
  const dataStart = record.localOffset + 30 + nameLen + extraLen
  const data = buf.subarray(dataStart, dataStart + record.compressedSize)

  // 0 = stored, 8 = deflate. Theme files are one or the other.
  return record.method === 0 ? data.toString('utf8') : zlib.inflateRawSync(data).toString('utf8')
}

/** Normalize a package.json theme path to its zip entry name. */
function themeEntryName(themePath) {
  const clean = String(themePath).replace(/^\.\//, '').replace(/^\//, '')

  return `extension/${clean}`
}

/** Extract every contributed color theme from a `.vsix` buffer. */
function extractThemes(vsixBuffer) {
  const records = readCentralDirectory(vsixBuffer)
  const pkgRecord = records.get('extension/package.json')

  if (!pkgRecord) {
    throw new Error('Package manifest missing from the extension.')
  }

  const pkg = JSON.parse(extractEntry(vsixBuffer, pkgRecord))
  const contributed = pkg?.contributes?.themes

  if (!Array.isArray(contributed) || contributed.length === 0) {
    return []
  }

  const themes = []

  for (const entry of contributed) {
    if (!entry?.path) {
      continue
    }

    const record = records.get(themeEntryName(entry.path))

    if (!record) {
      continue
    }

    try {
      themes.push({
        label: entry.label || entry.id || pkg.displayName || pkg.name || 'VS Code Theme',
        uiTheme: entry.uiTheme,
        contents: extractEntry(vsixBuffer, record)
      })
    } catch {
      // Skip an entry we can't inflate rather than failing the whole install.
    }
  }

  return themes
}

/**
 * Public entry: resolve, download, and extract color themes for `id`
 * (`publisher.extension`). Returns `{ extensionId, displayName, themes }`.
 */
async function fetchMarketplaceThemes(id) {
  const trimmed = String(id || '').trim()

  if (!ID_RE.test(trimmed)) {
    throw new Error('Expected a Marketplace id like "publisher.extension".')
  }

  const { displayName, vsixUrl } = await resolveExtension(trimmed)
  const vsix = await request(vsixUrl, { headers: { 'User-Agent': 'Hermes-Desktop' } })
  const themes = extractThemes(vsix)

  return { extensionId: trimmed, displayName, themes }
}

module.exports = {
  fetchMarketplaceThemes,
  searchMarketplaceThemes,
  extractThemes,
  readCentralDirectory,
  __testing: { themeEntryName, looksLikeIconTheme }
}
