import {
  App,
  EditorPosition,
  HeadingCache,
  MarkdownView,
  OpenViewState,
  Platform,
  TFile,
  View,
  WorkspaceLeaf,
} from 'obsidian';
import { AnySuggestion, Mode, SourceInfo } from 'src/types';
import { InputInfo } from 'src/switcherPlus';
import { SwitcherPlusSettings } from 'src/settings';
import {
  isCommandSuggestion,
  isEditorSuggestion,
  isSymbolSuggestion,
  isUnresolvedSuggestion,
  isWorkspaceSuggestion,
  stripMDExtensionFromPath,
} from 'src/utils';

export abstract class Handler<T> {
  get commandString(): string {
    return null;
  }

  constructor(protected app: App, protected settings: SwitcherPlusSettings) {}

  validateCommand(
    _inputInfo: InputInfo,
    _index: number,
    _filterText: string,
    _activeSuggestion: AnySuggestion,
    _activeLeaf: WorkspaceLeaf,
  ): void {
    // no op
  }

  getSuggestions(_inputInfo: InputInfo): T[] {
    return [];
  }

  renderSuggestion(_sugg: T, _parentEl: HTMLElement): void {
    // no op
  }

  onChooseSuggestion(_sugg: T, _evt: MouseEvent | KeyboardEvent): void {
    // no op
  }

  getEditorInfo(leaf: WorkspaceLeaf): SourceInfo {
    const { excludeViewTypes } = this.settings;
    let file: TFile = null;
    let isValidSource = false;
    let cursor: EditorPosition = null;

    if (leaf) {
      const { view } = leaf;

      const viewType = view.getViewType();
      file = view.file;
      cursor = this.getCursorPosition(view);

      // determine if the current active editor pane is valid
      const isCurrentEditorValid = !excludeViewTypes.includes(viewType);

      // whether or not the current active editor can be used as the target for
      // symbol search
      isValidSource = isCurrentEditorValid && !!file;
    }

    return { isValidSource, leaf, file, suggestion: null, cursor };
  }

  getSuggestionInfo(suggestion: AnySuggestion): SourceInfo {
    const info = this.getSourceInfoFromSuggestion(suggestion);
    let leaf = info.leaf;

    if (info.isValidSource) {
      // try to find a matching leaf for suggestion types that don't explicitly
      // provide one. This is primarily needed to be able to focus an
      // existing pane if there is one
      ({ leaf } = this.findOpenEditor(info.file, info.leaf));
    }

    // Get the cursor information to support `selectNearestHeading`
    const cursor = this.getCursorPosition(leaf?.view);

    return { ...info, leaf, cursor };
  }

  protected getSourceInfoFromSuggestion(suggestion: AnySuggestion): SourceInfo {
    let file: TFile = null;
    let leaf: WorkspaceLeaf = null;

    // Can't use a symbol, workspace, unresolved (non-existent file) suggestions as
    // the target for another symbol command, because they don't point to a file
    const isFileBasedSuggestion =
      suggestion &&
      !isSymbolSuggestion(suggestion) &&
      !isUnresolvedSuggestion(suggestion) &&
      !isWorkspaceSuggestion(suggestion) &&
      !isCommandSuggestion(suggestion);

    if (isFileBasedSuggestion) {
      file = suggestion.file;
    }

    if (isEditorSuggestion(suggestion)) {
      leaf = suggestion.item;
    }

    const isValidSource = !!file;

    return { isValidSource, leaf, file, suggestion };
  }

  /**
   * Retrieves the position of the cursor, given that view is in a Mode that supports cursors.
   * @param  {View} view
   * @returns EditorPosition
   */
  getCursorPosition(view: View): EditorPosition {
    let cursor: EditorPosition = null;

    if (view?.getViewType() === 'markdown') {
      const md = view as MarkdownView;

      if (md.getMode() !== 'preview') {
        const { editor } = md;
        cursor = editor.getCursor('head');
      }
    }

    return cursor;
  }

  /**
   * Returns the text of the first H1 contained in sourceFile, or sourceFile
   * path if an H1 does not exist
   * @param  {TFile} sourceFile
   * @returns string
   */
  getTitleText(sourceFile: TFile): string {
    const path = stripMDExtensionFromPath(sourceFile);
    const h1 = this.getFirstH1(sourceFile);

    return h1?.heading ?? path;
  }

