import { closeBrackets } from '@codemirror/autocomplete';
import { syntaxHighlighting, bracketMatching } from '@codemirror/language';
import { Compartment, Facet, EditorState as CodeMirrorState } from '@codemirror/state';
import type { Extension, StateEffect } from '@codemirror/state';
import { EditorView as CodeMirror, lineNumbers, gutters } from '@codemirror/view';
import type { ViewUpdate } from '@codemirror/view';
import type { IntlShape } from 'react-intl';

import { getBrowserInfo } from '@atlaskit/editor-common/browser';
import {
	areCodeBlockLineNumbersHidden,
	isCodeBlockWordWrapEnabled,
} from '@atlaskit/editor-common/code-block';
import { messages as floatingToolbarMessages } from '@atlaskit/editor-common/floating-toolbar';
import {
	blockTypeMessages,
	codeBlockMessages,
	roleDescriptionMessages,
} from '@atlaskit/editor-common/messages';
import type { RelativeSelectionPos } from '@atlaskit/editor-common/selection';
import type {
	getPosHandler,
	getPosHandlerNode,
	ExtractInjectionAPI,
	EditorContentMode,
} from '@atlaskit/editor-common/types';
import { ZERO_WIDTH_SPACE } from '@atlaskit/editor-common/whitespace';
import type { EditorSelectionAPI } from '@atlaskit/editor-plugin-selection';
import type { Node as PMNode } from '@atlaskit/editor-prosemirror/model';
import { NodeSelection } from '@atlaskit/editor-prosemirror/state';
import type {
	Decoration,
	DecorationSource,
	EditorView,
	NodeView,
} from '@atlaskit/editor-prosemirror/view';
import { DecorationSet } from '@atlaskit/editor-prosemirror/view';
import { fg } from '@atlaskit/platform-feature-flags';
import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
import { expValEqualsNoExposure } from '@atlaskit/tmp-editor-statsig/exp-val-equals-no-exposure';

import type { CodeBlockAdvancedPlugin } from '../codeBlockAdvancedPluginType';
import { highlightStyle } from '../ui/syntaxHighlightingTheme';
import { cmTheme, codeFoldingTheme } from '../ui/theme';

import { syncCMWithPM } from './codemirrorSync/syncCMWithPM';
import { getCMSelectionChanges } from './codemirrorSync/updateCMSelection';
import { firstCodeBlockInDocument } from './extensions/firstCodeBlockInDocument';
import { foldGutterExtension, getCodeBlockFoldStateEffects } from './extensions/foldGutter';
import { keymapExtension } from './extensions/keymap';
import { lineSeparatorExtension } from './extensions/lineSeparator';
import { manageSelectionMarker } from './extensions/manageSelectionMarker';
import { prosemirrorDecorationPlugin } from './extensions/prosemirrorDecorations';
import { tripleClickSelectAllExtension } from './extensions/tripleClickExtension';
import getLanguageName from './languages/getLanguageName';
import { LanguageLoader } from './languages/loader';

// Store last observed heights of code blocks
const codeBlockHeights = new WeakMap<HTMLElement, number>();

export interface ConfigProps {
	allowCodeFolding: boolean;
	api: ExtractInjectionAPI<CodeBlockAdvancedPlugin> | undefined;
	extensions: Extension[];
	getIntl: () => IntlShape;
}

// Based on: https://prosemirror.net/examples/codemirror/
class CodeBlockAdvancedNodeView implements NodeView {
	dom: Node;
	private updating: boolean;
	private view: EditorView;
	private lineWrappingCompartment = new Compartment();
	private lineNumbersCompartment = new Compartment();
	private languageCompartment = new Compartment();
	private readOnlyCompartment = new Compartment();
	private pmDecorationsCompartment = new Compartment();
	private themeCompartment = new Compartment();
	private node: PMNode;
	private getPos: getPosHandlerNode;
	private cm: CodeMirror;
	private contentMode: EditorContentMode | undefined;
	private selectionAPI: EditorSelectionAPI | undefined;
	private maybeTryingToReachNodeSelection = false;
	private cleanupDisabledState: (() => void) | undefined;
	private languageLoader: LanguageLoader;
	private pmFacet = Facet.define<DecorationSource>();
	private ro?: ResizeObserver;
	private unsubscribeContentFormat: (() => void) | undefined;
	private invisibleAriaDescription?: HTMLSpanElement;
	private config: ConfigProps;

