import Setting from '@joplin/lib/models/Setting';
import shim from '@joplin/lib/shim';
import { themeStyle } from '@joplin/lib/theme';
import EditLinkDialog from './EditLinkDialog';
import { defaultSearchState, SearchPanel } from './SearchPanel';
import ExtendedWebView from '../ExtendedWebView';

import * as React from 'react';
import { forwardRef, useImperativeHandle } from 'react';
import { useMemo, useState, useCallback, useRef } from 'react';
import { LayoutChangeEvent, View, ViewStyle } from 'react-native';
const { editorFont } = require('../global-style');

import { EditorControl, EditorSettings, SelectionRange } from './types';
import { _ } from '@joplin/lib/locale';
import MarkdownToolbar from './MarkdownToolbar/MarkdownToolbar';
import { ChangeEvent, EditorEvent, EditorEventType, SelectionRangeChangeEvent, UndoRedoDepthChangeEvent } from '@joplin/editor/events';
import { EditorCommandType, EditorKeymap, EditorLanguageType, PluginData, SearchState } from '@joplin/editor/types';
import supportsCommand from '@joplin/editor/CodeMirror/editorCommands/supportsCommand';
import SelectionFormatting, { defaultSelectionFormatting } from '@joplin/editor/SelectionFormatting';

type ChangeEventHandler = (event: ChangeEvent)=> void;
type UndoRedoDepthChangeHandler = (event: UndoRedoDepthChangeEvent)=> void;
type SelectionChangeEventHandler = (event: SelectionRangeChangeEvent)=> void;
type OnAttachCallback = ()=> void;

interface Props {
	themeId: number;
	initialText: string;
	initialSelection?: SelectionRange;
	style: ViewStyle;
	contentStyle?: ViewStyle;
	toolbarEnabled: boolean;
	readOnly: boolean;

	onChange: ChangeEventHandler;
	onSelectionChange: SelectionChangeEventHandler;
	onUndoRedoDepthChange: UndoRedoDepthChangeHandler;
	onAttach: OnAttachCallback;
}

function fontFamilyFromSettings() {
	const font = editorFont(Setting.value('style.editor.fontFamily'));
	return font ? `${font}, sans-serif` : 'sans-serif';
}

function useCss(themeId: number): string {
	return useMemo(() => {
		const theme = themeStyle(themeId);
		return `
			:root {
				background-color: ${theme.backgroundColor};
			}

			body {
				margin: 0;
				height: 100vh;
				width: 100vh;
				width: 100vw;
				min-width: 100vw;
				box-sizing: border-box;

				padding-left: 1px;
				padding-right: 1px;
				padding-bottom: 1px;
				padding-top: 10px;

				font-size: 13pt;
			}
		`;
	}, [themeId]);
}

function useHtml(css: string): string {
	const [html, setHtml] = useState('');

	useMemo(() => {
		setHtml(`
			<!DOCTYPE html>
			<html>
				<head>
					<meta charset="UTF-8">
					<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
					<title>${_('Note editor')}</title>
					<style>
						/* For better scrolling on iOS (working scrollbar) we use external, rather than internal,
						   scrolling. */
						.cm-scroller {
							overflow: none;

							/* Ensure that the editor can be foused by clicking on the lower half of the screen.
							   Don't use 100vh to prevent a scrollbar being present for empty notes. */
							min-height: 80vh;
						}

						${css}
					</style>
				</head>
				<body>
					<div class="CodeMirror" style="height:100%;" autocapitalize="on"></div>
				</body>
			</html>
		`);
	}, [css]);

	return html;
}

function editorTheme(themeId: number) {
	const fontSizeInPx = Setting.value('style.editor.fontSize');

	// Convert from `px` to `em`. To support font size scaling based on
	// system accessibility settings, we need to provide font sizes in `em`.
	// 16px is about 1em with the default root font size.
	const estimatedFontSizeInEm = fontSizeInPx / 16;

	return {
		...themeStyle(themeId),

		// To allow accessibility font scaling, we also need to set the
		// fontSize to a value in `em`s (relative scaling relative to
		// parent font size).
		fontSizeUnits: 'em',
		fontSize: estimatedFontSizeInEm,
		fontFamily: fontFamilyFromSettings(),
	};
}

