import embed, { type Result as VegaEmbedResult } from 'vega-embed'
import { validateInlineChartSpec } from '../lib/chart-spec-safety'
import { isOpaqueMessageOrigin, resolveParentTargetOrigin } from './chart-frame-message-origin.ts'

type ChartRenderMessage = {
  type: 'render-chart'
  requestId: number
  spec: Record<string, unknown>
}

type ChartCaptureMessage = {
  type: 'capture-chart'
  requestId: number
  scale?: number
}

type ChartPingMessage = {
  type: 'chart-frame-ping'
}

type ChartResponseMessage =
  | { type: 'chart-frame-ready' }
  | { type: 'chart-ready'; requestId: number; height: number }
  | { type: 'chart-error'; requestId: number; message: string }
  | { type: 'chart-capture'; requestId: number; dataUrl: string }
  | { type: 'chart-capture-error'; requestId: number; message: string }

const root = document.getElementById('chart-root')
let activeChartResizeObserver: ResizeObserver | null = null
// Keep the live vega view around so a later capture request can call
// `view.toImageURL('png')` without needing to re-render. Replaced on
// each successful render; cleared on teardown.
let activeView: VegaEmbedResult['view'] | null = null
let activeRenderGeneration = 0

function ignoreCleanupError(_error: unknown) {
  // Best-effort teardown only. A failed Vega cleanup should not break the
  // next chart render or surface a false chart failure to the parent frame.
}

function disposeView(view: VegaEmbedResult['view'] | null) {
  if (!view) return
  try {
    view.finalize()
  } catch (error) {
    ignoreCleanupError(error)
  }
}

function resetActiveChartState() {
  activeRenderGeneration += 1
  activeChartResizeObserver?.disconnect()
  activeChartResizeObserver = null
  disposeView(activeView)
  activeView = null
}

function denyChartResourceAccess(uri?: string) {
  const detail = uri ? `external resource "${uri}" is not allowed` : 'external resources are not allowed'
  return Promise.reject(new Error(`Chart rendering only supports local inline specs: ${detail}`))
}

function createRestrictedVegaLoader() {
  return {
    options: {},
    fileAccess: false,
    sanitize: (uri?: string) => denyChartResourceAccess(uri),
    load: (uri?: string) => denyChartResourceAccess(uri),
    file: (uri?: string) => denyChartResourceAccess(uri),
    http: (uri?: string) => denyChartResourceAccess(uri),
  }
}

function postToParent(message: ChartResponseMessage) {
  // Use a concrete referrer origin when Chromium provides one. Packaged
  // `file://` navigations can strip the referrer, and this sandboxed frame's
  // own origin is opaque, so `"*"` is the only deliverable target in that
  // case. Delivery is still scoped to `window.parent`; the parent validates
  // `event.source === iframe.contentWindow` before trusting the payload.
  window.parent.postMessage(message, resolveParentTargetOrigin(document.referrer))
}

function expectedParentOrigin() {
  try {
    return document.referrer
      ? new URL(document.referrer).origin
      : window.location.origin
  } catch {
    return window.location.origin
  }
}

function parentOriginMatches(eventOrigin: string) {
  const expectedOrigin = expectedParentOrigin()
  return eventOrigin === expectedOrigin
    || (isOpaqueMessageOrigin(eventOrigin) && isOpaqueMessageOrigin(expectedOrigin))
}

function shouldHandleParentMessage(event: MessageEvent) {
  return event.source === window.parent && parentOriginMatches(event.origin)
}

function measureChartHeight() {
  if (!root) return 0
  const svg = root.querySelector('svg')
  const renderedSvgHeight = svg instanceof SVGSVGElement
    ? Math.ceil(svg.getBoundingClientRect().height || svg.height.baseVal.value || 0)
    : 0
  const rootHeight = Math.ceil(root.getBoundingClientRect().height || 0)
  const documentHeight = Math.ceil(document.documentElement.scrollHeight || 0)
  const bodyHeight = Math.ceil(document.body.scrollHeight || 0)
  return Math.max(renderedSvgHeight, rootHeight, documentHeight, bodyHeight, 180)
}