	constructor(
		node: PMNode,
		view: EditorView,
		getPos: getPosHandlerNode,
		innerDecorations: DecorationSource,
		config: ConfigProps,
	) {
		this.config = config;
		this.node = node;

		this.view = view;
		this.getPos = getPos;
		const contentFormatSharedState = expValEquals(
			'confluence_compact_text_format',
			'isEnabled',
			true,
		)
			? config.api?.contentFormat?.sharedState
			: undefined;
		this.contentMode = expValEquals('confluence_compact_text_format', 'isEnabled', true)
			? contentFormatSharedState?.currentState?.()?.contentMode
			: undefined;

		this.selectionAPI = config.api?.selection?.actions;
		const getNode = () => this.node;
		const onMaybeNodeSelection = () => (this.maybeTryingToReachNodeSelection = true);
		this.cleanupDisabledState = config.api?.editorDisabled?.sharedState.onChange(() => {
			this.updateReadonlyState();
		});

		this.languageLoader = new LanguageLoader((lang) => {
			this.updating = true;
			this.cm.dispatch({
				effects: this.languageCompartment.reconfigure(lang),
			});
			this.updating = false;
		});
		const { formatMessage } = config.getIntl();
		const formattedAriaLabel = formatMessage(blockTypeMessages.codeblock);

		const isMacOS = getBrowserInfo().mac;

		this.cm = new CodeMirror({
			doc: this.node.textContent,
			extensions: [
				...config.extensions,
				this.lineWrappingCompartment.of(
					isCodeBlockWordWrapEnabled(node) ? CodeMirror.lineWrapping : [],
				),
				this.languageCompartment.of([]),
				this.pmDecorationsCompartment.of(this.pmFacet.compute([], () => innerDecorations)),
				keymapExtension({
					view,
					getPos,
					getNode,
					selectCodeBlockNode: this.selectCodeBlockNode.bind(this),
					onMaybeNodeSelection,
					customFindReplace: Boolean(config.api?.findReplace),
				}),
				// Goes before cmTheme to override styles
				config.allowCodeFolding ? [codeFoldingTheme] : [],
				this.themeCompartment.of(
					cmTheme({
						contentMode: expValEquals('confluence_compact_text_format', 'isEnabled', true)
							? this.contentMode
							: undefined,
					}),
				),
				syntaxHighlighting(highlightStyle),
				bracketMatching(),
				expValEquals('platform_editor_code_block_q4_lovability', 'isEnabled', true)
					? this.lineNumbersCompartment.of(this.getLineNumberVisibilityExtensions(node))
					: this.getLineNumberExtensions(),
				// Explicitly disable "sticky" positioning on all gutters to match
				// Renderer behaviour.
				gutters({ fixed: false }),
				CodeMirror.updateListener.of((update) => this.forwardUpdate(update)),
				this.readOnlyCompartment.of([
					CodeMirrorState.readOnly.of(!this.view.editable),
					CodeMirror.contentAttributes.of({ contentEditable: `${this.view.editable}` }),
				]),
				closeBrackets(),
				CodeMirror.editorAttributes.of({
					class: 'code-block',
					...(fg('platform_editor_adf_with_localid') && {
						'data-local-id': this.node.attrs.localId,
					}),
				}),
				manageSelectionMarker(config.api),
				prosemirrorDecorationPlugin(this.pmFacet, view, getPos),
				tripleClickSelectAllExtension(),
				firstCodeBlockInDocument(getPos),
				CodeMirror.contentAttributes.of({
					...(!expValEquals('editor_a11y_role_textbox', 'isEnabled', true) && {
						'aria-label': `${formattedAriaLabel}`,
					}),
					...(isMacOS &&
						expValEquals('editor_a11y_role_textbox', 'isEnabled', true) && {
							role: 'textbox',
							'aria-roledescription': formatMessage(roleDescriptionMessages.codeSnippetTextBox),
							'aria-describedby': `codesnippet-${this.node.attrs.localId}`,
							'aria-multiline': 'true',
							'aria-label': formattedAriaLabel,
						}),
					...(!isMacOS &&
						expValEquals('editor_a11y_role_textbox', 'isEnabled', true) && {
							'aria-label': formattedAriaLabel,
							'aria-describedby': `codesnippet-${this.node.attrs.localId}`,
						}),
				}),
				config.allowCodeFolding
					? [
							foldGutterExtension({
								selectNode: this.selectCodeBlockNodeAndFocus,
								getNode: () => this.node,
							}),
						]
					: [],
				// With platform_editor_fix_advanced_codeblocks_crlf_patch the lineSeparatorExtension is not needed
				expValEquals('platform_editor_fix_advanced_codeblocks_crlf_patch', 'isEnabled', true)
					? []
					: [lineSeparatorExtension()],
			],
		});

		if (contentFormatSharedState) {
			this.unsubscribeContentFormat = contentFormatSharedState.onChange(
				({ nextSharedState, prevSharedState }) => {
					const prevMode = prevSharedState?.contentMode;
					const nextMode = nextSharedState?.contentMode;
					if (nextMode === prevMode) {
						return;
					}

					this.applyContentModeTheme(nextMode);

					if (this.updating || this.cm.hasFocus) {
						return;
					}

					this.cm.requestMeasure();
				},
			);
		}

		// Observe size changes of the CodeMirror DOM and request a measurement pass
		if (
			!expValEquals('confluence_compact_text_format', 'isEnabled', true) &&
			expValEquals('cc_editor_ai_content_mode', 'variant', 'test') &&
			fg('platform_editor_content_mode_button_mvp')
		) {
			this.ro = new ResizeObserver((entries) => {
				// Skip measurements when:
				// 1. Currently updating (prevents feedback loops)
				// 2. CodeMirror has focus (user is actively typing/editing)
				if (this.updating || this.cm.hasFocus) {
					return;
				}

				// Only trigger on height changes, not width or other dimension changes
				for (const entry of entries) {
					const currentHeight = entry.contentRect.height;
					const lastHeight = codeBlockHeights.get(this.cm.contentDOM);
					if (lastHeight !== undefined && lastHeight === currentHeight) {
						return;
					}
					codeBlockHeights.set(this.cm.contentDOM, currentHeight);
				}

				// CodeMirror to re-measure when its content size changes
				this.cm.requestMeasure();
			});
			this.ro.observe(this.cm.contentDOM);
		}

		// We append an additional element that fixes a selection bug on chrome if the code block
		// is the first element followed by subsequent code blocks
		const spaceContainer = document.createElement('span');
		spaceContainer.innerText = ZERO_WIDTH_SPACE;
		spaceContainer.style.height = '0';
		// The editor's outer node is our DOM representation
		this.dom = this.cm.dom;
		this.dom.appendChild(spaceContainer);

		if (
			expValEquals('editor_a11y_role_textbox', 'isEnabled', true) &&
			fg('platform_editor_adf_with_localid')
		) {
			this.invisibleAriaDescription = document.createElement('span');
			this.invisibleAriaDescription.hidden = true;
			this.invisibleAriaDescription.id = `codesnippet-${this.node.attrs.localId}`;
			this.updateAriaDescription();

			this.dom.appendChild(this.invisibleAriaDescription);
		}

		// This flag is used to avoid an update loop between the outer and
		// inner editor
		this.updating = false;
		this.updateLanguage();
		this.updateLocalIdAttribute();
		this.wordWrappingEnabled = isCodeBlockWordWrapEnabled(node);
		this.lineNumbersHidden = areCodeBlockLineNumbersHidden(node);

		// Restore fold state after initialization
		if (config.allowCodeFolding) {
			this.restoreFoldState();
		}
	}

