/* eslint-disable react-hooks/rules-of-hooks */

import {
	BaseBoxShapeUtil,
	HTMLContainer,
	TLEmbedShape,
	TLEmbedShapeProps,
	TLResizeInfo,
	embedShapeMigrations,
	embedShapeProps,
	lerp,
	resizeBox,
	toDomPrecision,
	useColorMode,
	useIsEditing,
	useSvgExportContext,
	useValue,
} from '@tldraw/editor'
import {
	DEFAULT_EMBED_DEFINITIONS,
	DefaultEmbedConfig,
	TLEmbedDefinition,
	TLEmbedShapePermissions,
	embedShapePermissionDefaults,
	unknownEmbedShapePermissionOverrides,
} from '../../defaultEmbedDefinitions'
import { TLEmbedResult, getCorrectedEmbedSize, getEmbedInfo } from '../../utils/embeds/embeds'
import { BookmarkShapeComponent } from '../bookmark/BookmarkShapeUtil'
import { ShapeOptionsWithDisplayValues, getDisplayValues } from '../shared/getDisplayValues'
import { getRotatedBoxShadow } from '../shared/rotated-box-shadow'

/** @public */
export interface EmbedShapeUtilDisplayValues {
	showShadow: boolean
}

/** @public */
export interface EmbedShapeOptions extends ShapeOptionsWithDisplayValues<
	TLEmbedShape,
	EmbedShapeUtilDisplayValues
> {
	/** The embed definitions to use for this shape util. */
	readonly embedDefinitions: readonly TLEmbedDefinition[]
	/**
	 * Per-embed configuration, keyed by embed type. Passed to each definition's `toEmbedUrl` when
	 * building its embed URL — for example, an API key for the default Google Maps embed:
	 *
	 * ```ts
	 * EmbedShapeUtil.configure({ embedConfig: { google_maps: { apiKey: '...' } } })
	 * ```
	 */
	readonly embedConfig?: DefaultEmbedConfig & Record<string, unknown>
}

const getSandboxPermissions = (permissions: TLEmbedShapePermissions) => {
	return Object.entries(permissions)
		.filter(([_perm, isEnabled]) => isEnabled)
		.map(([perm]) => perm)
		.join(' ')
}

/** @public */
export class EmbedShapeUtil extends BaseBoxShapeUtil<TLEmbedShape> {
	static override type = 'embed' as const
	static override props = embedShapeProps
	static override migrations = embedShapeMigrations

	override options: EmbedShapeOptions = {
		embedDefinitions: DEFAULT_EMBED_DEFINITIONS,
		embedConfig: {},
		getDefaultDisplayValues(): EmbedShapeUtilDisplayValues {
			return {
				showShadow: true,
			}
		},
		getCustomDisplayValues(): Partial<EmbedShapeUtilDisplayValues> {
			return {}
		},
	}

	override canEditWhileLocked(shape: TLEmbedShape) {
		const result = this.getEmbedDefinition(shape.props.url)
		if (!result) return true
		return result.definition.canEditWhileLocked ?? true
	}

	private static legacyEmbedDefinitions: readonly TLEmbedDefinition[] | null = null

	/** @deprecated - Use `EmbedShapeUtil.configure({ embedDefinitions: [...] })` instead. */
	static setEmbedDefinitions(embedDefinitions: readonly TLEmbedDefinition[]) {
		EmbedShapeUtil.legacyEmbedDefinitions = embedDefinitions
	}

	private getEmbedDefs(): readonly TLEmbedDefinition[] {
		return EmbedShapeUtil.legacyEmbedDefinitions ?? this.options.embedDefinitions
	}

	getEmbedDefinitions(): readonly TLEmbedDefinition[] {
		return this.getEmbedDefs()
	}

	getEmbedDefinition(url: string): TLEmbedResult {
		return getEmbedInfo(this.getEmbedDefs(), url, this.options.embedConfig)
	}

