import { app, net, shell, type WebContents } from 'electron'
import { stat } from 'fs/promises'
import { join, normalize, dirname } from 'path'
import { UPDATE_HOST, RELEASE_API } from './config'
import { streamVerifiedFile } from './lib/verifiedDownload'
import { IpcChannels, type AppUpdateInfo, type AppUpdateDownload } from '@shared/ipc'

// --- Update source -----------------------------------------------------------
// The Gitea repo whose Releases host the AeroFetch installers lives in
// config.ts (CC11) with the other build/host constants. The updater reads the
// repo's latest release over the public REST API and downloads the installer
// asset attached to it.

// Security: only ever download or execute a file served by the trusted update
// host over HTTPS. downloadUrl comes from the release JSON, and Gitea 302-redirects
// release downloads to its attachment store — so this is re-checked on EVERY
// redirect hop (see downloadAppUpdate), which is what stops a tampered/MITM'd
// response from bouncing us off the trusted host.
export function isTrustedDownloadUrl(url: string): boolean {
  try {
    const u = new URL(url)
    return u.protocol === 'https:' && u.host === new URL(UPDATE_HOST).host
  } catch {
    return false
  }
}

/** Where downloaded installers are written (and the only dir we'll run one from). */
function updateDir(): string {
  return app.getPath('temp')
}

/**
 * Parse 'v1.2.3' / '1.2.3-rc.1' into the comparable numeric components of the
 * release CORE — the x.y.z before any '-prerelease' or '+build' suffix. Dropping
 * the suffix means a prerelease (e.g. 0.5.0-rc.1) never sorts ABOVE its final
 * release (0.5.0): at most it compares equal, so we won't offer a prerelease as
 * an "upgrade" over the same shipped version.
 */
function parseVersion(v: string): number[] {
  // split() always yields at least one element; `?? ''` only satisfies the type
  // checker (noUncheckedIndexedAccess) for the [0] access.
  const core = v.replace(/^v/i, '').split(/[-+]/)[0] ?? ''
  return core
    .split('.')
    .map((p) => parseInt(p, 10))
    .filter((n) => !Number.isNaN(n))
}

/** Component-wise numeric compare: -1 if a<b, 0 if equal, 1 if a>b. */
export function compareVersions(a: string, b: string): number {
  const pa = parseVersion(a)
  const pb = parseVersion(b)
  const len = Math.max(pa.length, pb.length)
  for (let i = 0; i < len; i++) {
    const x = pa[i] ?? 0
    const y = pb[i] ?? 0
    if (x !== y) return x < y ? -1 : 1
  }
  return 0
}

interface GiteaAsset {
  name: string
  browser_download_url: string
}
interface GiteaRelease {
  tag_name: string
  body?: string
  html_url?: string
  assets?: GiteaAsset[]
}

/**
 * Pull the SHA-256 hex out of a checksum file — a bare digest, `sha256sum`
 * output (`<hash>  file`), or PowerShell Get-FileHash (uppercase). Returns the
 * lowercase digest, or null if there's no standalone 64-char hex token (so a
 * longer run like a sha512 digest is ignored rather than sliced).
 *
 * When `fileName` is given (the installer asset's name), a multi-line / combined
 * checksum file is matched line-by-line and the hash on the line naming THIS
 * asset wins — so a `<asset>.sha256` that happens to list several files can't
 * verify the installer against the wrong line's hash (B3). Falls back to the
 * first standalone digest for bare single-hash files (no filename present).
 */
export function extractSha256(text: string, fileName?: string): string | null {
  const hashRe = /\b[a-f0-9]{64}\b/i
  if (fileName) {
    const base = fileName.toLowerCase()
    for (const line of text.split(/\r?\n/)) {
      const m = line.match(hashRe)
      if (!m) continue
      // The filename is the last whitespace-delimited token on a sha256sum /
      // Get-FileHash line (optionally with a '*' binary-mode marker). Match it
      // exactly — a loose substring would let 'App.exe' match a 'MyApp.exe' line.
      const last = (line.trim().split(/\s+/).pop() ?? '').replace(/^\*/, '')
      if (last.toLowerCase() === base) return m[0].toLowerCase()
    }
  }
  const m = text.match(hashRe)
  return m ? m[0].toLowerCase() : null
}

/** Pick the Windows installer asset — prefer the NSIS "Setup" .exe, else any .exe. */
function pickInstaller(assets: GiteaAsset[]): GiteaAsset | undefined {
  const exes = assets.filter((a) => /\.exe$/i.test(a.name))
  return exes.find((a) => /setup/i.test(a.name)) ?? exes[0]
}