	destroy(): void {
		// ED-27428: CodeMirror gets into an infinite loop as it detects mutations on removed
		// decorations. When we change the breakout we destroy the node and cleanup these decorations from
		// codemirror
		this.clearProseMirrorDecorations();
		this.cleanupDisabledState?.();
		if (expValEquals('confluence_compact_text_format', 'isEnabled', true)) {
			this.unsubscribeContentFormat?.();
		}
		if (
			!expValEquals('confluence_compact_text_format', 'isEnabled', true) &&
			expValEquals('cc_editor_ai_content_mode', 'variant', 'test') &&
			fg('platform_editor_content_mode_button_mvp')
		) {
			this.ro?.disconnect();
		}
	}

	forwardUpdate(update: ViewUpdate): void {
		if (this.updating || !this.cm.hasFocus) {
			return;
		}
		const offset = (this.getPos?.() ?? 0) + 1;

		syncCMWithPM({
			view: this.view,
			update,
			offset,
		});
	}

	setSelection(anchor: number, head: number): void {
		if (!this.maybeTryingToReachNodeSelection) {
			this.cm.focus();
		}
		this.updating = true;
		this.cm.dispatch({ selection: { anchor, head } });
		this.updating = false;
	}

	private updateReadonlyState() {
		this.updating = true;
		this.cm.dispatch({
			effects: this.readOnlyCompartment.reconfigure([
				CodeMirrorState.readOnly.of(!this.view.editable),
				CodeMirror.contentAttributes.of({ contentEditable: `${this.view.editable}` }),
			]),
		});
		this.updating = false;
	}