type OnInjectJSCallback = (js: string)=> void;
type OnSetVisibleCallback = (visible: boolean)=> void;
type OnSearchStateChangeCallback = (state: SearchState)=> void;
const useEditorControl = (
	injectJS: OnInjectJSCallback, setLinkDialogVisible: OnSetVisibleCallback,
	setSearchState: OnSearchStateChangeCallback,
): EditorControl => {
	return useMemo(() => {
		const execCommand = (command: EditorCommandType) => {
			injectJS(`cm.execCommand(${JSON.stringify(command)})`);
		};

		const setSearchStateCallback = (state: SearchState) => {
			injectJS(`cm.setSearchState(${JSON.stringify(state)})`);
			setSearchState(state);
		};

		const control: EditorControl = {
			supportsCommand(command: EditorCommandType) {
				return supportsCommand(command);
			},
			execCommand,

			undo() {
				injectJS('cm.undo()');
			},
			redo() {
				injectJS('cm.redo()');
			},
			select(anchor: number, head: number) {
				injectJS(
					`cm.select(${JSON.stringify(anchor)}, ${JSON.stringify(head)});`,
				);
			},
			setScrollPercent(fraction: number) {
				injectJS(`cm.setScrollFraction(${JSON.stringify(fraction)})`);
			},
			insertText(text: string) {
				injectJS(`cm.insertText(${JSON.stringify(text)});`);
			},
			updateBody(newBody: string) {
				injectJS(`cm.updateBody(${JSON.stringify(newBody)});`);
			},
			updateSettings(newSettings: EditorSettings) {
				injectJS(`cm.updateSettings(${JSON.stringify(newSettings)})`);
			},

			toggleBolded() {
				execCommand(EditorCommandType.ToggleBolded);
			},
			toggleItalicized() {
				execCommand(EditorCommandType.ToggleItalicized);
			},
			toggleOrderedList() {
				execCommand(EditorCommandType.ToggleNumberedList);
			},
			toggleUnorderedList() {
				execCommand(EditorCommandType.ToggleBulletedList);
			},
			toggleTaskList() {
				execCommand(EditorCommandType.ToggleCheckList);
			},
			toggleCode() {
				execCommand(EditorCommandType.ToggleCode);
			},
			toggleMath() {
				execCommand(EditorCommandType.ToggleMath);
			},
			toggleHeaderLevel(level: number) {
				const levelToCommand = [
					EditorCommandType.ToggleHeading1,
					EditorCommandType.ToggleHeading2,
					EditorCommandType.ToggleHeading3,
					EditorCommandType.ToggleHeading4,
					EditorCommandType.ToggleHeading5,
				];

				const index = level - 1;

				if (index < 0 || index >= levelToCommand.length) {
					throw new Error(`Unsupported header level ${level}`);
				}

				execCommand(levelToCommand[index]);
			},
			increaseIndent() {
				execCommand(EditorCommandType.IndentMore);
			},
			decreaseIndent() {
				execCommand(EditorCommandType.IndentLess);
			},
			updateLink(label: string, url: string) {
				injectJS(`cm.updateLink(
					${JSON.stringify(label)},
					${JSON.stringify(url)}
				);`);
			},
			scrollSelectionIntoView() {
				execCommand(EditorCommandType.ScrollSelectionIntoView);
			},
			showLinkDialog() {
				setLinkDialogVisible(true);
			},
			hideLinkDialog() {
				setLinkDialogVisible(false);
			},
			hideKeyboard() {
				injectJS('document.activeElement?.blur();');
			},

			setPlugins: async (plugins: PluginData[]) => {
				injectJS(`cm.setPlugins(${JSON.stringify(plugins)});`);
			},

			setSearchState: setSearchStateCallback,

			searchControl: {
				findNext() {
					execCommand(EditorCommandType.FindNext);
				},
				findPrevious() {
					execCommand(EditorCommandType.FindPrevious);
				},
				replaceNext() {
					execCommand(EditorCommandType.ReplaceNext);
				},
				replaceAll() {
					execCommand(EditorCommandType.ReplaceAll);
				},

				showSearch() {
					execCommand(EditorCommandType.ShowSearch);
				},
				hideSearch() {
					execCommand(EditorCommandType.HideSearch);
				},

				setSearchState: setSearchStateCallback,
			},
		};

		return control;
	}, [injectJS, setLinkDialogVisible, setSearchState]);
};