async function renderChart(message: ChartRenderMessage) {
  if (!root) {
    postToParent({ type: 'chart-error', requestId: message.requestId, message: 'Chart frame root not found' })
    return
  }

  resetActiveChartState()
  const renderGeneration = activeRenderGeneration
  root.innerHTML = ''

  try {
    validateInlineChartSpec(message.spec)
    const result = await embed(root, message.spec as any, {
      actions: false,
      renderer: 'svg',
      loader: createRestrictedVegaLoader() as any,
    })
    if (renderGeneration !== activeRenderGeneration) {
      disposeView(result.view)
      return
    }
    activeView = result.view

    const reportHeight = () => {
      if (renderGeneration !== activeRenderGeneration) return
      const chartHeight = Math.max(measureChartHeight(), result.view.height() + 40)
      postToParent({ type: 'chart-ready', requestId: message.requestId, height: chartHeight })
    }

    result.view.resize()
    await result.view.runAsync()
    if (renderGeneration !== activeRenderGeneration) {
      disposeView(result.view)
      return
    }

    reportHeight()

    requestAnimationFrame(() => {
      reportHeight()
      requestAnimationFrame(reportHeight)
    })

    activeChartResizeObserver = new ResizeObserver(() => {
      reportHeight()
    })
    activeChartResizeObserver.observe(root)
  } catch (error: unknown) {
    if (renderGeneration !== activeRenderGeneration) return
    postToParent({
      type: 'chart-error',
      requestId: message.requestId,
      message: error instanceof Error ? error.message : 'Failed to render chart',
    })
  }
}

async function captureChart(message: ChartCaptureMessage) {
  if (!activeView) {
    postToParent({
      type: 'chart-capture-error',
      requestId: message.requestId,
      message: 'No active chart view to capture',
    })
    return
  }

  try {
    // Vega's `toImageURL` renders through an offscreen canvas regardless
    // of the visible renderer (we use SVG for display). Scale 2 keeps
    // it crisp on retina without bloating the PNG too much.
    const scale = typeof message.scale === 'number' && message.scale > 0 ? message.scale : 2
    const dataUrl = await activeView.toImageURL('png', scale)
    postToParent({ type: 'chart-capture', requestId: message.requestId, dataUrl })
  } catch (error: unknown) {
    postToParent({
      type: 'chart-capture-error',
      requestId: message.requestId,
      message: error instanceof Error ? error.message : 'Failed to capture chart PNG',
    })
  }
}

window.addEventListener('message', (event) => {
  if (!shouldHandleParentMessage(event)) return
  const data = event.data as ChartRenderMessage | ChartCaptureMessage | ChartPingMessage | undefined
  if (!data || typeof data !== 'object') return
  if (data.type === 'chart-frame-ping') {
    postToParent({ type: 'chart-frame-ready' })
    return
  }
  if (typeof data.requestId !== 'number') return
  if (data.type === 'render-chart' && (data as ChartRenderMessage).spec) {
    void renderChart(data as ChartRenderMessage)
    return
  }
  if (data.type === 'capture-chart') {
    void captureChart(data as ChartCaptureMessage)
  }
})

window.addEventListener('error', (event) => {
  postToParent({
    type: 'chart-error',
    requestId: -1,
    message: event.error?.message || event.message || 'Chart frame failed to load',
  })
})

window.addEventListener('unhandledrejection', (event) => {
  const reason = event.reason as { message?: string } | string | undefined
  postToParent({
    type: 'chart-error',
    requestId: -1,
    message: typeof reason === 'string' ? reason : reason?.message || 'Chart frame failed to initialize',
  })
})

document.addEventListener('securitypolicyviolation', (event) => {
  postToParent({
    type: 'chart-error',
    requestId: -1,
    message: `Chart frame blocked ${event.violatedDirective || 'a CSP directive'}`,
  })
})

window.addEventListener('beforeunload', () => {
  resetActiveChartState()
})

postToParent({ type: 'chart-frame-ready' })