	private updateLanguage() {
		this.languageLoader.updateLanguage(this.node.attrs.language);
		if (
			expValEquals('editor_a11y_role_textbox', 'isEnabled', true) &&
			fg('platform_editor_adf_with_localid')
		) {
			this.updateAriaDescription();
		}
	}

	private updateAriaDescription() {
		if (!this.invisibleAriaDescription) {
			return;
		}

		const { formatMessage } = this.config.getIntl();
		const languageName = getLanguageName(this.node.attrs.language);
		if (languageName) {
			this.invisibleAriaDescription.textContent = `${formatMessage(
				codeBlockMessages.codeblockLanguageAriaDescription,
				{
					language: languageName,
				},
			)} ${formatMessage(floatingToolbarMessages.floatingToolbarAnnouncer)}`;
		} else {
			// If the lanuage is undefined provide a more human readable message
			this.invisibleAriaDescription.textContent = `${formatMessage(
				codeBlockMessages.codeBlockLanguageNotSet,
			)} ${formatMessage(floatingToolbarMessages.floatingToolbarAnnouncer)}`;
		}
	}

	private updateLocalIdAttribute() {
		if (fg('platform_editor_adf_with_localid')) {
			const localId = this.node.attrs.localId;
			if (localId) {
				this.cm.dom.setAttribute('data-local-id', localId);
			} else {
				this.cm.dom.removeAttribute('data-local-id');
			}
		}
	}

	private selectCodeBlockNode(relativeSelectionPos: RelativeSelectionPos | undefined) {
		const tr = this.selectionAPI?.selectNearNode({
			selectionRelativeToNode: relativeSelectionPos,
			selection: NodeSelection.create(this.view.state.doc, this.getPos?.() ?? 0),
		})(this.view.state);
		if (tr) {
			this.view.dispatch(tr);
		}
	}

	private wordWrappingEnabled = false;
	private lineNumbersHidden = false;

	private selectCodeBlockNodeAndFocus = () => {
		this.selectCodeBlockNode(undefined);
		this.view.focus();
	};

	private getLineNumberExtensions(): Extension[] {
		return [
			lineNumbers({
				domEventHandlers: {
					click: () => {
						this.selectCodeBlockNodeAndFocus();
						return true;
					},
				},
			}),
		];
	}

	private getLineNumberVisibilityExtensions(node: PMNode): Extension[] {
		if (areCodeBlockLineNumbersHidden(node)) {
			return [];
		}

		return this.getLineNumberExtensions();
	}

	private getLineNumbersEffects(node: PMNode) {
		const lineNumbersHidden = areCodeBlockLineNumbersHidden(node);
		if (this.lineNumbersHidden !== lineNumbersHidden) {
			this.lineNumbersHidden = lineNumbersHidden;
			return this.lineNumbersCompartment.reconfigure(
				this.getLineNumberVisibilityExtensions(node),
			);
		}
		return undefined;
	}

