import type { CompilerOptions, Diagnostic } from "@typespec/compiler";
import { $ } from "@typespec/compiler/typekit";
import { Pane, SplitPane } from "@typespec/react-components";
import "@typespec/react-components/style.css";
import debounce from "debounce";
import { KeyCode, KeyMod, MarkerSeverity, MarkerTag, Uri, editor } from "monaco-editor";
import {
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
  type FunctionComponent,
  type ReactNode,
} from "react";
import { resolveVirtualPath } from "../browser-host.js";
import { EditorCommandBar } from "../editor-command-bar/editor-command-bar.js";
import { getMonacoRange, updateDiagnosticsForCodeFixes } from "../services.js";
import type { BrowserHost, PlaygroundSample } from "../types.js";
import { PlaygroundContextProvider } from "./context/playground-context.js";
import { debugGlobals, printDebugInfo } from "./debug.js";
import { DefaultFooter } from "./default-footer.js";
import { EditorPanel } from "./editor-panel/editor-panel.js";
import { useMonacoModel, type OnMountData } from "./editor.js";
import { OutputView } from "./output-view/output-view.js";
import style from "./playground.module.css";
import { ProblemPane } from "./problem-pane/index.js";
import type { CommandBarItem } from "./responsive-command-bar/index.js";
import type { CompilationState, FileOutputViewer, ProgramViewer } from "./types.js";
import { useIsMobile } from "./use-mobile.js";
import { usePlaygroundState, type PlaygroundState } from "./use-playground-state.js";
import { ViewToggle, type ViewMode } from "./view-toggle.js";

// Re-export the PlaygroundState type for convenience
export type { PlaygroundState };

export interface PlaygroundEmitterOptions {
  /** Compile debounce delay in milliseconds. Default is 200. */
  debounce?: number;
}

export interface PlaygroundProps {
  host: BrowserHost;

  /** Default content if leaving this unmanaged. */
  defaultContent?: string;

  /** List of available libraries */
  readonly libraries: readonly string[];

  /** Samples available */
  samples?: Record<string, PlaygroundSample>;

  /** Playground state (controlled) */
  playgroundState?: PlaygroundState;
  /** Default playground state if leaving this unmanaged */
  defaultPlaygroundState?: PlaygroundState;
  /** Callback when playground state changes */
  onPlaygroundStateChange?: (state: PlaygroundState) => void;

  /**
   * Default emitter to use if not provided in defaultPlaygroundState.
   * @deprecated Use defaultPlaygroundState.emitter instead
   */
  defaultEmitter?: string;

  onFileBug?: () => void;

  /** Additional items to show in the command bar. */
  commandBarItems?: CommandBarItem[];

  /** Custom viewers to view the typespec program */
  viewers?: ProgramViewer[];

  /** Custom file viewers that enabled for certain emitters. Key of the map is emitter name */
  emitterViewers?: Record<string, FileOutputViewer[]>;

  /**
   * Per-emitter playground options. Key is the emitter name.
   */
  emitterOptions?: Record<string, PlaygroundEmitterOptions>;

  onSave?: (value: PlaygroundSaveData) => void;

  editorOptions?: PlaygroundEditorsOptions;

  /**
   * Change the footer of the playground.
   */
  footer?: ReactNode;
}

export interface PlaygroundEditorsOptions {
  theme?: string;
}

export interface PlaygroundSaveData extends PlaygroundState {
  /** Current content of the playground.   */
  content: string;

  /** Emitter name. */
  emitter: string;
}