/** Query the configured repo's latest release and compare it to the running app. */
export async function checkForAppUpdate(): Promise<AppUpdateInfo> {
  const currentVersion = app.getVersion()
  // Bound the check so a stalled/unreachable server can't hang the UI spinner
  // indefinitely — mirrors the timeouts on the yt-dlp calls.
  const controller = new AbortController()
  const timer = setTimeout(() => controller.abort(), 15_000)
  try {
    const res = await fetch(RELEASE_API, {
      headers: { Accept: 'application/json' },
      // The API lives at an exact pinned URL and answers 200 directly. Refusing
      // redirects keeps the check pinned to that origin; a stray redirect (e.g. to
      // a sign-in page) fails safe into the catch below rather than being followed.
      redirect: 'error',
      signal: controller.signal
    })
    if (!res.ok) {
      const auth = res.status === 401 || res.status === 403 || res.status === 404
      return {
        ok: false,
        available: false,
        currentVersion,
        error: auth
          ? 'Could not reach the update server (it may require sign-in for anonymous access).'
          : `Update check failed (HTTP ${res.status}).`
      }
    }
    const rel = (await res.json()) as GiteaRelease
    const latestVersion = (rel.tag_name ?? '').replace(/^v/i, '')
    const asset = pickInstaller(rel.assets ?? [])
    const available = latestVersion !== '' && compareVersions(latestVersion, currentVersion) > 0
    return {
      ok: true,
      available,
      currentVersion,
      latestVersion,
      notes: rel.body?.trim() || undefined,
      // Only ever hand the OS browser a release page on our own trusted host.
      htmlUrl: rel.html_url && isTrustedDownloadUrl(rel.html_url) ? rel.html_url : undefined,
      downloadUrl: asset?.browser_download_url,
      assetName: asset?.name
    }
  } catch (e) {
    const timedOut = e instanceof Error && e.name === 'AbortError'
    return {
      ok: false,
      available: false,
      currentVersion,
      error: timedOut
        ? 'Update check timed out — the server took too long to respond.'
        : e instanceof Error
          ? e.message
          : String(e)
    }
  } finally {
    clearTimeout(timer)
  }
}

// The in-flight installer download's canceller, set while a download runs and
// cleared on settle. During the pre-download checksum phase it just flags the
// cancel; during the download phase it routes through `finish()` (abort the
// request + clean up the partial). Each phase clears the slot only if it still
// owns it, so a superseded download's late settle can't knock out a newer
// download's canceller (same ownership rule as L140's releaseActive). (B6)
let cancelActiveDownload: (() => void) | null = null

/** Abort the in-flight app-update installer download, if any (the update card's Cancel). */
export function cancelAppUpdate(): void {
  cancelActiveDownload?.()
}

// Integrity: every release MUST attach a checksum asset named `<installer>.sha256`
// (e.g. AeroFetch-Setup-0.5.0.exe.sha256) whose contents include the installer's
// lowercase SHA-256 hex — bare, or in `sha256sum`/`Get-FileHash` form. We refuse
// to run an installer we can't verify against it. Note this is INTEGRITY +
// defence-in-depth (corruption, truncation, tampering-after-publish, a redirect
// bounced to other storage), NOT protection against a fully compromised host —
// that host serves both files, so only Authenticode signing defends against it.
const REQUIRE_CHECKSUM: boolean = true

/**
 * GET a small text resource from the trusted host, re-validating the host on
 * every redirect hop (same discipline as the installer download) and capping the
 * body so a hostile/huge response can't exhaust memory. Used for the .sha256 file.
 */
function fetchTrustedText(
  url: string,
  maxBytes = 64 * 1024,
  timeoutMs = 15_000
): Promise<{ ok: true; text: string } | { ok: false; status?: number; error: string }> {
  return new Promise((resolve) => {
    let settled = false
    const done = (
      r: { ok: true; text: string } | { ok: false; status?: number; error: string }
    ): void => {
      if (settled) return
      settled = true
      clearTimeout(timer)
      if (!r.ok) request.abort()
      resolve(r)
    }
    const request = net.request({ url, redirect: 'manual' })
    const timer = setTimeout(() => done({ ok: false, error: 'timed out' }), timeoutMs)
    request.on('redirect', (_s, _m, redirectUrl) => {
      if (!isTrustedDownloadUrl(redirectUrl)) {
        done({ ok: false, error: 'redirect to an untrusted location' })
        return
      }
      request.followRedirect()
    })
    request.on('response', (response) => {
      const status = response.statusCode
      if (status < 200 || status >= 300) {
        done({ ok: false, status, error: `HTTP ${status}` })
        return
      }
      const chunks: Buffer[] = []
      let size = 0
      response.on('data', (c: Buffer) => {
        size += c.length
        if (size > maxBytes) done({ ok: false, error: 'checksum file too large' })
        else chunks.push(c)
      })
      response.on('end', () => done({ ok: true, text: Buffer.concat(chunks).toString('utf8') }))
      response.on('aborted', () => done({ ok: false, error: 'interrupted' }))
      response.on('error', (e: Error) => done({ ok: false, error: e.message }))
    })
    request.on('error', (e: Error) => done({ ok: false, error: e.message }))
    request.end()
  })
}