	private getWordWrapEffects(node: PMNode) {
		if (this.wordWrappingEnabled !== isCodeBlockWordWrapEnabled(node)) {
			this.wordWrappingEnabled = !this.wordWrappingEnabled;
			return this.lineWrappingCompartment.reconfigure(
				isCodeBlockWordWrapEnabled(node) ? CodeMirror.lineWrapping : [],
			);
		}
		return undefined;
	}

	private restoreFoldState() {
		this.updating = true;
		const effects = getCodeBlockFoldStateEffects({ node: this.node, cm: this.cm });
		if (effects) {
			this.cm.dispatch({ effects });
		}
		this.updating = false;
	}

	private applyContentModeTheme(contentMode: EditorContentMode | undefined) {
		if (contentMode === this.contentMode) {
			return;
		}
		this.contentMode = contentMode;
		this.updating = true;
		this.cm.dispatch({
			effects: this.themeCompartment.reconfigure(cmTheme({ contentMode })),
		});
		this.updating = false;
	}

	update(node: PMNode, _: readonly Decoration[], innerDecorations: DecorationSource): boolean {
		this.maybeTryingToReachNodeSelection = false;

		if (node.type !== this.node.type) {
			return false;
		}
		this.node = node;
		if (this.updating) {
			return true;
		}
		this.updateLanguage();
		this.updateLocalIdAttribute();
		const newText = node.textContent,
			curText = this.cm.state.doc.toString();

		// Updates bundled for performance (to avoid multiple-dispatches)
		const changes = getCMSelectionChanges(curText, newText);
		const wordWrapEffect = this.getWordWrapEffects(node);
		const lineNumbersEffect = expValEquals(
			'platform_editor_code_block_q4_lovability',
			'isEnabled',
			true,
		)
			? this.getLineNumbersEffects(node)
			: undefined;
		const prosemirrorDecorationsEffect = this.getProseMirrorDecorationEffects(innerDecorations);
		if (changes || wordWrapEffect || lineNumbersEffect || prosemirrorDecorationsEffect) {
			this.updating = true;
			this.cm.dispatch({
				effects: [wordWrapEffect, lineNumbersEffect, prosemirrorDecorationsEffect].filter(
					(effect): effect is StateEffect<unknown> => !!effect,
				),
				changes,
			});
			this.updating = false;
		}
		return true;
	}

	/**
	 * Updates a facet which stores information on the prosemirror decorations
	 *
	 * This then gets translated to codemirror decorations in `prosemirrorDecorationPlugin`
	 * @param decorationSource
	 * @example
	 */
	private getProseMirrorDecorationEffects(decorationSource: DecorationSource) {
		const computedFacet = this.pmFacet.compute([], () => decorationSource);
		return this.pmDecorationsCompartment.reconfigure(computedFacet);
	}

	private clearProseMirrorDecorations() {
		this.updating = true;
		const computedFacet = this.pmFacet.compute([], () => DecorationSet.empty);
		this.cm.dispatch({
			effects: this.pmDecorationsCompartment.reconfigure(computedFacet),
		});
		this.updating = false;
	}

	stopEvent(e: Event): boolean {
		// If we have selected the node we should not stop these events
		if (
			(e instanceof KeyboardEvent || e instanceof ClipboardEvent) &&
			this.view.state.selection instanceof NodeSelection &&
			this.view.state.selection.from === this.getPos?.()
		) {
			return false;
		}

		if (
			e instanceof DragEvent &&
			e.type === 'dragenter' &&
			expValEqualsNoExposure('platform_editor_block_controls_perf_optimization', 'isEnabled', true)
		) {
			return false; // Allow dragenter to propagate so that the editor can handle it
		}

		return true;
	}
}

export const getCodeBlockAdvancedNodeView =
	(props: ConfigProps) =>
	(
		node: PMNode,
		view: EditorView,
		getPos: getPosHandler,
		innerDecorations: DecorationSource,
	): CodeBlockAdvancedNodeView => {
		return new CodeBlockAdvancedNodeView(
			node,
			view,
			getPos as getPosHandlerNode,
			innerDecorations,
			props,
		);
	};