/**
 * Playground component for TypeSpec with consolidated state management.
 *
 * @example
 * ```tsx
 * const [playgroundState, setPlaygroundState] = useState<PlaygroundState>({
 *   emitter: 'openapi3',
 *   compilerOptions: {},
 *   sampleName: 'basic',
 *   selectedViewer: 'openapi',
 *   viewerState: {}
 * });
 *
 * <Playground
 *   host={host}
 *   playgroundState={playgroundState}
 *   onPlaygroundStateChange={setPlaygroundState}
 *   samples={samples}
 *   viewers={viewers}
 * />
 * ```
 *
 * For uncontrolled usage, use defaultPlaygroundState:
 * ```tsx
 * <Playground
 *   host={host}
 *   defaultPlaygroundState={{
 *     emitter: 'openapi3',
 *     compilerOptions: {},
 *   }}
 *   samples={samples}
 *   viewers={viewers}
 * />
 * ```
 *
 * For backward compatibility, you can also use the deprecated defaultEmitter prop:
 * ```tsx
 * <Playground
 *   host={host}
 *   defaultEmitter="openapi3"
 *   samples={samples}
 *   viewers={viewers}
 * />
 * ```
 */
export const Playground: FunctionComponent<PlaygroundProps> = (props) => {
  const { host, onSave } = props;
  const editorRef = useRef<editor.IStandaloneCodeEditor | undefined>(undefined);

  useEffect(() => {
    editor.setTheme(props.editorOptions?.theme ?? "typespec");
  }, [props.editorOptions?.theme]);

  useEffect(() => {
    printDebugInfo();

    debugGlobals().host = host;
  }, [host]);

  const typespecModel = useMonacoModel("inmemory://test/main.tsp", "typespec");
  const [compilationState, setCompilationState] = useState<CompilationState | undefined>(undefined);
  const lastSuccessfulOutputRef = useRef<string[]>([]);
  const [isCompiling, setIsCompiling] = useState(false);
  const [isOutputStale, setIsOutputStale] = useState(false);

  // Use the playground state hook
  const state = usePlaygroundState({
    libraries: props.libraries,
    samples: props.samples,
    playgroundState: props.playgroundState,
    defaultPlaygroundState: props.defaultPlaygroundState,
    onPlaygroundStateChange: props.onPlaygroundStateChange,
    // eslint-disable-next-line @typescript-eslint/no-deprecated
    defaultEmitter: props.defaultEmitter,
    defaultContent: props.defaultContent,
  });

  // Extract values from the state hook
  const {
    selectedEmitter,
    compilerOptions,
    selectedSampleName,
    selectedViewer,
    viewerState,
    content,
    onSelectedEmitterChange,
    onCompilerOptionsChange,
    onSelectedSampleNameChange,
    onSelectedViewerChange,
    onViewerStateChange,
    onContentChange,
  } = state;

  // Clear preserved output when switching emitters
  useEffect(() => {
    lastSuccessfulOutputRef.current = [];
    setIsOutputStale(false);
  }, [selectedEmitter]);

  // Track whether content changes originated from the model (user typing)
  // to avoid the sync effect resetting the model during typing
  const isModelDrivenChangeRef = useRef(false);

  // Sync Monaco model with state content (only for external/programmatic changes)
  useEffect(() => {
    if (isModelDrivenChangeRef.current) {
      isModelDrivenChangeRef.current = false;
      return;
    }
    if (typespecModel.getValue() !== (content ?? "")) {
      typespecModel.setValue(content ?? "");
    }
  }, [content, typespecModel]);

  // Use refs to avoid re-subscribing to onDidChangeContent on every keystroke
  const contentRef = useRef(content);
  const onContentChangeRef = useRef(onContentChange);
  useEffect(() => {
    contentRef.current = content;
  }, [content]);
  useEffect(() => {
    onContentChangeRef.current = onContentChange;
  }, [onContentChange]);

  // Update state when Monaco model changes
  useEffect(() => {
    const disposable = typespecModel.onDidChangeContent(() => {
      const newContent = typespecModel.getValue();
      if (newContent !== contentRef.current) {
        isModelDrivenChangeRef.current = true;
        onContentChangeRef.current(newContent);
      }
    });
    return () => disposable.dispose();
  }, [typespecModel]);

  const isSampleUntouched = useMemo(() => {
    return Boolean(selectedSampleName && content === props.samples?.[selectedSampleName]?.content);
  }, [content, selectedSampleName, props.samples]);

  const compileIdRef = useRef(0);
  const isCompilingRef = useRef(false);
  const pendingRecompileRef = useRef(false);
  const doCompileRef = useRef<() => Promise<void>>(() => Promise.resolve());

  const doCompile = useCallback(async () => {
    // If a compile is already in progress, mark that a recompile is needed and
    // bail out. The in-flight compile will re-trigger on completion. This avoids
    // stacking up synchronous compiles that block the UI thread during typing.
    if (isCompilingRef.current) {
      pendingRecompileRef.current = true;
      return;
    }
    const currentContent = typespecModel.getValue();
    const typespecCompiler = host.compiler;
    const compileId = ++compileIdRef.current;

    isCompilingRef.current = true;
    setIsCompiling(true);
    let state: CompilationState;
    try {
      state = await compile(host, currentContent, selectedEmitter, compilerOptions);
    } catch (error) {
      // eslint-disable-next-line no-console
      console.error("Compilation failed", error);
      isCompilingRef.current = false;
      setIsCompiling(false);
      if (pendingRecompileRef.current) {
        pendingRecompileRef.current = false;
        void doCompileRef.current();
      }
      return;
    }
    isCompilingRef.current = false;
    setIsCompiling(false);

    // Discard stale results from an older compilation
    if (compileId !== compileIdRef.current) return;

    // When compilation has errors and produced no output files, preserve the
    // previous successful output so the user doesn't lose their selected file
    // while typing (transient syntax errors).
    if (
      "program" in state &&
      state.program.hasError() &&
      state.outputFiles.length === 0 &&
      lastSuccessfulOutputRef.current.length > 0
    ) {
      setIsOutputStale(true);
      setCompilationState({
        ...state,
        outputFiles: lastSuccessfulOutputRef.current,
      });
    } else {
      setIsOutputStale(false);
      if ("program" in state && state.outputFiles.length > 0) {
        lastSuccessfulOutputRef.current = state.outputFiles;
      }
      setCompilationState(state);
    }
    if ("program" in state) {
      const markers: editor.IMarkerData[] = state.program.diagnostics.map((diag) => ({
        ...getMonacoRange(typespecCompiler, diag.target),
        message: diag.message,
        severity: diag.severity === "error" ? MarkerSeverity.Error : MarkerSeverity.Warning,
        tags: diag.code === "deprecated" ? [MarkerTag.Deprecated] : undefined,
      }));

      // Update code action provider with current diagnostics (for codefix support).
      updateDiagnosticsForCodeFixes(typespecCompiler, state.program.diagnostics);

      // Set the program on the window.
      debugGlobals().program = state.program;
      debugGlobals().$$ = $(state.program);

      editor.setModelMarkers(typespecModel, "owner", markers ?? []);
    } else {
      updateDiagnosticsForCodeFixes(typespecCompiler, []);
      editor.setModelMarkers(typespecModel, "owner", []);
    }

    // If typing happened while this compile was running, trigger a trailing
    // compile so the output stays in sync with the latest content.
    if (pendingRecompileRef.current) {
      pendingRecompileRef.current = false;
      void doCompileRef.current();
    }
  }, [host, selectedEmitter, compilerOptions, typespecModel]);

  useEffect(() => {
    doCompileRef.current = doCompile;
  }, [doCompile]);

  const currentEmitterOptions = selectedEmitter
    ? props.emitterOptions?.[selectedEmitter]
    : undefined;

  useEffect(() => {
    const delay = currentEmitterOptions?.debounce ?? 200;
    const debouncer = debounce(() => doCompile(), delay);
    const disposable = typespecModel.onDidChangeContent(debouncer);
    return () => {
      debouncer.clear();
      disposable.dispose();
    };
  }, [typespecModel, doCompile, currentEmitterOptions?.debounce]);

  useEffect(() => {
    void doCompile();
  }, [doCompile]);

  const saveCode = useCallback(() => {
    if (onSave) {
      // Read directly from the model to ensure we save the latest content,
      // not a potentially stale React state value
      const currentContent = typespecModel.getValue();
      onSave({
        content: currentContent,
        emitter: selectedEmitter,
        compilerOptions,
        sampleName: isSampleUntouched ? selectedSampleName : undefined,
        selectedViewer,
        viewerState,
      });
    }
  }, [
    typespecModel,
    onSave,
    selectedEmitter,
    compilerOptions,
    selectedSampleName,
    isSampleUntouched,
    selectedViewer,
    viewerState,
  ]);

  const formatCode = useCallback(() => {
    void editorRef.current?.getAction("editor.action.formatDocument")?.run();
  }, []);

  const fileBug = useCallback(async () => {
    if (props.onFileBug) {
      saveCode();
      props.onFileBug();
    }
  }, [props, saveCode]);

  const typespecEditorActions = useMemo(
    (): editor.IActionDescriptor[] => [
      // ctrl/cmd+S => save
      { id: "save", label: "Save", keybindings: [KeyMod.CtrlCmd | KeyCode.KeyS], run: saveCode },
    ],
    [saveCode],
  );

  const onTypeSpecEditorMount = useCallback(({ editor }: OnMountData) => {
    editorRef.current = editor;
  }, []);

  const [verticalPaneSizes, setVerticalPaneSizes] = useState<(string | number | undefined)[]>(
    verticalPaneSizesConst.collapsed,
  );
  const toggleProblemPane = useCallback(() => {
    setVerticalPaneSizes((value) => {
      return value === verticalPaneSizesConst.collapsed
        ? verticalPaneSizesConst.expanded
        : verticalPaneSizesConst.collapsed;
    });
  }, [setVerticalPaneSizes]);

  const onVerticalPaneSizeChange = useCallback(
    (sizes: number[]) => {
      setVerticalPaneSizes(sizes);
    },
    [setVerticalPaneSizes],
  );
  const handleDiagnosticSelected = useCallback(
    (diagnostic: Diagnostic) => {
      editorRef.current?.setSelection(getMonacoRange(host.compiler, diagnostic.target));
    },
    [host.compiler],
  );

  const playgroundContext = useMemo(() => {
    return {
      host,
      setContent: (val: string) => {
        typespecModel.setValue(val);
        onContentChange(val);
      },
    };
  }, [host, typespecModel, onContentChange]);

  const isMobile = useIsMobile();
  const [viewMode, setViewMode] = useState<ViewMode>("editor");

  // Reset to "editor" when entering mobile, force "both" on desktop
  useEffect(() => {
    if (!isMobile) {
      setViewMode("both");
    } else {
      setViewMode("editor");
    }
  }, [isMobile]);

  const commandBar = (
    <EditorCommandBar
      host={host}
      selectedEmitter={selectedEmitter}
      onSelectedEmitterChange={onSelectedEmitterChange}
      samples={props.samples}
      selectedSampleName={selectedSampleName}
      onSelectedSampleNameChange={onSelectedSampleNameChange}
      saveCode={saveCode}
      formatCode={formatCode}
      fileBug={props.onFileBug ? fileBug : undefined}
      commandBarItems={props.commandBarItems}
    />
  );

  const editorPanel = (
    <EditorPanel
      host={host}
      model={typespecModel}
      actions={typespecEditorActions}
      editorOptions={props.editorOptions}
      onMount={onTypeSpecEditorMount}
      selectedEmitter={selectedEmitter}
      compilerOptions={compilerOptions}
      onCompilerOptionsChange={onCompilerOptionsChange}
      onSelectedEmitterChange={onSelectedEmitterChange}
      commandBar={isMobile ? undefined : commandBar}
    />
  );

  const outputPanel = (
    <OutputView
      compilationState={compilationState}
      isCompiling={isCompiling}
      isOutputStale={isOutputStale}
      editorOptions={props.editorOptions}
      viewers={props.viewers}
      fileViewers={selectedEmitter ? props.emitterViewers?.[selectedEmitter] : undefined}
      selectedViewer={selectedViewer}
      onViewerChange={onSelectedViewerChange}
      viewerState={viewerState}
      onViewerStateChange={onViewerStateChange}
    />
  );

  const mainContent =
    viewMode === "both" ? (
      <SplitPane initialSizes={["50%", "50%"]}>
        <Pane className={style["edit-pane"]}>{editorPanel}</Pane>
        <Pane>{outputPanel}</Pane>
      </SplitPane>
    ) : viewMode === "editor" ? (
      <div className={style["single-pane"]}>{editorPanel}</div>
    ) : (
      <div className={style["single-pane"]}>{outputPanel}</div>
    );

  return (
    <PlaygroundContextProvider value={playgroundContext}>
      <div className={style["layout"]}>
        {isMobile && (
          <ViewToggle viewMode={viewMode} onViewModeChange={setViewMode} actions={commandBar} />
        )}
        <SplitPane sizes={verticalPaneSizes} onChange={onVerticalPaneSizeChange} split="horizontal">
          <Pane>{mainContent}</Pane>
          <Pane minSize={30}>
            <ProblemPane
              collapsed={verticalPaneSizes[1] === verticalPaneSizesConst.collapsed[1]}
              compilationState={compilationState}
              onHeaderClick={toggleProblemPane}
              onDiagnosticSelected={handleDiagnosticSelected}
            />
          </Pane>
        </SplitPane>
        {props.footer ?? <DefaultFooter />}
      </div>
    </PlaygroundContextProvider>
  );
};