/** Stream the installer to a temp file, pushing progress events to the renderer. */
export async function downloadAppUpdate(wc: WebContents, url: string): Promise<AppUpdateDownload> {
  if (!isTrustedDownloadUrl(url)) {
    return { ok: false, error: 'Refused to download from an untrusted location.' }
  }

  // Derive a safe .exe filename from the URL; fall back to a fixed name.
  const urlName = decodeURIComponent(new URL(url).pathname.split('/').pop() || '')
  const safeName = /^[\w.\- ]+\.exe$/i.test(urlName) ? urlName : 'AeroFetch-Setup.exe'
  const filePath = join(updateDir(), safeName)

  // Resolve the published checksum up front (tiny, fails fast) so we never pull a
  // ~300 MB installer we'd then refuse to run. The .sha256 sits next to the asset,
  // so its URL is the installer URL + '.sha256' — still on the host-pinned origin.
  const checksumUrl = (() => {
    const u = new URL(url)
    u.pathname += '.sha256'
    return u.toString()
  })()
  // The renderer's Cancel button is live from the moment this is invoked, so the
  // checksum phase must be cancelable too — without this, a Cancel during the
  // fetch is a silent no-op and the full installer download proceeds anyway (B6).
  // This early canceller only flags; nothing is on disk yet, so there's no teardown.
  let canceledEarly = false
  const earlyCancel = (): void => {
    canceledEarly = true
  }
  cancelActiveDownload = earlyCancel
  const settleEarly = (result: AppUpdateDownload): AppUpdateDownload => {
    if (cancelActiveDownload === earlyCancel) cancelActiveDownload = null
    return result
  }

  const sum = await fetchTrustedText(checksumUrl)
  if (canceledEarly) return settleEarly({ ok: false, error: 'Update download canceled.' })
  let expectedSha: string | null = null
  if (sum.ok) {
    expectedSha = extractSha256(sum.text, safeName)
    if (!expectedSha) {
      return settleEarly({ ok: false, error: "The update's checksum file is malformed." })
    }
  } else if (sum.status === 404) {
    if (REQUIRE_CHECKSUM) {
      return settleEarly({
        ok: false,
        error: `This release has no checksum (${safeName}.sha256) attached — refusing to install an unverified update.`
      })
    }
  } else {
    return settleEarly({ ok: false, error: `Couldn't fetch the update's checksum — ${sum.error}.` })
  }

  // The streaming itself — redirects, hashing, timeout, truncation, teardown —
  // lives in streamVerifiedFile (lib/verifiedDownload, CL4). This function stays a
  // linear pre-flight: trust-check → checksum resolution → stream → done. The cancel
  // slot's B6 ownership dance stays here, next to the module state it guards:
  // ownership moves from the checksum-phase earlyCancel above to the stream's
  // cancel, and is released only if this download still owns the slot.
  return streamVerifiedFile({
    url,
    filePath,
    expectedSha,
    // Re-check the trusted host on every redirect hop (Gitea 302s to its attachment store).
    isTrusted: isTrustedDownloadUrl,
    onProgress: (progress) => {
      if (!wc.isDestroyed()) wc.send(IpcChannels.appUpdateProgress, progress)
    },
    onCancelReady: (cancel) => {
      cancelActiveDownload = cancel
    },
    onSettled: (cancel) => {
      if (cancelActiveDownload === cancel) cancelActiveDownload = null
    },
    // Preserve the update card's exact wording (the generic defaults are for other callers).
    messages: {
      checksumMismatch:
        'The downloaded update failed its checksum check — it may be corrupt or tampered with. Nothing was installed.',
      canceled: 'Update download canceled.'
    }
  })
}

/** Launch a freshly-downloaded installer, then quit so it can replace the app. */
export async function runAppUpdate(filePath: string): Promise<{ ok: boolean; error?: string }> {
  try {
    // Defence in depth: only run a .exe that sits DIRECTLY in our own temp dir,
    // never an arbitrary path handed over IPC. Compare the parent directory by
    // equality — startsWith() is defeated by a sibling like `<temp>_evil\x.exe`.
    const expectedDir = normalize(updateDir()).toLowerCase()
    const target = normalize(filePath)
    if (dirname(target).toLowerCase() !== expectedDir || !/\.exe$/i.test(target)) {
      return { ok: false, error: 'Refused to run an unexpected file.' }
    }
    await stat(target) // throws if the file is missing
    // Hand the installer to the OS via ShellExecute, which (unlike a raw
    // CreateProcess/spawn) honors the NSIS elevation manifest — needed if the
    // build ever flips to perMachine. openPath resolves once the launch is
    // initiated, so the installer process already exists when we quit.
    const err = await shell.openPath(target)
    if (err) return { ok: false, error: err }
    // Quit on the next tick so this IPC response reaches the renderer first; the
    // launched installer is independent of our process and survives the quit.
    setImmediate(() => app.quit())
    return { ok: true }
  } catch (e) {
    return { ok: false, error: e instanceof Error ? e.message : String(e) }
  }
}