	/**
	 * Resolve the embed's true aspect ratio (for definitions with `sizeToContentAspectRatio`) and
	 * correct the shape's size to match. The dimensions come from the URL's OpenGraph metadata via
	 * the editor's url asset handler (the same "unfurl" path bookmarks use). The correction is
	 * applied without adding to the undo history.
	 */
	async resolveAspectRatio(shape: TLEmbedShape) {
		const { url } = shape.props
		const definition = this.getEmbedDefinition(url)?.definition
		if (!definition?.sizeToContentAspectRatio || !url) return

		const resolvedRatio = await this.getContentAspectRatio(url)

		// The editor may have been torn down while the metadata was in flight.
		if (this.editor.isDisposed) return

		const latest = this.editor.getShape<TLEmbedShape>(shape.id)
		// Bail if the shape is gone or its url changed while we were awaiting: a later run for the
		// new url will handle it, and we must not apply this url's ratio to a different video.
		if (!latest || latest.props.url !== url) return

		const corrected = getCorrectedEmbedSize({
			w: latest.props.w,
			h: latest.props.h,
			resolvedRatio,
		})
		if (!corrected) return

		// `onResize` enforces a per-axis minimum (inflated by the aspect ratio when locked), so a very
		// tall/narrow target could have one axis clamped while the other isn't — that would break the
		// ratio and re-letterboxes the very content we're correcting. Scale the whole target up so it
		// clears both floors, which preserves the ratio at the cost of a slightly larger box only in
		// the extreme (still far smaller than leaving one axis un-corrected).
		const { minWidth, minHeight } = this.getResizeMinBounds(latest)
		const clearFloor = Math.max(1, minWidth / corrected.w, minHeight / corrected.h)
		const targetW = corrected.w * clearFloor
		const targetH = corrected.h * clearFloor

		// Let the editor resize the shape: by default it scales around the shape's page-space center
		// and along its own rotation axis, so the center stays fixed even if the embed was rotated
		// before the ratio resolved. No need to compute x/y by hand.
		//
		// We pass `isAspectRatioLocked: false` because embeds like Vimeo lock their aspect ratio,
		// which would otherwise make the editor merge our non-uniform scale into a single uniform
		// factor — leaving the wrong ratio and defeating the whole correction.
		this.editor.run(
			() => {
				this.editor.resizeShape(
					latest.id,
					{
						x: targetW / latest.props.w,
						y: targetH / latest.props.h,
					},
					{ isAspectRatioLocked: false }
				)
			},
			{ history: 'ignore' }
		)
	}

	/**
	 * Resolve the content's aspect ratio from the URL's OpenGraph metadata, fetched through the
	 * editor's url asset handler (the "unfurl" service). Returns `undefined` if no usable dimensions
	 * are available (e.g. no unfurl service is configured, or the page reports no image size).
	 */
	private async getContentAspectRatio(url: string): Promise<number | undefined> {
		try {
			const asset = await this.editor.getAssetForExternalContent({ type: 'url', url })
			if (!asset || asset.type !== 'bookmark') return undefined
			const width = asset.meta.imageWidth
			const height = asset.meta.imageHeight
			if (typeof width === 'number' && typeof height === 'number' && width > 0 && height > 0) {
				return width / height
			}
		} catch {
			// Resolving the aspect ratio is best-effort: never let a metadata fetch failure surface.
		}
		return undefined
	}

	override getText(shape: TLEmbedShape) {
		return shape.props.url
	}

	override getAriaDescriptor(shape: TLEmbedShape) {
		const embedInfo = this.getEmbedDefinition(shape.props.url)
		return embedInfo?.definition.title
	}

	override hideSelectionBoundsFg(shape: TLEmbedShape) {
		return !this.canResize(shape)
	}
	override canEdit(shape: TLEmbedShape) {
		return true
	}
	override canResize(shape: TLEmbedShape) {
		return this.getEmbedDefinition(shape.props.url)?.definition?.doesResize ?? true
	}
	override canEditInReadonly(shape: TLEmbedShape) {
		return true
	}

	override getDefaultProps(): TLEmbedShape['props'] {
		return {
			w: 300,
			h: 300,
			url: '',
		}
	}

	override getGeometry(shape: TLEmbedShape) {
		return super.getGeometry(shape)
	}

	override isAspectRatioLocked(shape: TLEmbedShape) {
		const embedInfo = this.getEmbedDefinition(shape.props.url)
		return embedInfo?.definition.isAspectRatioLocked ?? false
	}

