import * as React from 'react';
import { useState, useEffect, useRef, forwardRef, useCallback, useImperativeHandle, ForwardedRef, useContext } from 'react';

import { EditorCommand, MarkupToHtmlOptions, NoteBodyEditorProps, NoteBodyEditorRef, OnChangeEvent } from '../../../utils/types';
import { getResourcesFromPasteEvent } from '../../../utils/resourceHandling';
import { ScrollOptions, ScrollOptionTypes } from '../../../utils/types';
import NoteTextViewer from '../../../../NoteTextViewer';
import Editor from './Editor';
import usePluginServiceRegistration from '../../../utils/usePluginServiceRegistration';
import Setting from '@joplin/lib/models/Setting';
import Note from '@joplin/lib/models/Note';
import { _ } from '@joplin/lib/locale';
import bridge from '../../../../../services/bridge';
import shim from '@joplin/lib/shim';
import { clipboard } from 'electron';
import { reg } from '@joplin/lib/registry';
import ErrorBoundary from '../../../../ErrorBoundary';
import { SearchState, UserEventSource } from '@joplin/editor/types';
import useStyles from '../utils/useStyles';
import { EditorEvent, EditorEventType } from '@joplin/editor/events';
import useScrollHandler from '../utils/useScrollHandler';
import Logger from '@joplin/utils/Logger';
import useEditorCommands from './useEditorCommands';
import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
import useContextMenu from '../utils/useContextMenu';
import useWebviewIpcMessage from '../utils/useWebviewIpcMessage';
import Toolbar from '../Toolbar';
import useEditorSearchHandler from '../utils/useEditorSearchHandler';
import CommandService from '@joplin/lib/services/CommandService';
import useRefocusOnVisiblePaneChange from './utils/useRefocusOnVisiblePaneChange';
import { WindowIdContext } from '../../../../NewWindowOrIFrame';
import eventManager, { EventName, ResourceChangeEvent } from '@joplin/lib/eventManager';
import useSyncEditorValue from './utils/useSyncEditorValue';
import { getGlobalSettings } from '@joplin/renderer/types';
import useEditorSettings from './utils/useEditorSettings';

const logger = Logger.create('CodeMirror6');
const logDebug = (message: string) => logger.debug(message);

interface RenderedBody {
	html: string;
	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
	pluginAssets: any[];
}

function defaultRenderedBody(): RenderedBody {
	return {
		html: '',
		pluginAssets: [],
	};
}

function markupRenderOptions(override: MarkupToHtmlOptions = null): MarkupToHtmlOptions {
	return { ...override };
}