function NoteEditor(props: Props, ref: any) {
	const webviewRef = useRef(null);

	const setInitialSelectionJS = props.initialSelection ? `
		cm.select(${props.initialSelection.start}, ${props.initialSelection.end});
		cm.execCommand('scrollSelectionIntoView');
	` : '';

	const editorSettings: EditorSettings = {
		themeId: props.themeId,
		themeData: editorTheme(props.themeId),
		katexEnabled: Setting.value('markdown.plugin.katex'),
		spellcheckEnabled: Setting.value('editor.mobile.spellcheckEnabled'),
		language: EditorLanguageType.Markdown,
		useExternalSearch: true,
		readOnly: props.readOnly,

		keymap: EditorKeymap.Default,

		automatchBraces: false,
		ignoreModifiers: false,

		indentWithTabs: false,
	};

	const injectedJavaScript = `
		function postMessage(name, data) {
			window.ReactNativeWebView.postMessage(JSON.stringify({
				data,
				name,
			}));
		}

		function logMessage(...msg) {
			postMessage('onLog', { value: msg });
		}

		// Globalize logMessage, postMessage
		window.logMessage = logMessage;
		window.postMessage = postMessage;

		window.onerror = (message, source, lineno) => {
			window.ReactNativeWebView.postMessage(
				"error: " + message + " in file://" + source + ", line " + lineno
			);
		};

		window.onunhandledrejection = (event) => {
			window.ReactNativeWebView.postMessage(
				"error: Unhandled promise rejection: " + event
			);
		};

		if (!window.cm) {
			// This variable is not used within this script
			// but is called using "injectJavaScript" from
			// the wrapper component.
			window.cm = null;

			try {
				${shim.injectedJs('codeMirrorBundle')};

				const parentElement = document.getElementsByClassName('CodeMirror')[0];
				const initialText = ${JSON.stringify(props.initialText)};
				const settings = ${JSON.stringify(editorSettings)};

				cm = codeMirrorBundle.initCodeMirror(parentElement, initialText, settings);

				${setInitialSelectionJS}

				window.onresize = () => {
					cm.execCommand('scrollSelectionIntoView');
				};
			} catch (e) {
				window.ReactNativeWebView.postMessage("error:" + e.message + ": " + JSON.stringify(e))
			}
		}
		true;
	`;

	const css = useCss(props.themeId);
	const html = useHtml(css);
	const [selectionState, setSelectionState] = useState<SelectionFormatting>(defaultSelectionFormatting);
	const [linkDialogVisible, setLinkDialogVisible] = useState(false);
	const [searchState, setSearchState] = useState(defaultSearchState);

	// Runs [js] in the context of the CodeMirror frame.
	const injectJS = (js: string) => {
		webviewRef.current.injectJS(js);
	};

	const editorControl = useEditorControl(
		injectJS, setLinkDialogVisible, setSearchState,
	);

	useImperativeHandle(ref, () => {
		return editorControl;
	});

	const onMessage = useCallback((event: any) => {
		const data = event.nativeEvent.data;

		if (data.indexOf('error:') === 0) {
			console.error('CodeMirror:', data);
			return;
		}

		const msg = JSON.parse(data);

		// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
		const handlers: Record<string, Function> = {
			onLog: (event: any) => {
				// eslint-disable-next-line no-console
				console.info('CodeMirror:', ...event.value);
			},

			onEditorEvent: (event: EditorEvent) => {
				let exhaustivenessCheck: never;
				switch (event.kind) {
				case EditorEventType.Change:
					props.onChange(event);
					break;
				case EditorEventType.UndoRedoDepthChange:
					props.onUndoRedoDepthChange(event);
					break;
				case EditorEventType.SelectionRangeChange:
					props.onSelectionChange(event);
					break;
				case EditorEventType.SelectionFormattingChange:
					setSelectionState(event.formatting);
					break;
				case EditorEventType.EditLink:
					editorControl.showLinkDialog();
					break;
				case EditorEventType.UpdateSearchDialog:
					setSearchState(event.searchState);

					if (event.searchState.dialogVisible) {
						editorControl.searchControl.showSearch();
					} else {
						editorControl.searchControl.hideSearch();
					}
					break;
				case EditorEventType.Scroll:
					// Not handled
					break;
				default:
					exhaustivenessCheck = event;
					return exhaustivenessCheck;
				}
				return;
			},
		};

		if (handlers[msg.name]) {
			handlers[msg.name](msg.data);
		} else {
			// eslint-disable-next-line no-console
			console.info('Unsupported CodeMirror message:', msg);
		}
	}, [props.onSelectionChange, props.onUndoRedoDepthChange, props.onChange, editorControl]);

	const onError = useCallback(() => {
		console.error('NoteEditor: webview error');
	}, []);

	const [hasSpaceForToolbar, setHasSpaceForToolbar] = useState(true);
	const toolbarEnabled = props.toolbarEnabled && hasSpaceForToolbar;

	const onContainerLayout = useCallback((event: LayoutChangeEvent) => {
		const containerHeight = event.nativeEvent.layout.height;

		if (containerHeight < 140) {
			setHasSpaceForToolbar(false);
		} else {
			setHasSpaceForToolbar(true);
		}
	}, []);

	const toolbar = <MarkdownToolbar
		style={{
			// Don't show the markdown toolbar if there isn't enough space
			// for it:
			flexShrink: 1,
		}}
		editorSettings={editorSettings}
		editorControl={editorControl}
		selectionState={selectionState}
		searchState={searchState}
		onAttach={props.onAttach}
		readOnly={props.readOnly}
	/>;

	return (
		<View
			testID='note-editor-root'
			onLayout={onContainerLayout}
			style={{
				...props.style,
				flexDirection: 'column',
			}}
		>
			<EditLinkDialog
				visible={linkDialogVisible}
				themeId={props.themeId}
				editorControl={editorControl}
				selectionState={selectionState}
			/>
			<View style={{
				flexGrow: 1,
				flexShrink: 0,
				minHeight: '30%',
				...props.contentStyle,
			}}>
				<ExtendedWebView
					webviewInstanceId='NoteEditor'
					themeId={props.themeId}
					scrollEnabled={true}
					ref={webviewRef}
					html={html}
					injectedJavaScript={injectedJavaScript}
					onMessage={onMessage}
					onError={onError}
				/>
			</View>

			<SearchPanel
				editorSettings={editorSettings}
				searchControl={editorControl.searchControl}
				searchState={searchState}
			/>

			{toolbarEnabled ? toolbar : null}
		</View>
	);
}

export default forwardRef(NoteEditor);