  /**
   * Finds and returns the first H1 from sourceFile
   * @param  {TFile} sourceFile
   * @returns HeadingCache
   */
  getFirstH1(sourceFile: TFile): HeadingCache | null {
    let h1: HeadingCache = null;
    const { metadataCache } = this.app;
    const headingList: HeadingCache[] =
      metadataCache.getFileCache(sourceFile)?.headings?.filter((v) => v.level === 1) ??
      [];

    if (headingList.length) {
      h1 = headingList.reduce((acc, curr) => {
        const { line: currLine } = curr.position.start;
        const accLine = acc.position.start.line;

        return currLine < accLine ? curr : acc;
      });
    }

    return h1;
  }

  /**
   * Finds the first open WorkspaceLeaf that is showing source file.
   * @param  {TFile} file The source file that is being shown to find
   * @param  {WorkspaceLeaf} leaf An already open editor, or, a 'reference' WorkspaceLeaf (example: backlinks, outline, etc.. views) that is used to find the associated editor if one exists.
   * @returns TargetInfo
   */
  findOpenEditor(file: TFile, leaf?: WorkspaceLeaf): SourceInfo {
    let matchingLeaf = null;
    const isTargetLeaf = !!leaf;
    const {
      settings: { referenceViews, excludeViewTypes, includeSidePanelViewTypes },
      app: { workspace },
    } = this;

    const isMatch = (l: WorkspaceLeaf) => {
      let val = false;

      if (l?.view) {
        const isRefView = referenceViews.includes(l.view.getViewType());
        const isTargetRefView =
          isTargetLeaf && referenceViews.includes(leaf.view.getViewType());

        if (!isRefView) {
          val = isTargetLeaf && !isTargetRefView ? l === leaf : l.view.file === file;
        }
      }

      return val;
    };

    // Prioritize the active leaf matches first, otherwise find the first matching leaf
    if (isMatch(workspace.activeLeaf)) {
      matchingLeaf = workspace.activeLeaf;
    }

    if (!matchingLeaf) {
      const leaves = this.getOpenLeaves(excludeViewTypes, includeSidePanelViewTypes);

      // put leaf at the first index so it gets checked first
      matchingLeaf = [leaf, ...leaves].find(isMatch);
    }

    return {
      leaf: matchingLeaf ?? null,
      file,
      suggestion: null,
      isValidSource: false,
    };
  }

  /**
   * Determines whether or not a new leaf should be created
   * @param  {boolean} isModDown Set to true if the user holding cmd/ctrl
   * @param  {} isAlreadyOpen=false Set to true if there is a pane showing the file already
   * @param  {Mode} mode? Only Symbol mode has special handling.
   * @returns boolean
   */
  shouldCreateNewLeaf(isModDown: boolean, isAlreadyOpen = false, mode?: Mode): boolean {
    const {
      onOpenPreferNewPane,
      alwaysNewPaneForSymbols,
      useActivePaneForSymbolsOnMobile,
    } = this.settings;

    const isNewPaneRequested = !isAlreadyOpen && onOpenPreferNewPane;
    let shouldCreateNew = isModDown || isNewPaneRequested;

    if (mode === Mode.SymbolList && !onOpenPreferNewPane) {
      const { isMobile } = Platform;
      shouldCreateNew = alwaysNewPaneForSymbols || isModDown;

      if (isMobile) {
        shouldCreateNew = isModDown || !useActivePaneForSymbolsOnMobile;
      }
    }

    return shouldCreateNew;
  }

  /**
   * Determines if a leaf belongs to the main editor panel (workspace.rootSplit)
   * as opposed to the side panels
   * @param  {WorkspaceLeaf} leaf
   * @returns boolean
   */
  isMainPanelLeaf(leaf: WorkspaceLeaf): boolean {
    return leaf?.getRoot() === this.app.workspace.rootSplit;
  }