const verticalPaneSizesConst = {
  collapsed: [undefined, 30],
  expanded: [undefined, 200],
};
const outputDir = resolveVirtualPath("tsp-output");

async function compile(
  host: BrowserHost,
  content: string,
  selectedEmitter: string,
  options: CompilerOptions,
): Promise<CompilationState> {
  await host.writeFile("main.tsp", content);
  await emptyOutputDir(host);
  try {
    const typespecCompiler = host.compiler;
    const program = await typespecCompiler.compile(host, resolveVirtualPath("main.tsp"), {
      ...options,
      options: {
        ...options.options,
        [selectedEmitter]: {
          ...options.options?.[selectedEmitter],
          "emitter-output-dir": outputDir,
        },
      },
      outputDir,
      emit: selectedEmitter ? [selectedEmitter] : [],
    });
    const outputFiles = await findOutputFiles(host);
    return { program, outputFiles };
  } catch (error) {
    // eslint-disable-next-line no-console
    console.error("Internal compiler error", error);
    return { internalCompilerError: error };
  }
}
async function findOutputFiles(host: BrowserHost): Promise<string[]> {
  const files: string[] = [];

  async function addFiles(dir: string) {
    const items = await host.readDir(outputDir + dir);
    for (const item of items) {
      const itemPath = `${dir}/${item}`;
      if ((await host.stat(outputDir + itemPath)).isDirectory()) {
        await addFiles(itemPath);
      } else {
        files.push(dir === "" ? item : `${dir}/${item}`);
      }
    }
  }
  await addFiles("");
  return files;
}

async function emptyOutputDir(host: BrowserHost) {
  // empty output directory
  const dirs = await host.readDir("./tsp-output");
  for (const file of dirs) {
    const path = "./tsp-output/" + file;
    const uri = Uri.parse(host.pathToFileURL(path));
    const model = editor.getModel(uri);
    if (model) {
      model.dispose();
    }
    await host.rm(path, { recursive: true });
  }
}