	/**
	 * The minimum width/height a resize of `shape` is allowed to reach. When the embed's aspect ratio
	 * is locked, the longer axis's minimum is inflated by the current aspect ratio so that shrinking
	 * to the floor keeps the shorter axis at or above the base minimum.
	 */
	private getResizeMinBounds(shape: TLEmbedShape) {
		const embedInfo = this.getEmbedDefinition(shape.props.url)
		let minWidth = embedInfo?.definition.minWidth ?? 200
		let minHeight = embedInfo?.definition.minHeight ?? 200
		if (this.isAspectRatioLocked(shape)) {
			// Neither the width nor the height can be less than the base minimum.
			const aspectRatio = shape.props.w / shape.props.h
			if (aspectRatio > 1) {
				// Landscape
				minWidth *= aspectRatio
			} else {
				// Portrait
				minHeight /= aspectRatio
			}
		}
		return { minWidth, minHeight }
	}

	override onResize(shape: TLEmbedShape, info: TLResizeInfo<TLEmbedShape>) {
		return resizeBox(shape, info, this.getResizeMinBounds(shape))
	}

	override component(shape: TLEmbedShape) {
		const svgExport = useSvgExportContext()
		const { w, h, url } = shape.props
		const isEditing = useIsEditing(shape.id)
		const colorMode = useColorMode()
		const dv = getDisplayValues(this, shape, colorMode)

		const embedInfo = this.getEmbedDefinition(url)

		const isHoveringWhileEditingSameShape = useValue(
			'is hovering',
			() => {
				const { editingShapeId, hoveredShapeId } = this.editor.getCurrentPageState()

				if (editingShapeId && hoveredShapeId !== editingShapeId) {
					const editingShape = this.editor.getShape(editingShapeId)
					if (editingShape && this.editor.isShapeOfType(editingShape, 'embed')) {
						return true
					}
				}

				return false
			},
			[]
		)

		const pageRotation = this.editor.getShapePageTransform(shape)!.rotation()

		if (svgExport) {
			// for SVG exports, we show a blank embed
			return (
				<HTMLContainer className="tl-embed-container" id={shape.id}>
					<div
						className="tl-embed"
						style={{
							border: 0,
							boxShadow: dv.showShadow ? getRotatedBoxShadow(pageRotation) : 'none',
							borderRadius: embedInfo?.definition.overrideOutlineRadius ?? 8,
							background: embedInfo?.definition.backgroundColor ?? 'var(--tl-color-background)',
							width: w,
							height: h,
						}}
					/>
				</HTMLContainer>
			)
		}

		const isInteractive = isEditing || isHoveringWhileEditingSameShape

		// Prevent nested embedding of tldraw
		const isIframe =
			typeof window !== 'undefined' && (window !== window.top || window.self !== window.parent)
		if (isIframe && embedInfo?.definition.type === 'tldraw') return null

		const sandbox = getSandboxPermissions({
			...embedShapePermissionDefaults,
			...(embedInfo
				? (embedInfo.definition.overridePermissions ?? {})
				: unknownEmbedShapePermissionOverrides),
		})

		if (embedInfo?.definition.type === 'github_gist') {
			const idFromGistUrl = embedInfo.url.split('/').pop()
			if (!idFromGistUrl) throw Error('No gist id!')

			// Gist embeds use srcDoc, so we must disable allow-same-origin. Otherwise
			// the embedded script shares the parent's origin and can escape the sandbox.
			const gistSandbox = getSandboxPermissions({
				...embedShapePermissionDefaults,
				...(embedInfo?.definition?.overridePermissions ?? {}),
				'allow-same-origin': false,
			})

			return (
				<HTMLContainer className="tl-embed-container" id={shape.id}>
					<Gist
						id={idFromGistUrl}
						sandbox={gistSandbox}
						width={toDomPrecision(w)!}
						height={toDomPrecision(h)!}
						isInteractive={isInteractive}
						pageRotation={pageRotation}
						showShadow={dv.showShadow}
					/>
				</HTMLContainer>
			)
		}

		const iframeSrc = embedInfo?.embedUrl ?? url

		return (
			<HTMLContainer className="tl-embed-container" id={shape.id}>
				{iframeSrc ? (
					<iframe
						className="tl-embed"
						sandbox={sandbox}
						src={iframeSrc}
						width={toDomPrecision(w)}
						height={toDomPrecision(h)}
						draggable={false}
						// eslint-disable-next-line @typescript-eslint/no-deprecated
						frameBorder="0"
						referrerPolicy="strict-origin-when-cross-origin"
						tabIndex={isEditing ? 0 : -1}
						allowFullScreen
						style={{
							border: 0,
							// Fill the container exactly. Relying on the fixed pixel width/height causes
							// the flex-centered iframe to be rounded independently from its box at
							// fractional sizes/zooms, leaving a 1-2px background sliver on one edge.
							width: '100%',
							height: '100%',
							pointerEvents: isInteractive ? 'auto' : 'none',
							// Fix for safari <https://stackoverflow.com/a/49150908>
							zIndex: isInteractive ? '' : '-1',
							boxShadow: dv.showShadow ? getRotatedBoxShadow(pageRotation) : 'none',
							borderRadius: embedInfo?.definition?.overrideOutlineRadius ?? 8,
							background: embedInfo?.definition?.backgroundColor,
						}}
					/>
				) : (
					<BookmarkShapeComponent
						url={url}
						h={h}
						rotation={pageRotation}
						assetId={null}
						showImageContainer={false}
					/>
				)}
			</HTMLContainer>
		)
	}