  /**
   * Reveals and optionally bring into focus a WorkspaceLeaf, including leaves
   * from the side panels.
   * @param  {WorkspaceLeaf} leaf
   * @param  {boolean} pushHistory?
   * @param  {Record<string} eState?
   * @param  {} unknown>
   * @returns void
   */
  activateLeaf(
    leaf: WorkspaceLeaf,
    pushHistory?: boolean,
    eState?: Record<string, unknown>,
  ): void {
    const { workspace } = this.app;
    const isInSidePanel = !this.isMainPanelLeaf(leaf);
    const state = { focus: true, ...eState };

    if (isInSidePanel) {
      workspace.revealLeaf(leaf);
    }

    workspace.setActiveLeaf(leaf, pushHistory);
    leaf.view.setEphemeralState(state);
  }

  /**
   * Returns a array of all open WorkspaceLeaf taking into account
   * excludeMainPanelViewTypes and includeSidePanelViewTypes.
   * @param  {string[]} excludeMainPanelViewTypes?
   * @param  {string[]} includeSidePanelViewTypes?
   * @returns WorkspaceLeaf[]
   */
  getOpenLeaves(
    excludeMainPanelViewTypes?: string[],
    includeSidePanelViewTypes?: string[],
  ): WorkspaceLeaf[] {
    const leaves: WorkspaceLeaf[] = [];

    const saveLeaf = (l: WorkspaceLeaf) => {
      const viewType = l.view?.getViewType();

      if (this.isMainPanelLeaf(l)) {
        if (!excludeMainPanelViewTypes?.includes(viewType)) {
          leaves.push(l);
        }
      } else if (includeSidePanelViewTypes?.includes(viewType)) {
        leaves.push(l);
      }
    };

    this.app.workspace.iterateAllLeaves(saveLeaf);
    return leaves;
  }

  /**
   * Loads a file into a (optionally new) WorkspaceLeaf
   * @param  {TFile} file
   * @param  {boolean} shouldCreateNewLeaf
   * @param  {OpenViewState} openState?
   * @param  {} errorContext=''
   * @returns void
   */
  openFileInLeaf(
    file: TFile,
    shouldCreateNewLeaf: boolean,
    openState?: OpenViewState,
    errorContext?: string,
  ): void {
    errorContext = errorContext ?? '';
    const message = `Switcher++: error opening file. ${errorContext}`;

    try {
      this.app.workspace
        .getLeaf(shouldCreateNewLeaf)
        .openFile(file, openState)
        .catch((reason) => {
          // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
          console.log(`${message} ${reason}`);
        });
    } catch (error) {
      // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
      console.log(`${message} ${error}`);
    }
  }

  /**
   * Determines whether to activate (make active and focused) an existing WorkspaceLeaf,
   * or, create a new WorkspaceLeaf, or, reuse an unpinned WorkspaceLeaf in order to
   * dispay file. This takes user settings and Mod key status into account.
   * @param  {boolean} isModDown Set to true if the user is holding down cmd/ctrl keys
   * @param  {TFile} file The file to display
   * @param  {string} errorContext Custom text to save in error messages
   * @param  {OpenViewState} openState? State to pass to the new, or activated view. If
   * falsy, default values will be used
   * @param  {WorkspaceLeaf} leaf? Editor, or reference WorkspaceLeaf to activate if it's
   * already known
   * @param  {Mode} mode? Only Symbol mode has custom handling
   * @returns void
   */
  navigateToLeafOrOpenFile(
    isModDown: boolean,
    file: TFile,
    errorContext: string,
    openState?: OpenViewState,
    leaf?: WorkspaceLeaf,
    mode?: Mode,
  ): void {
    const { leaf: targetLeaf } = this.findOpenEditor(file, leaf);
    const isAlreadyOpen = !!targetLeaf;
    const shouldCreateNew = this.shouldCreateNewLeaf(isModDown, isAlreadyOpen, mode);

    // default to having the pane active and focused
    openState = openState ?? { active: true, eState: { active: true, focus: true } };

    if (targetLeaf && !shouldCreateNew) {
      const eState = openState?.eState as Record<string, unknown>;
      this.activateLeaf(targetLeaf, true, eState);
    } else {
      this.openFileInLeaf(file, shouldCreateNew, openState, errorContext);
    }
  }
}