import * as H from 'history';
import { Unsubscribable } from 'rxjs';

import { AppEvents, CoreApp, DataQueryRequest, NavIndex, NavModelItem, locationUtil } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import {
  dataLayers,
  getUrlSyncManager,
  SceneDataLayers,
  SceneFlexLayout,
  sceneGraph,
  SceneGridItem,
  SceneGridLayout,
  SceneObject,
  SceneObjectBase,
  SceneObjectState,
  SceneObjectStateChangedEvent,
  SceneRefreshPicker,
  SceneTimeRange,
  sceneUtils,
  SceneVariable,
  SceneVariableDependencyConfigLike,
  VizPanel,
} from '@grafana/scenes';
import { Dashboard, DashboardLink } from '@grafana/schema';
import appEvents from 'app/core/app_events';
import { LS_PANEL_COPY_KEY } from 'app/core/constants';
import { getNavModel } from 'app/core/selectors/navModel';
import store from 'app/core/store';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { DashboardModel } from 'app/features/dashboard/state';
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
import { VariablesChanged } from 'app/features/variables/types';
import { DashboardDTO, DashboardMeta, SaveDashboardResponseDTO } from 'app/types';
import { ShowConfirmModalEvent } from 'app/types/events';

import { PanelEditor } from '../panel-edit/PanelEditor';
import { SaveDashboardDrawer } from '../saving/SaveDashboardDrawer';
import { DashboardSceneRenderer } from '../scene/DashboardSceneRenderer';
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
import { gridItemToPanel } from '../serialization/transformSceneToSaveModel';
import { DecoratedRevisionModel } from '../settings/VersionsEditView';
import { DashboardEditView } from '../settings/utils';
import { historySrv } from '../settings/version-history';
import { DashboardModelCompatibilityWrapper } from '../utils/DashboardModelCompatibilityWrapper';
import { djb2Hash } from '../utils/djb2Hash';
import { getDashboardUrl } from '../utils/urlBuilders';
import { forceRenderChildren, getClosestVizPanel, getPanelIdForVizPanel, isPanelClone } from '../utils/utils';

import { DashboardControls } from './DashboardControls';
import { DashboardSceneUrlSync } from './DashboardSceneUrlSync';
import { PanelRepeaterGridItem } from './PanelRepeaterGridItem';
import { ViewPanelScene } from './ViewPanelScene';
import { setupKeyboardShortcuts } from './keyboardShortcuts';

export const PERSISTED_PROPS = ['title', 'description', 'tags', 'editable', 'graphTooltip', 'links'];

export interface DashboardSceneState extends SceneObjectState {
  /** The title */
  title: string;
  /** The description */
  description?: string;
  /** Tags */
  tags?: string[];
  /** Links */
  links: DashboardLink[];
  /** Is editable */
  editable?: boolean;
  /** A uid when saved */
  uid?: string;
  /** @deprecated */
  id?: number | null;
  /** Layout of panels */
  body: SceneObject;
  /** NavToolbar actions */
  actions?: SceneObject[];
  /** Fixed row at the top of the canvas with for example variables and time range controls */
  controls?: DashboardControls;
  /** True when editing */
  isEditing?: boolean;
  /** True when user made a change */
  isDirty?: boolean;
  /** meta flags */
  meta: DashboardMeta;
  /** Version of the dashboard */
  version?: number;
  /** Panel to inspect */
  inspectPanelKey?: string;
  /** Panel to view in fullscreen */
  viewPanelScene?: ViewPanelScene;
  /** Edit view */
  editview?: DashboardEditView;
  /** Edit panel */
  editPanel?: PanelEditor;
  /** Scene object that handles the current drawer or modal */
  overlay?: SceneObject;
}