const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditorRef>) => {
	const styles = useStyles(props);

	const [renderedBody, setRenderedBody] = useState<RenderedBody>(defaultRenderedBody()); // Viewer content
	const [renderedBodyContentKey, setRenderedBodyContentKey] = useState<string>(null);

	const [webviewReady, setWebviewReady] = useState(false);

	const editorRef = useRef<CodeMirrorControl>(null);
	const rootRef = useRef(null);
	const webviewRef = useRef(null);

	type OnChangeCallback = (event: OnChangeEvent)=> void;
	const props_onChangeRef = useRef<OnChangeCallback>(null);
	props_onChangeRef.current = props.onChange;

	const [selectionRange, setSelectionRange] = useState({ from: 0, to: 0 });

	const {
		resetScroll, editor_scroll, setEditorPercentScroll, setViewerPercentScroll, getLineScrollPercent,
	} = useScrollHandler(editorRef, webviewRef, props.onScroll);

	usePluginServiceRegistration(ref);

	const codeMirror_change = useCallback((newBody: string) => {
		if (newBody !== props.content) {
			props_onChangeRef.current({ changeId: null, content: newBody });
		}
	}, [props.content]);

	const onEditorPaste = useCallback(async (event: Event|null = null) => {
		const resourceMds = await getResourcesFromPasteEvent(event);
		if (!resourceMds.length) return;
		if (editorRef.current) {
			editorRef.current.insertText(resourceMds.join('\n'), UserEventSource.Paste);
		}
	}, []);

	const editorCutText = useCallback(() => {
		if (editorRef.current) {
			editorRef.current.cutText(text => clipboard.writeText(text));
		}
	}, []);

	const editorCopyText = useCallback(() => {
		if (editorRef.current) {
			editorRef.current.copyText(text => clipboard.writeText(text));
		}
	}, []);

	const editorPasteText = useCallback(async () => {
		if (editorRef.current) {
			const modifiedMd = await Note.replaceResourceExternalToInternalLinks(clipboard.readText(), { useAbsolutePaths: true });
			editorRef.current.insertText(modifiedMd, UserEventSource.Paste);
		}
	}, []);

	const editorPaste = useCallback(() => {
		const clipboardText = clipboard.readText();

		if (clipboardText) {
			void editorPasteText();
		} else {
			// To handle pasting images
			void onEditorPaste();
		}
	}, [editorPasteText, onEditorPaste]);

	const commands = useEditorCommands({
		webviewRef,
		editorRef,
		selectionRange,

		editorCopyText, editorCutText, editorPaste,
		editorContent: props.content,
		visiblePanes: props.visiblePanes,
		contentMarkupLanguage: props.contentMarkupLanguage,
	});

	useImperativeHandle(ref, () => {
		return {
			content: () => props.content,
			resetScroll: () => {
				resetScroll();
			},
			scrollTo: (options: ScrollOptions) => {
				if (options.type === ScrollOptionTypes.Hash) {
					const hash: string = options.value;
					webviewRef.current?.send('scrollToHash', hash);
					editorRef.current.jumpToHash(hash);
				} else if (options.type === ScrollOptionTypes.Percent) {
					const percent = options.value as number;
					setEditorPercentScroll(percent);
					setViewerPercentScroll(percent);

				} else {
					throw new Error(`Unsupported scroll options: ${options.type}`);
				}
			},
			supportsCommand: (name: string) => {
				if (name === 'search' && !props.visiblePanes.includes('editor')) {
					return false;
				}
				return name in commands || editorRef.current.supportsCommand(name);
			},
			execCommand: async (cmd: EditorCommand) => {
				if (!editorRef.current) return false;

				logger.debug('execCommand', cmd);

				let commandOutput = null;
				if (cmd.name in commands) {
					// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
					commandOutput = (commands as any)[cmd.name](cmd.value);
				} else if (editorRef.current.supportsCommand(cmd.name)) {
					commandOutput = editorRef.current.execCommand(cmd.name);
				} else if (editorRef.current.supportsJoplinCommand(cmd.name)) {
					commandOutput = editorRef.current.execJoplinCommand(cmd.name);
				} else {
					reg.logger().warn('CodeMirror: unsupported Joplin command: ', cmd);
				}

				return commandOutput;
			},
		};
	}, [props.content, props.visiblePanes, commands, resetScroll, setEditorPercentScroll, setViewerPercentScroll]);

	const webview_domReady = useCallback(() => {
		setWebviewReady(true);
	}, []);

	const webview_ipcMessage = useWebviewIpcMessage({
		editorRef,
		setEditorPercentScroll,
		getLineScrollPercent,
		content: props.content,
		onMessage: props.onMessage,
	});

	useEffect(() => {
		let cancelled = false;

		// When a new note is loaded (contentKey is different), we want the note to be displayed
		// right away. However once that's done, we put a small delay so that the view is not
		// being constantly updated while the user changes the note.
		const interval = renderedBodyContentKey !== props.contentKey ? 0 : 500;

		const timeoutId = shim.setTimeout(async () => {
			let bodyToRender = props.content;

			if (!props.visiblePanes.includes('viewer')) {
				return;
			}

			if (!bodyToRender.trim() && props.visiblePanes.indexOf('viewer') >= 0 && props.visiblePanes.indexOf('editor') < 0) {
				// Fixes https://github.com/laurent22/joplin/issues/217
				bodyToRender = `<i>${_('This note has no content. Click on "%s" to toggle the editor and edit the note.', _('Layout'))}</i>`;
			}

			const result = await props.markupToHtml(props.contentMarkupLanguage, bodyToRender, markupRenderOptions({
				resourceInfos: props.resourceInfos,
				contentMaxWidth: props.contentMaxWidth,
				mapsToLine: true,
				// Always using useCustomPdfViewer for now, we can add a new setting for it in future if we need to.
				useCustomPdfViewer: props.useCustomPdfViewer,
				noteId: props.noteId,
				vendorDir: bridge().vendorDir(),
				globalSettings: getGlobalSettings(Setting),
				showNoteLinkIcon: props.showNoteLinkIcon,
			}));

			if (cancelled) return;

			setRenderedBody(result);

			// Since we set `renderedBodyContentKey` here, it means this effect is going to
			// be triggered again, but that's hard to avoid and the second call would be cheap
			// anyway since the renderered markdown is cached by MdToHtml. We could use a ref
			// to avoid this, but a second rendering might still happens anyway to render images,
			// resources, or for other reasons. So it's best to focus on making any second call
			// to this effect as cheap as possible with caching, etc.
			setRenderedBodyContentKey(props.contentKey);
		}, interval);

		return () => {
			cancelled = true;
			shim.clearTimeout(timeoutId);
		};
	}, [
		props.content, props.contentKey, renderedBodyContentKey, props.contentMarkupLanguage,
		props.visiblePanes, props.resourceInfos, props.markupToHtml, props.contentMaxWidth,
		props.noteId, props.useCustomPdfViewer, props.showNoteLinkIcon,
	]);

	useEffect(() => {
		const listener = (event: ResourceChangeEvent) => {
			editorRef.current?.onResourceChanged(event.id);
		};

		eventManager.on(EventName.ResourceChange, listener);
		return () => {
			eventManager.off(EventName.ResourceChange, listener);
		};
	}, [props.resourceInfos]);

	useEffect(() => {
		if (!webviewReady) return;

		let lineCount = 0;
		if (editorRef.current) {
			lineCount = editorRef.current.editor.state.doc.lines;
		}

		// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
		const options: any = {
			pluginAssets: renderedBody.pluginAssets,
			downloadResources: Setting.value('sync.resourceDownloadMode'),
			markupLineCount: lineCount,
		};

		// It seems when there's an error immediately when the component is
		// mounted, webviewReady might be true, but webviewRef.current will be
		// undefined. Maybe due to the error boundary that unmount components.
		// Since we can't do much about it we just print an error.
		if (webviewRef.current) {
			// To keep consistency among CodeMirror's editing and scroll percents
			// of Editor and Viewer.
			const percent = getLineScrollPercent();
			setEditorPercentScroll(percent);
			options.percent = percent;
			webviewRef.current.setHtml(renderedBody.html, options);
		} else {
			console.error('Trying to set HTML on an undefined webview ref');
		}
	}, [renderedBody, webviewReady, getLineScrollPercent, setEditorPercentScroll]);

	useRefocusOnVisiblePaneChange({ editorRef, webviewRef, visiblePanes: props.visiblePanes });

	useEditorSearchHandler({
		setLocalSearchResultCount: props.setLocalSearchResultCount,
		searchMarkers: props.searchMarkers,
		webviewRef,
		editorRef,
		noteContent: props.content,
		renderedBody,
		showEditorMarkers: !props.useLocalSearch,
	});

	useContextMenu({
		plugins: props.plugins,
		dispatch: props.dispatch,
		editorCutText, editorCopyText, editorPaste,
		editorRef,
		editorClassName: 'cm-editor',
		containerRef: rootRef,
	});

	const lastSearchState = useRef<SearchState|null>(null);
	const onEditorEvent = useCallback((event: EditorEvent) => {
		if (event.kind === EditorEventType.Scroll) {
			editor_scroll();
		} else if (event.kind === EditorEventType.Change) {
			codeMirror_change(event.value);
		} else if (event.kind === EditorEventType.SelectionRangeChange) {
			props.onCursorMotion({ markdown: event.from });
			setSelectionRange({ from: event.from, to: event.to });
		} else if (event.kind === EditorEventType.UpdateSearchDialog) {
			if (lastSearchState.current?.searchText !== event.searchState.searchText) {
				props.setLocalSearch(event.searchState.searchText);
			}

			if (lastSearchState.current?.dialogVisible !== event.searchState.dialogVisible) {
				props.setShowLocalSearch(event.searchState.dialogVisible);
			}
			lastSearchState.current = event.searchState;
		} else if (event.kind === EditorEventType.FollowLink) {
			void CommandService.instance().execute('openItem', event.link);
		}
	}, [editor_scroll, codeMirror_change, props.setLocalSearch, props.setShowLocalSearch, props.onCursorMotion]);

	const onSelectPastBeginning = useCallback(() => {
		void CommandService.instance().execute('focusElement', 'noteTitle');
	}, []);

	const initialCursorLocationRef = useRef(0);
	initialCursorLocationRef.current = props.initialCursorLocation.markdown ?? 0;

	useSyncEditorValue({
		content: props.content,
		visiblePanes: props.visiblePanes,
		onMessage: props.onMessage,
		editorRef,
		noteId: props.noteId,
		initialCursorLocationRef,
	});

	const settings = useEditorSettings({
		baseTheme: styles.globalTheme,
		contentMarkupLanguage: props.contentMarkupLanguage,
		disabled: props.disabled,
		keyboardMode: props.keyboardMode,
		tabMovesFocus: props.tabMovesFocus,
	});

	const renderEditor = () => {
		return (
			<div className='editor'>
				<Editor
					style={styles.editor}
					initialText={props.content}
					initialSelectionRef={initialCursorLocationRef}
					initialNoteId={props.noteId}
					ref={editorRef}
					settings={settings}
					pluginStates={props.plugins}
					onPasteFile={null}
					onEvent={onEditorEvent}
					onLogMessage={logDebug}
					onEditorPaste={onEditorPaste}
					onSelectPastBeginning={onSelectPastBeginning}
					externalSearch={props.searchMarkers}
					useLocalSearch={props.useLocalSearch}
					onLocalize={_}
				/>
			</div>
		);
	};

	const renderViewer = () => {
		return (
			<div className='viewer'>
				<NoteTextViewer
					ref={webviewRef}
					themeId={props.themeId}
					viewerStyle={styles.viewer}
					onIpcMessage={webview_ipcMessage}
					onDomReady={webview_domReady}
					contentMaxWidth={props.contentMaxWidth}
				/>
			</div>
		);
	};

	const editorViewerRow = (
		<div className={[
			'note-editor-viewer-row',
			props.visiblePanes.includes('editor') ? '-show-editor' : '',
			props.visiblePanes.includes('viewer') ? '-show-viewer' : '',
		].join(' ')}>
			{renderEditor()}
			{renderViewer()}
		</div>
	);

	const windowId = useContext(WindowIdContext);
	return (
		<ErrorBoundary message="The text editor encountered a fatal error and could not continue. The error might be due to a plugin, so please try to disable some of them and try again.">
			<div style={styles.root} ref={rootRef}>
				<div style={styles.rowToolbar}>
					<Toolbar themeId={props.themeId} windowId={windowId}/>
					{props.noteToolbar}
				</div>
				{editorViewerRow}
			</div>
		</ErrorBoundary>
	);
};

export default forwardRef(CodeMirror);