	override getIndicatorPath(shape: TLEmbedShape): Path2D {
		const path = new Path2D()
		path.rect(0, 0, shape.props.w, shape.props.h)
		return path
	}

	override getInterpolatedProps(
		startShape: TLEmbedShape,
		endShape: TLEmbedShape,
		t: number
	): TLEmbedShapeProps {
		return {
			...(t > 0.5 ? endShape.props : startShape.props),
			w: lerp(startShape.props.w, endShape.props.w, t),
			h: lerp(startShape.props.h, endShape.props.h, t),
		}
	}
}

function Gist({
	id,
	sandbox,
	isInteractive,
	width,
	height,
	style,
	pageRotation,
	showShadow,
}: {
	id: string
	sandbox: string
	isInteractive: boolean
	width: number
	height: number
	pageRotation: number
	showShadow: boolean
	style?: React.CSSProperties
}) {
	// Security warning:
	// Gists allow adding .json extensions to the URL which return JSONP.
	// Furthermore, the JSONP can include callbacks that execute arbitrary JavaScript.
	// It _is_ sandboxed by the iframe but we still want to disable it nonetheless.
	// We restrict the id to only allow hexdecimal characters to prevent this.
	// Read more:
	//   https://github.com/bhaveshk90/Content-Security-Policy-CSP-Bypass-Techniques
	//   https://github.com/renniepak/CSPBypass
	if (!id.match(/^[0-9a-f]+$/)) throw Error('No gist id!')

	return (
		<iframe
			className="tl-embed"
			sandbox={sandbox}
			draggable={false}
			width={toDomPrecision(width)}
			height={toDomPrecision(height)}
			// eslint-disable-next-line @typescript-eslint/no-deprecated
			frameBorder="0"
			// eslint-disable-next-line @typescript-eslint/no-deprecated
			scrolling="no"
			referrerPolicy="strict-origin-when-cross-origin"
			tabIndex={isInteractive ? 0 : -1}
			style={{
				...style,
				pointerEvents: isInteractive ? 'all' : 'none',
				// Fix for safari <https://stackoverflow.com/a/49150908>
				zIndex: isInteractive ? '' : '-1',
				boxShadow: showShadow ? getRotatedBoxShadow(pageRotation) : 'none',
			}}
			srcDoc={`
			<html>
				<head>
					<base target="_blank">
				</head>
				<body>
					<script src=${`https://gist.github.com/${id}.js`}></script>
					<style type="text/css">
						* { margin: 0px; }
						table { height: 100%; background-color: red; }
						.gist { background-color: none; height: 100%;  }
						.gist .gist-file { height: calc(100vh - 2px); padding: 0px; display: grid; grid-template-rows: 1fr auto; }
					</style>
				</body>
			</html>`}
		/>
	)
}