export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
  static listenToChangesInProps = PERSISTED_PROPS;
  static Component = DashboardSceneRenderer;

  /**
   * Handles url sync
   */
  protected _urlSync = new DashboardSceneUrlSync(this);
  /**
   * Get notified when variables change
   */
  protected _variableDependency = new DashboardVariableDependency();

  /**
   * State before editing started
   */
  private _initialState?: DashboardSceneState;
  /**
   * The save model which the scene was originally created from
   */
  private _initialSaveModel?: Dashboard;
  /**
   * Url state before editing started
   */
  private _initialUrlState?: H.Location;
  /**
   * change tracking subscription
   */
  private _changeTrackerSub?: Unsubscribable;

  public constructor(state: Partial<DashboardSceneState>) {
    super({
      title: 'Dashboard',
      meta: {},
      editable: true,
      body: state.body ?? new SceneFlexLayout({ children: [] }),
      links: state.links ?? [],
      ...state,
    });

    this.addActivationHandler(() => this._activationHandler());
  }

  private _activationHandler() {
    let prevSceneContext = window.__grafanaSceneContext;

    window.__grafanaSceneContext = this;

    if (this.state.isEditing) {
      this.startTrackingChanges();
    }

    if (!this.state.meta.isEmbedded && this.state.uid) {
      dashboardWatcher.watch(this.state.uid);
    }

    const clearKeyBindings = setupKeyboardShortcuts(this);
    const oldDashboardWrapper = new DashboardModelCompatibilityWrapper(this);

    // @ts-expect-error
    getDashboardSrv().setCurrent(oldDashboardWrapper);

    // Deactivation logic
    return () => {
      window.__grafanaSceneContext = prevSceneContext;
      clearKeyBindings();
      this.stopTrackingChanges();
      this.stopUrlSync();
      oldDashboardWrapper.destroy();
      dashboardWatcher.leave();
    };
  }

  public startUrlSync() {
    if (!this.state.meta.isEmbedded) {
      getUrlSyncManager().initSync(this);
    }
  }

  public stopUrlSync() {
    getUrlSyncManager().cleanUp(this);
  }

  public onEnterEditMode = () => {
    // Save this state
    this._initialState = sceneUtils.cloneSceneObjectState(this.state);
    this._initialUrlState = locationService.getLocation();

    // Switch to edit mode
    this.setState({ isEditing: true });

    // Propagate change edit mode change to children
    this.propagateEditModeChange();
    this.startTrackingChanges();
  };

  public saveCompleted(saveModel: Dashboard, result: SaveDashboardResponseDTO, folderUid?: string) {
    this._initialSaveModel = {
      ...saveModel,
      id: result.id,
      uid: result.uid,
      version: result.version,
    };

    this.stopTrackingChanges();
    this.setState({
      version: result.version,
      isDirty: false,
      uid: result.uid,
      id: result.id,
      meta: {
        ...this.state.meta,
        uid: result.uid,
        url: result.url,
        slug: result.slug,
        folderUid: folderUid,
      },
    });
    this.startTrackingChanges();
  }

  private propagateEditModeChange() {
    if (this.state.body instanceof SceneGridLayout) {
      this.state.body.setState({ isDraggable: this.state.isEditing, isResizable: this.state.isEditing });
      forceRenderChildren(this.state.body, true);
    }
  }

  public exitEditMode({ skipConfirm }: { skipConfirm: boolean }) {
    if (!this.canDiscard()) {
      console.error('Trying to discard back to a state that does not exist, initialState undefined');
      return;
    }

    if (!this.state.isDirty || skipConfirm) {
      this.exitEditModeConfirmed();
      return;
    }

    appEvents.publish(
      new ShowConfirmModalEvent({
        title: 'Discard changes to dashboard?',
        text: `You have unsaved changes to this dashboard. Are you sure you want to discard them?`,
        icon: 'trash-alt',
        yesText: 'Discard',
        onConfirm: this.exitEditModeConfirmed.bind(this),
      })
    );
  }

  private exitEditModeConfirmed() {
    // No need to listen to changes anymore
    this.stopTrackingChanges();
    // Stop url sync before updating url
    this.stopUrlSync();

    // Now we can update urls
    // We are updating url and removing editview and editPanel.
    // The initial url may be including edit view, edit panel or inspect query params if the user pasted the url,
    // hence we need to cleanup those query params to get back to the dashboard view. Otherwise url sync can trigger overlays.
    locationService.replace(
      locationUtil.getUrlForPartial(this._initialUrlState!, {
        editPanel: null,
        editview: null,
        inspect: null,
        inspectTab: null,
      })
    );

    // locationService.replace({ pathname: this._initialUrlState?.pathname, search: this._initialUrlState?.search });
    // Update state and disable editing
    this.setState({ ...this._initialState, isEditing: false });
    // and start url sync again
    this.startUrlSync();
    // Disable grid dragging
    this.propagateEditModeChange();
  }

  public canDiscard() {
    return this._initialState !== undefined;
  }

  public onRestore = async (version: DecoratedRevisionModel): Promise<boolean> => {
    const versionRsp = await historySrv.restoreDashboard(version.uid, version.version);

    if (!Number.isInteger(versionRsp.version)) {
      return false;
    }

    const dashboardDTO: DashboardDTO = {
      dashboard: new DashboardModel(version.data),
      meta: this.state.meta,
    };
    const dashScene = transformSaveModelToScene(dashboardDTO);
    const newState = sceneUtils.cloneSceneObjectState(dashScene.state);
    newState.version = versionRsp.version;

    this._initialState = newState;
    this.exitEditMode({ skipConfirm: false });

    return true;
  };

  public openSaveDrawer({ saveAsCopy }: { saveAsCopy?: boolean }) {
    if (!this.state.isEditing) {
      return;
    }

    this.setState({
      overlay: new SaveDashboardDrawer({
        dashboardRef: this.getRef(),
        saveAsCopy,
      }),
    });
  }

  public getPageNav(location: H.Location, navIndex: NavIndex) {
    const { meta, viewPanelScene, editPanel } = this.state;

    let pageNav: NavModelItem = {
      text: this.state.title,
      url: getDashboardUrl({
        uid: this.state.uid,
        slug: meta.slug,
        currentQueryParams: location.search,
        updateQuery: { viewPanel: null, inspect: null, editview: null, editPanel: null, tab: null },
      }),
    };

    const { folderUid } = meta;

    if (folderUid) {
      const folderNavModel = getNavModel(navIndex, `folder-dashboards-${folderUid}`).main;
      // If the folder hasn't loaded (maybe user doesn't have permission on it?) then
      // don't show the "page not found" breadcrumb
      if (folderNavModel.id !== 'not-found') {
        pageNav = {
          ...pageNav,
          parentItem: folderNavModel,
        };
      }
    }

    if (viewPanelScene) {
      pageNav = {
        text: 'View panel',
        parentItem: pageNav,
      };
    }

    if (editPanel) {
      pageNav = {
        text: 'Edit panel',
        parentItem: pageNav,
      };
    }

    return pageNav;
  }

  /**
   * Returns the body (layout) or the full view panel
   */
  public getBodyToRender(): SceneObject {
    return this.state.viewPanelScene ?? this.state.body;
  }

  private startTrackingChanges() {
    this._changeTrackerSub = this.subscribeToEvent(
      SceneObjectStateChangedEvent,
      (event: SceneObjectStateChangedEvent) => {
        if (event.payload.changedObject instanceof SceneRefreshPicker) {
          if (Object.prototype.hasOwnProperty.call(event.payload.partialUpdate, 'intervals')) {
            this.setIsDirty();
          }
        }
        if (event.payload.changedObject instanceof SceneDataLayers) {
          this.setIsDirty();
        }
        if (event.payload.changedObject instanceof dataLayers.AnnotationsDataLayer) {
          if (!Object.prototype.hasOwnProperty.call(event.payload.partialUpdate, 'data')) {
            this.setIsDirty();
          }
        }
        if (event.payload.changedObject instanceof SceneGridItem) {
          this.setIsDirty();
        }
        if (event.payload.changedObject instanceof DashboardScene) {
          if (Object.keys(event.payload.partialUpdate).some((key) => PERSISTED_PROPS.includes(key))) {
            this.setIsDirty();
          }
        }
        if (event.payload.changedObject instanceof SceneTimeRange) {
          this.setIsDirty();
        }
        if (event.payload.changedObject instanceof DashboardControls) {
          if (Object.prototype.hasOwnProperty.call(event.payload.partialUpdate, 'hideTimeControls')) {
            this.setIsDirty();
          }
        }
      }
    );
  }

  private setIsDirty() {
    if (!this.state.isDirty) {
      this.setState({ isDirty: true });
    }
  }

  private stopTrackingChanges() {
    this._changeTrackerSub?.unsubscribe();
  }

  public getInitialState(): DashboardSceneState | undefined {
    return this._initialState;
  }

  public addPanel(vizPanel: VizPanel): void {
    // TODO: need logic for adding a panel when other panels exist
    // This is the logic when dashboard is empty
    this.setState({
      body: new SceneGridLayout({
        children: [
          new SceneGridItem({
            height: 10,
            width: 10,
            x: 0.2,
            y: 0,
            body: vizPanel,
          }),
        ],
      }),
    });
  }

  public duplicatePanel(vizPanel: VizPanel) {
    if (!vizPanel.parent) {
      return;
    }

    const gridItem = vizPanel.parent;

    if (!(gridItem instanceof SceneGridItem || PanelRepeaterGridItem)) {
      console.error('Trying to duplicate a panel in a layout that is not SceneGridItem or PanelRepeaterGridItem');
      return;
    }

    let panelState;
    let panelData;
    if (gridItem instanceof PanelRepeaterGridItem) {
      const { key, ...gridRepeaterSourceState } = sceneUtils.cloneSceneObjectState(gridItem.state.source.state);
      panelState = { ...gridRepeaterSourceState };
      panelData = sceneGraph.getData(gridItem.state.source).clone();
    } else {
      const { key, ...gridItemPanelState } = sceneUtils.cloneSceneObjectState(vizPanel.state);
      panelState = { ...gridItemPanelState };
      panelData = sceneGraph.getData(vizPanel).clone();
    }

    // when we duplicate a panel we don't want to clone the alert state
    delete panelData.state.data?.alertState;

    const { key: gridItemKey, ...gridItemToDuplicateState } = sceneUtils.cloneSceneObjectState(gridItem.state);

    const newGridItem = new SceneGridItem({
      ...gridItemToDuplicateState,
      body: new VizPanel({ ...panelState, $data: panelData }),
    });

    if (!(this.state.body instanceof SceneGridLayout)) {
      console.error('Trying to duplicate a panel in a layout that is not SceneGridLayout ');
      return;
    }

    const sceneGridLayout = this.state.body;

    sceneGridLayout.setState({
      children: [...sceneGridLayout.state.children, newGridItem],
    });
  }

  public copyPanel(vizPanel: VizPanel) {
    if (!vizPanel.parent) {
      return;
    }

    const gridItem = vizPanel.parent;

    const jsonData = gridItemToPanel(gridItem);

    store.set(LS_PANEL_COPY_KEY, JSON.stringify(jsonData));
    appEvents.emit(AppEvents.alertSuccess, ['Panel copied. Click **Add panel** icon to paste.']);
  }

  public showModal(modal: SceneObject) {
    this.setState({ overlay: modal });
  }

  public closeModal() {
    this.setState({ overlay: undefined });
  }

  public async onStarDashboard() {
    const { meta, uid } = this.state;
    if (!uid) {
      return;
    }
    try {
      const result = await getDashboardSrv().starDashboard(uid, Boolean(meta.isStarred));

      this.setState({
        meta: {
          ...meta,
          isStarred: result,
        },
      });
    } catch (err) {
      console.error('Failed to star dashboard', err);
    }
  }

  public onOpenSettings = () => {
    locationService.partial({ editview: 'settings' });
  };

  public isEmpty = (): boolean => {
    const { body, viewPanelScene } = this.state;

    if (!!viewPanelScene) {
      return !!viewPanelScene.state.body;
    }

    if (body instanceof SceneFlexLayout || body instanceof SceneGridLayout) {
      return body.state.children.length === 0;
    }

    throw new Error('Invalid body type');
  };

  /**
   * Called by the SceneQueryRunner to privide contextural parameters (tracking) props for the request
   */
  public enrichDataRequest(sceneObject: SceneObject): Partial<DataQueryRequest> {
    const panel = getClosestVizPanel(sceneObject);
    let panelId = 0;

    if (panel && panel.state.key) {
      if (isPanelClone(panel.state.key)) {
        panelId = djb2Hash(panel?.state.key);
      } else {
        panelId = getPanelIdForVizPanel(panel);
      }
    }

    return {
      app: CoreApp.Dashboard,
      dashboardUID: this.state.uid,
      panelId,
      panelPluginId: panel?.state.pluginId,
    };
  }

  canEditDashboard() {
    const { meta } = this.state;

    return Boolean(meta.canEdit || meta.canMakeEditable);
  }

  public getInitialSaveModel() {
    return this._initialSaveModel;
  }

  /** Hacky temp function until we refactor transformSaveModelToScene a bit */
  public setInitialSaveModel(saveModel: Dashboard) {
    this._initialSaveModel = saveModel;
  }
}

export class DashboardVariableDependency implements SceneVariableDependencyConfigLike {
  private _emptySet = new Set<string>();

  getNames(): Set<string> {
    return this._emptySet;
  }

  public hasDependencyOn(): boolean {
    return false;
  }

  public variableUpdateCompleted(variable: SceneVariable, hasChanged: boolean) {
    if (hasChanged) {
      // Temp solution for some core panels (like dashlist) to know that variables have changed
      appEvents.publish(new VariablesChanged({ refreshAll: true, panelIds: [] }));
    }
  }
}
