import { locationUtil, type UrlQueryMap } from '@grafana/data';
import { t } from '@grafana/i18n';
import { config, getBackendSrv, getDataSourceSrv, isFetchError, locationService } from '@grafana/runtime';
import { UserStorage } from '@grafana/runtime/internal';
import { sceneGraph } from '@grafana/scenes';
import {
  type Spec as DashboardV2Spec,
  type VariableKind,
  type DashboardLink,
} from '@grafana/schema/apis/dashboard.grafana.app/v2';
import {
  type GetRepositoryFilesWithPathApiResponse,
  provisioningAPIv0alpha1,
} from 'app/api/clients/provisioning/v0alpha1';
import { StateManagerBase } from 'app/core/services/StateManagerBase';
import { contextSrv } from 'app/core/services/context_srv';
import { getMessageFromError, getMessageIdFromError, getStatusFromError } from 'app/core/utils/errors';
import { startMeasure, stopMeasure } from 'app/core/utils/metrics';
import {
  AnnoKeyEmbedded,
  AnnoKeyFolder,
  AnnoKeyManagerIdentity,
  AnnoKeyManagerKind,
  AnnoKeySourcePath,
} from 'app/features/apiserver/types';
import { dashboardAPIVersionResolver } from 'app/features/dashboard/api/DashboardAPIVersionResolver';
import { ensureV2Response } from 'app/features/dashboard/api/ResponseTransformers';
import { DashboardVersionError, type DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
import {
  isDashboardResource,
  isDashboardV1Resource,
  isDashboardV2Resource,
  isDashboardV2Spec,
  isV2StoredVersion,
} from 'app/features/dashboard/api/utils';
import { initializeDashboardAnalyticsAggregator } from 'app/features/dashboard/services/DashboardAnalyticsAggregator';
import { dashboardLoaderSrv, DashboardLoaderSrvV2 } from 'app/features/dashboard/services/DashboardLoaderSrv';
import { getDashboardSceneProfiler } from 'app/features/dashboard/services/DashboardProfiler';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { initializeReportRenderReadinessObserver } from 'app/features/dashboard/services/ReportRenderReadinessObserver';
import { initializeScenePerformanceLogger } from 'app/features/dashboard/services/ScenePerformanceLogger';
import { emitDashboardViewEvent } from 'app/features/dashboard/state/analyticsProcessor';
import { trackDashboardSceneLoaded } from 'app/features/dashboard-scene/utils/tracking';
import { playlistSrv } from 'app/features/playlist/PlaylistSrv';
import { type ProvisioningPreview } from 'app/features/provisioning/types';
import { dispatch } from 'app/store/store';
import {
  type DashboardDataDTO,
  type DashboardDTO,
  DashboardRoutes,
  type HomeDashboardRedirectDTO,
  isRedirectResponse,
} from 'app/types/dashboard';

import { type PanelEditor } from '../panel-edit/PanelEditor';
import { type DashboardScene } from '../scene/DashboardScene';
import { buildNewDashboardSaveModel, buildNewDashboardSaveModelV2 } from '../serialization/buildNewDashboardSaveModel';
import { transformSaveModelSchemaV2ToScene } from '../serialization/transformSaveModelSchemaV2ToScene';
import {
  createV2RowsLayout,
  type SceneCreationOptions,
  transformSaveModelToScene,
} from '../serialization/transformSaveModelToScene';
import { restoreDashboardStateFromLocalStorage } from '../utils/dashboardSessionState';

import { processQueryParamsForDashboardLoad, updateNavModel } from './utils';

/**
 * Initialize both performance services to ensure they're ready before profiling starts
 */
function initializeDashboardPerformanceServices(): void {
  initializeScenePerformanceLogger();
  initializeDashboardAnalyticsAggregator();
}

export interface LoadError {
  status?: number;
  messageId?: string;
  message: string;
}

export interface DashboardScenePageState {
  dashboard?: DashboardScene;
  panelEditor?: PanelEditor;
  isLoading?: boolean;
  loadError?: LoadError;
}

export const DASHBOARD_CACHE_TTL = 500;

const LOAD_SCENE_MEASUREMENT = 'loadDashboardScene';

/** Only used by cache in loading home in DashboardPageProxy and initDashboard (Old arch), can remove this after old dashboard arch is gone */
export const HOME_DASHBOARD_CACHE_KEY = '__grafana_home_uid__';

interface DashboardCacheEntry<T> {
  dashboard: T;
  ts: number;
  cacheKey: string;
}

export interface LoadDashboardOptions {
  uid: string;
  route: DashboardRoutes;
  slug?: string;
  type?: string;
  urlFolderUid?: string;
  defaultVariables?: VariableKind[];
  defaultLinks?: DashboardLink[];
}

export type HomeDashboardDTO = DashboardDTO & {
  dashboard: DashboardDataDTO | DashboardV2Spec;
};

/**
 * Possible non-redirect return shapes of `/api/dashboards/home`. Either a
 * classic `{ meta, dashboard }` envelope (legacy `home.json`) or a
 * Kubernetes-style dashboard resource returned verbatim from the backend when
 * `default_home_dashboard_path` points at a `dashboard.grafana.app/*` file.
 */
export type HomeDashboardFetchResult =
  | HomeDashboardDTO
  | DashboardWithAccessInfo<DashboardV2Spec>
  | DashboardWithAccessInfo<DashboardDataDTO>;

interface DashboardScenePageStateManagerLike<T> {
  fetchDashboard(options: LoadDashboardOptions): Promise<T | null>;
  getDashboardFromCache(cacheKey: string): T | null;
  loadDashboard(options: LoadDashboardOptions): Promise<void>;
  transformResponseToScene(rsp: T | null, options: LoadDashboardOptions): DashboardScene | null;
  reloadDashboard(queryParams: UrlQueryMap): Promise<void>;
  loadSnapshot(slug: string): Promise<void>;
  setDashboardCache(cacheKey: string, dashboard: T): void;
  clearSceneCache(): void;
  clearDashboardCache(): void;
  clearState(): void;
  getCache(): Record<string, DashboardScene>;
  useState: () => DashboardScenePageState;
}

/**
 * Creates scene creation options with appropriate layout creator
 * based on feature flags and dashboard type.
 */
export function getSceneCreationOptions(
  loadOptions?: LoadDashboardOptions,
  meta?: { isSnapshot?: boolean }
): SceneCreationOptions | undefined {
  const isReport = loadOptions?.route === DashboardRoutes.Report;
  const isTemplate = loadOptions?.route === DashboardRoutes.Template;
  const isSnapshot = meta?.isSnapshot ?? false;

  // Don't use v2 layout for reports or snapshots
  if (isReport || isSnapshot || isTemplate) {
    return undefined;
  }

  // Use v2 layout creator when v2 API is enabled
  if (shouldForceV2API()) {
    return {
      createLayout: createV2RowsLayout,
      targetVersion: 'v2',
    };
  }

  return undefined;
}

abstract class DashboardScenePageStateManagerBase<T>
  extends StateManagerBase<DashboardScenePageState>
  implements DashboardScenePageStateManagerLike<T>
{
  abstract fetchDashboard(options: LoadDashboardOptions): Promise<T | null>;
  abstract reloadDashboard(queryParams: UrlQueryMap): Promise<void>;
  abstract transformResponseToScene(rsp: T | null, options: LoadDashboardOptions): DashboardScene | null;
  abstract loadSnapshotScene(slug: string): Promise<DashboardScene>;

  protected cache: Record<string, DashboardScene> = {};

  // This is a simplistic, short-term cache for DashboardDTOs to avoid fetching the same dashboard multiple times across a short time span.
  protected dashboardCache?: DashboardCacheEntry<T>;

  getCache(): Record<string, DashboardScene> {
    return this.cache;
  }

  /**
   * `/api/dashboards/home` can return one of three shapes:
   *   1. A redirect to another dashboard ({@link HomeDashboardRedirectDTO}).
   *   2. The legacy classic wrapper {@link HomeDashboardDTO} (`{ meta, dashboard }`) for
   *      classic `home.json`-style files.
   *   3. A Kubernetes-style dashboard resource ({@link DashboardWithAccessInfo}) when
   *      `default_home_dashboard_path` points at a `dashboard.grafana.app/*`
   *      manifest. In that case the backend returns the file verbatim with an
   *      injected `access` block.
   */
  protected async fetchHomeDashboard(): Promise<HomeDashboardFetchResult | null> {
    const rsp = await getBackendSrv().get<HomeDashboardFetchResult | HomeDashboardRedirectDTO>('/api/dashboards/home');

    if (isRedirectResponse(rsp)) {
      const newUrl = locationUtil.processRedirectUri(rsp.redirectUri, locationService.getLocation());
      locationService.replace(newUrl);
      return null;
    }

    // k8s-format responses already carry an `access` block from the backend;
    // classic responses still use the legacy `meta` flags.
    if (isDashboardResource(rsp)) {
      return rsp;
    }

    if (rsp.meta) {
      rsp.meta.canSave = false;
      rsp.meta.canShare = false;
      rsp.meta.canStar = false;
    }
    return rsp;
  }

  private async loadHomeDashboard(): Promise<DashboardScene | null> {
    const rsp = await this.fetchHomeDashboard();
    if (!rsp) {
      return null;
    }

    // k8s-format home dashboards (dashboard.grafana.app/*) are returned
    // verbatim by the backend. Route them through the native v1/v2 scene
    // builders directly. In particular, v2 resources go through the v2
    // builder which supports RowsLayout / TabsLayout / AutoGridLayout — not
    // just GridLayout, which was the only shape the old v2->v1 downconverter
    // could handle.
    if (isDashboardV2Resource(rsp)) {
      return transformSaveModelSchemaV2ToScene(rsp);
    }
    if (isDashboardV1Resource(rsp)) {
      return transformSaveModelToScene(
        {
          dashboard: rsp.spec,
          meta: {
            canSave: false,
            canShare: false,
            canStar: false,
            canEdit: false,
            canDelete: false,
            canAdmin: false,
            k8s: rsp.metadata,
          },
        },
        undefined,
        getSceneCreationOptions()
      );
    }

    // Neither v1 nor v2 k8s resource - must be the classic DashboardDTO shape.
    return transformSaveModelToScene(rsp, undefined, getSceneCreationOptions());
  }

  public async loadSnapshot(slug: string) {
    try {
      const dashboard = await this.loadSnapshotScene(slug);

      this.setState({ dashboard: dashboard, isLoading: false });
    } catch (err) {
      const status = getStatusFromError(err);
      const message = getMessageFromError(err);
      const messageId = getMessageIdFromError(err);

      this.setState({
        isLoading: false,
        loadError: {
          status,
          message,
          messageId,
        },
      });
      // If the error is a DashboardVersionError, we want to throw it so that the error boundary is triggered
      // This enables us to switch to the correct version of the dashboard
      if (err instanceof DashboardVersionError) {
        throw err;
      }
    }
  }

  /**
   * Loads the assistant preview dashboard from the user storage
   * @param base64EncodedKey base64 encoded key of the dashboard in the user storage
   * @returns Promise with dashboard data (can be v1 or v2 format) and meta
   */
  protected async loadAssistantPreviewDashboard(
    base64EncodedKey: string
  ): Promise<{ dashboard: DashboardDataDTO | DashboardV2Spec; meta: DashboardDTO['meta'] }> {
    const decodedKey = atob(decodeURIComponent(base64EncodedKey));
    const userStorage = new UserStorage('grafana-assistant-app');
    const dashboard = await userStorage.getItem(decodeURIComponent(decodedKey));
    if (!dashboard) {
      throw new Error('Dashboard not found');
    }
    let dashboardData: DashboardDataDTO | DashboardV2Spec;
    try {
      dashboardData = JSON.parse(dashboard);
    } catch (err) {
      throw new Error('Invalid dashboard data');
    }
    return {
      dashboard: dashboardData,
      meta: {
        canStar: false,
        canShare: false,
        canDelete: false,
        canSave: false,
        canEdit: false,
      },
    };
  }

  protected async loadProvisioningDashboard(repo: string, path: string): Promise<T> {
    const params = new URLSearchParams(window.location.search);
    const ref = params.get('ref') ?? undefined; // commit hash or branch

    const loadWithRef = async (refParam: string | undefined) => {
      const result = await dispatch(
        provisioningAPIv0alpha1.endpoints.getRepositoryFilesWithPath.initiate({
          name: repo,
          path: path,
          ref: refParam,
        })
      );

      if (result && 'error' in result) {
        throw result.error;
      }

      const v: GetRepositoryFilesWithPathApiResponse = structuredClone(result.data);
      // Load the results from dryRun
      const dryRun = v.resource.dryRun;
      if (!dryRun) {
        return Promise.reject('failed to read provisioned dashboard');
      }

      if (!dryRun.apiVersion.startsWith('dashboard.grafana.app')) {
        return Promise.reject('unexpected resource type: ' + dryRun.apiVersion);
      }

      return this.processDashboardFromProvisioning(repo, path, dryRun, {
        file: v.path ?? '',
        ref: refParam,
        repo: repo,
      });
    };

    try {
      return await loadWithRef(ref);
    } catch (err) {
      // If ref is not found (404), retry without ref to default to the main branch
      if (ref && isFetchError(err) && err.status === 404) {
        return await loadWithRef(undefined);
      }
      throw err;
    }
  }

  private processDashboardFromProvisioning(
    repo: string,
    path: string,
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    dryRun: any,
    provisioningPreview: ProvisioningPreview
  ) {
    if (dryRun.apiVersion.split('/')[1].startsWith('v2')) {
      return {
        ...dryRun,
        kind: 'DashboardWithAccessInfo',
        access: {
          canStar: false,
          isSnapshot: false,
          canShare: false,

          // Should come from the repo settings
          canDelete: true,
          canSave: true,
          canEdit: true,
        },
      };
    }

    let anno = dryRun.metadata.annotations;
    if (!anno) {
      dryRun.metadata.annotations = {};
    }
    anno[AnnoKeyManagerKind] = 'repo';
    anno[AnnoKeyManagerIdentity] = repo;
    anno[AnnoKeySourcePath] = provisioningPreview.ref ? path + '#' + provisioningPreview.ref : path;

    // Include version information to align with the current dashboard schema
    const specWithVersion = {
      ...dryRun.spec,
      version: dryRun.metadata.generation || 0,
    };

    return {
      meta: {
        canStar: false,
        isSnapshot: false,
        canShare: false,

        // Should come from the repo settings
        canDelete: true,
        canSave: true,
        canEdit: true,

        // Includes additional k8s metadata
        k8s: dryRun.metadata,

        // lookup info
        provisioning: provisioningPreview,
      },
      dashboard: specWithVersion,
    };
  }

  public async loadDashboard(options: LoadDashboardOptions) {
    try {
      startMeasure(LOAD_SCENE_MEASUREMENT);
      const dashboard = await this.loadScene(options);
      if (!dashboard) {
        return;
      }

      if (config.featureToggles.preserveDashboardStateWhenNavigating && Boolean(options.uid)) {
        restoreDashboardStateFromLocalStorage(dashboard);
      }

      this.setState({ dashboard: dashboard, isLoading: false });
      const measure = stopMeasure(LOAD_SCENE_MEASUREMENT);
      const queryController = sceneGraph.getQueryController(dashboard);

      trackDashboardSceneLoaded(dashboard, measure?.duration);

      const isRenderTarget =
        options.route === DashboardRoutes.Report ||
        options.route === DashboardRoutes.Embedded ||
        (options.route === DashboardRoutes.Normal && contextSrv.user?.authenticatedBy === 'render');
      const enableProfiling =
        config.dashboardPerformanceMetrics.findIndex((uid) => uid === '*' || uid === options.uid) !== -1 ||
        isRenderTarget;

      if (enableProfiling) {
        // Initialize both performance services before starting profiling to ensure observers are registered
        initializeDashboardPerformanceServices();

        // Force profiling on the query controller — it may have been constructed without it
        // (e.g. render targets where the UID isn't in dashboardPerformanceMetrics)
        if (queryController && !queryController.state.enableProfiling) {
          queryController.setState({ enableProfiling: true });
        }
      }

      if (isRenderTarget) {
        // Register the report render readiness observer so the image renderer can detect
        // when the dashboard has fully rendered (queries + transforms + fieldConfig + render)
        initializeReportRenderReadinessObserver();
      }

      // Start dashboard_view profiling (both services are now guaranteed to be listening)
      queryController?.startProfile('dashboard_view');

      if (options.route !== DashboardRoutes.New) {
        emitDashboardViewEvent({
          meta: dashboard.state.meta,
          uid: dashboard.state.uid,
          title: dashboard.state.title,
        });
      }
    } catch (err) {
      const status = getStatusFromError(err);
      const message = getMessageFromError(err);
      const messageId = getMessageIdFromError(err);

      this.setState({
        isLoading: false,
        loadError: {
          status,
          message,
          messageId,
        },
      });

      if (!isFetchError(err)) {
        console.error('Error loading dashboard:', err);
      }

      // If the error is a DashboardVersionError, we want to throw it so that the error boundary is triggered
      // This enables us to switch to the correct version of the dashboard
      if (err instanceof DashboardVersionError) {
        throw err;
      }
    }
  }

  private async loadScene(options: LoadDashboardOptions): Promise<DashboardScene | null> {
    this.setState({ dashboard: undefined, isLoading: true });

    // Resolve API versions before synchronous getV1()/getV2() calls downstream.
    await dashboardAPIVersionResolver.resolve();

    // Home dashboard is not handled through legacy API and is not versioned.
    // Handling home dashboard flow separately from regular dashboard flow.
    if (options.route === DashboardRoutes.Home) {
      return await this.loadHomeDashboard();
    }

    const rsp = await this.fetchDashboard(options);

    if (!rsp) {
      return null;
    }

    const scene = this.transformResponseToScene(rsp, options);

    return scene;
  }

  public getDashboardFromCache(cacheKey: string): T | null {
    const cachedDashboard = this.dashboardCache;

    if (
      cachedDashboard &&
      cachedDashboard.cacheKey === cacheKey &&
      Date.now() - cachedDashboard?.ts < DASHBOARD_CACHE_TTL
    ) {
      return cachedDashboard.dashboard;
    }

    return null;
  }

  public clearState() {
    getDashboardSrv().setCurrent(undefined);

    this.setState({
      dashboard: undefined,
      loadError: undefined,
      isLoading: false,
      panelEditor: undefined,
    });
  }

  public setDashboardCache(cacheKey: string, dashboard: T) {
    this.dashboardCache = { dashboard, ts: Date.now(), cacheKey };
  }

  public clearDashboardCache() {
    this.dashboardCache = undefined;
  }

  public getSceneFromCache(cacheKey: string) {
    return this.cache[cacheKey];
  }

  public setSceneCache(cacheKey: string, scene: DashboardScene) {
    this.cache[cacheKey] = scene;
  }

  public removeSceneCache(cacheKey: string) {
    delete this.cache[cacheKey];
  }

  public clearSceneCache() {
    this.cache = {};
  }
}

export class DashboardScenePageStateManager extends DashboardScenePageStateManagerBase<DashboardDTO> {
  transformResponseToScene(rsp: DashboardDTO | null, options: LoadDashboardOptions): DashboardScene | null {
    const fromCache = this.getSceneFromCache(options.uid);

    if (
      fromCache &&
      fromCache.state.version === rsp?.dashboard.version &&
      fromCache.state.meta.created === rsp?.meta.created
    ) {
      const profiler = getDashboardSceneProfiler();
      profiler.setMetadata({
        dashboardUID: fromCache.state.uid,
        dashboardTitle: fromCache.state.title,
      });
      return fromCache;
    }

    if (rsp?.dashboard) {
      const sceneCreationOptions = getSceneCreationOptions(options, rsp.meta);
      const scene = transformSaveModelToScene(rsp, options, sceneCreationOptions);

      // Special handling for Template route - set up edit mode and dirty state
      if (
        (config.featureToggles.dashboardLibrary ||
          config.featureToggles.suggestedDashboards ||
          config.featureToggles.dashboardTemplates) &&
        options.route === DashboardRoutes.Template
      ) {
        scene.setInitialSaveModel(rsp.dashboard, rsp.meta);
        scene.onEnterEditMode();
        scene.setState({ isDirty: true });
      }

      // Cache scene only if not coming from Explore, we don't want to cache temporary dashboard
      if (options.uid) {
        this.setSceneCache(options.uid, scene);
      }

      return scene;
    }

    throw new Error('Dashboard not found');
  }

  public async loadSnapshotScene(slug: string): Promise<DashboardScene> {
    const rsp = await dashboardLoaderSrv.loadSnapshot(slug);

    if (rsp?.dashboard) {
      if (isDashboardV2Spec(rsp.dashboard)) {
        throw new DashboardVersionError('v2beta1', 'Using legacy snapshot API to get a V2 dashboard');
      }

      rsp.meta.snapshotKey = slug;

      // Snapshots should use default v1 layout
      const scene = transformSaveModelToScene(rsp, undefined, getSceneCreationOptions(undefined, { isSnapshot: true }));
      return scene;
    }

    throw new Error('Snapshot not found');
  }

  private buildDashboardDTOFromInterpolated(interpolatedDashboard: DashboardDataDTO): DashboardDTO {
    return {
      dashboard: {
        ...interpolatedDashboard,
        uid: '',
        version: 0,
        id: null,
      },
      meta: {
        canSave: contextSrv.hasEditPermissionInFolders,
        canEdit: contextSrv.hasEditPermissionInFolders,
        canStar: false,
        canShare: false,
        canDelete: false,
        isNew: true,
        folderUid: '',
      },
    };
  }

  private async loadDashboardLibrary(): Promise<DashboardDTO> {
    // Extract template parameters from URL
    const searchParams = new URLSearchParams(window.location.search);
    const datasource = searchParams.get('datasource');
    const gnetId = searchParams.get('gnetId');
    const pluginId = searchParams.get('pluginId');

    const path = searchParams.get('path');

    // Check if this is a template dashboard
    if (gnetId && datasource && pluginId) {
      return this.loadTemplateDashboard(gnetId, datasource, pluginId);
    }

    // Check if this is a community dashboard (has gnetId) or plugin dashboard
    if (gnetId) {
      return this.loadCommunityTemplateDashboard(gnetId);
    }

    // Original plugin dashboard flow
    if (!datasource || !pluginId || !path) {
      throw new Error('Missing required parameters for template dashboard');
    }

    const ds = getDataSourceSrv().getInstanceSettings(datasource);
    if (!ds) {
      throw new Error(`Datasource "${datasource}" not found. Please check your datasource configuration.`);
    }

    const data = {
      pluginId,
      path,
      overwrite: true,
      inputs: [
        {
          name: '*',
          type: 'datasource',
          pluginId,
          value: datasource,
        },
      ],
    };

    const interpolatedDashboard = await getBackendSrv().post('/api/dashboards/interpolate', data);
    return this.buildDashboardDTOFromInterpolated(interpolatedDashboard);
  }

  private async loadTemplateDashboard(
    gnetId: string,
    datasource: string | null,
    pluginId: string | null
  ): Promise<DashboardDTO> {
    // Fetch the community dashboard from grafana.com
    const gnetDashboard = await getBackendSrv().get(`/api/gnet/dashboards/${gnetId}`);

    // The dashboard JSON is in the 'json' property
    const dashboardJson = gnetDashboard.json;

    const data = {
      dashboard: dashboardJson,
      overwrite: true,
      inputs: [
        {
          name: '*',
          type: 'datasource',
          pluginId: pluginId,
          value: datasource,
        },
      ],
    };

    const interpolatedDashboard = await getBackendSrv().post('/api/dashboards/interpolate', data);
    return this.buildDashboardDTOFromInterpolated(interpolatedDashboard);
  }

  private async loadCommunityTemplateDashboard(gnetId: number | string): Promise<DashboardDTO> {
    // Extract mappings from URL params
    const location = locationService.getLocation();
    const searchParams = new URLSearchParams(location.search);
    const mappingsJson = searchParams.get('mappings');

    if (!mappingsJson) {
      throw new Error('Missing mappings parameter for community dashboard');
    }

    let mappings;
    try {
      mappings = JSON.parse(mappingsJson);
    } catch (err) {
      throw new Error('Invalid mappings parameter: ' + err);
    }

    // Fetch the community dashboard from grafana.com
    const gnetDashboard = await getBackendSrv().get(`/api/gnet/dashboards/${gnetId}`);

    // The dashboard JSON is in the 'json' property
    const dashboardJson = gnetDashboard.json;

    // Call interpolate endpoint with the dashboard JSON and mappings
    const data = {
      dashboard: dashboardJson,
      overwrite: true,
      inputs: mappings,
    };

    const interpolatedDashboard = await getBackendSrv().post('/api/dashboards/interpolate', data);
    return this.buildDashboardDTOFromInterpolated(interpolatedDashboard);
  }

  public async fetchDashboard({
    type,
    slug,
    uid,
    route,
    urlFolderUid,
  }: LoadDashboardOptions): Promise<DashboardDTO | null> {
    const cacheKey = route === DashboardRoutes.Home ? HOME_DASHBOARD_CACHE_KEY : uid;

    const cachedDashboard = this.getDashboardFromCache(cacheKey);

    if (cachedDashboard) {
      return cachedDashboard;
    }

    let rsp: DashboardDTO;

    try {
      switch (route) {
        case DashboardRoutes.Home: {
          // For legacy dashboarding we keep this logic here, as dashboard can be loaded through state manager's fetchDashboard method directly
          // See DashboardPageProxy.
          const homeDashboard = await this.fetchHomeDashboard();

          if (!homeDashboard) {
            return null;
          }

          // A v2 k8s home resource cannot be represented as the legacy
          // DashboardDTO shape this method returns. Signal it the same way
          // other v2 paths in this method do so DashboardVersionError
          // handling can switch over to the v2 state manager.
          if (isDashboardV2Resource(homeDashboard)) {
            throw new DashboardVersionError('v2beta1', 'Home dashboard is a v2 resource');
          }

          // For v1 k8s resources, unwrap to the classic `{ meta, dashboard }`
          // shape the rest of this method expects.
          if (isDashboardV1Resource(homeDashboard)) {
            rsp = {
              dashboard: homeDashboard.spec,
              meta: {
                canSave: false,
                canShare: false,
                canStar: false,
                canEdit: false,
                canDelete: false,
                canAdmin: false,
                k8s: homeDashboard.metadata,
              },
            };
          } else {
            rsp = homeDashboard;
          }
          break;
        }
        case DashboardRoutes.New:
          rsp = await buildNewDashboardSaveModel(urlFolderUid);
          break;
        case DashboardRoutes.Template:
          rsp = await this.loadDashboardLibrary();
          break;
        case DashboardRoutes.Provisioning:
          return this.loadProvisioningDashboard(slug || '', uid);
        case DashboardRoutes.AssistantPreview: {
          const result = await this.loadAssistantPreviewDashboard(uid);
          if (isDashboardV2Spec(result.dashboard)) {
            // This will redirect to the v2 dashboard dashboard manager to correctly load a v2 dashboard
            throw new DashboardVersionError('v2beta1', 'Assistant preview contains V2 dashboard');
          }
          return {
            dashboard: result.dashboard,
            meta: result.meta,
          };
        }
        case DashboardRoutes.Public: {
          const result = await dashboardLoaderSrv.loadDashboard('public', '', uid);
          // public dashboards use legacy API but can return V2 dashboards
          // in this case we need to throw a dashboard version error so that the call can be delegated
          // to V2 state manager which will run fetchDashboard
          if (isDashboardV2Spec(result.dashboard)) {
            throw new DashboardVersionError('v2beta1', 'Using legacy public dashboard API to get a V2 dashboard');
          }
          return result;
        }
        default:
          // If reloadDashboardsOnParamsChange is on, we need to process query params for dashboard load
          // Since the scene is not yet there, we need to process whatever came through URL
          if (config.featureToggles.reloadDashboardsOnParamsChange) {
            const queryParamsObject = processQueryParamsForDashboardLoad();
            rsp = await dashboardLoaderSrv.loadDashboard(type || 'db', slug || '', uid, queryParamsObject);
          } else {
            rsp = await dashboardLoaderSrv.loadDashboard(type || 'db', slug || '', uid);
          }

          if (route === DashboardRoutes.Embedded) {
            rsp.meta.isEmbedded = true;
          }
      }

      // Fix outdated URLs (e.g., old slugs from title changes) but skip during playlist navigation
      // Playlists manage their own URL generation and redirects would break the navigation flow
      if (rsp.meta.url && route === DashboardRoutes.Normal && !playlistSrv.state.isPlaying) {
        const dashboardUrl = locationUtil.stripBaseFromUrl(rsp.meta.url);
        const currentPath = locationService.getLocation().pathname;

        if (dashboardUrl !== currentPath) {
          // Spread current location to persist search params used for navigation
          locationService.replace({
            ...locationService.getLocation(),
            pathname: dashboardUrl,
          });
          console.log('not correct url correcting', dashboardUrl, currentPath);
        }
      }

      // Populate nav model in global store according to the folder
      if (rsp.meta.folderUid) {
        await updateNavModel(rsp.meta.folderUid);
      }

      // Do not cache new dashboards
      this.setDashboardCache(cacheKey, rsp);
    } catch (e) {
      // Ignore cancelled errors
      if (isFetchError(e) && e.cancelled) {
        return null;
      }

      throw e;
    }

    return rsp;
  }

  public async reloadDashboard(queryParams: UrlQueryMap): Promise<void> {
    const dashboard = this.state.dashboard;

    if (!dashboard || !dashboard.state.uid) {
      return;
    }

    const uid = dashboard.state.uid;

    try {
      this.setState({ isLoading: true });

      const rsp = await dashboardLoaderSrv.loadDashboard('db', dashboard.state.meta.slug, uid, queryParams);
      const fromCache = this.getSceneFromCache(uid);

      // check if cached db version is same as both
      // response and current db state. There are scenarios where they can differ
      // e.g: when reloadOnParamsChange ff is on the first loaded dashboard could be version 0
      // then on this reload call the rsp increments the version. When the cache is not set
      // it creates a new scene based on the new rsp. But if we navigate to another dashboard
      // and then back to the initial one, the cache is still set, but the dashboard will be loaded
      // again with version 0. Because the cache is set with the incremented version and the rsp on
      // reload will match the cached version we return and do nothing, but the set scene is still
      // the one for the version 0 dashboard, thus we verify dashboard state version as well
      if (
        fromCache &&
        fromCache.state.version === rsp?.dashboard.version &&
        fromCache.state.version === this.state.dashboard?.state.version
      ) {
        this.setState({ isLoading: false });
        return;
      }

      if (!rsp?.dashboard) {
        this.setState({
          isLoading: false,
          loadError: {
            status: 404,
            message: t(
              'dashboard-scene.dashboard-scene-page-state-manager.message.dashboard-not-found',
              'Dashboard not found'
            ),
          },
        });
        return;
      }

      const sceneCreationOptions = getSceneCreationOptions(undefined, rsp.meta);
      const scene = transformSaveModelToScene(rsp, undefined, sceneCreationOptions);

      // we need to call and restore dashboard state on every reload that pulls a new dashboard version
      if (config.featureToggles.preserveDashboardStateWhenNavigating && Boolean(uid)) {
        restoreDashboardStateFromLocalStorage(scene);
      }

      this.setSceneCache(uid, scene);
      this.setState({ dashboard: scene, isLoading: false });
    } catch (err) {
      const status = getStatusFromError(err);
      const message = getMessageFromError(err);

      this.setState({
        isLoading: false,
        loadError: {
          message,
          status,
        },
      });

      if (err instanceof DashboardVersionError) {
        throw err;
      }
    }
  }
}

export class DashboardScenePageStateManagerV2 extends DashboardScenePageStateManagerBase<
  DashboardWithAccessInfo<DashboardV2Spec>
> {
  private dashboardLoader = new DashboardLoaderSrvV2();

  public async loadSnapshotScene(slug: string): Promise<DashboardScene> {
    const rsp = await this.dashboardLoader.loadSnapshot(slug);
    const v2Response = ensureV2Response(rsp);

    if (v2Response.spec) {
      const scene = transformSaveModelSchemaV2ToScene(v2Response);
      scene.setState({ meta: { ...scene.state.meta, snapshotKey: slug } });
      return scene;
    }

    throw new Error('Snapshot not found');
  }

  transformResponseToScene(
    rsp: DashboardWithAccessInfo<DashboardV2Spec> | null,
    options: LoadDashboardOptions
  ): DashboardScene | null {
    const fromCache = this.getSceneFromCache(options.uid);

    if (fromCache && fromCache.state.version === rsp?.metadata.generation) {
      const profiler = getDashboardSceneProfiler();
      profiler.setMetadata({
        dashboardUID: fromCache.state.uid,
        dashboardTitle: fromCache.state.title,
      });
      return fromCache;
    }

    if (rsp) {
      const scene = transformSaveModelSchemaV2ToScene(rsp, options);

      // Cache scene only if not coming from Explore, we don't want to cache temporary dashboard
      if (options.uid) {
        this.setSceneCache(options.uid, scene);
      }

      return scene;
    }

    throw new Error('Dashboard not found');
  }

  public async fetchDashboard({
    type,
    slug,
    uid,
    route,
    urlFolderUid,
  }: LoadDashboardOptions): Promise<DashboardWithAccessInfo<DashboardV2Spec> | null> {
    const cacheKey = route === DashboardRoutes.Home ? HOME_DASHBOARD_CACHE_KEY : uid;

    const cachedDashboard = this.getDashboardFromCache(cacheKey);
    if (cachedDashboard) {
      return cachedDashboard;
    }

    let rsp: DashboardWithAccessInfo<DashboardV2Spec>;
    try {
      switch (route) {
        case DashboardRoutes.New:
          rsp = await buildNewDashboardSaveModelV2(urlFolderUid);
          break;
        case DashboardRoutes.Provisioning: {
          return await this.loadProvisioningDashboard(slug || '', uid);
        }
        case DashboardRoutes.AssistantPreview: {
          const result = await this.loadAssistantPreviewDashboard(uid);
          if (!isDashboardV2Spec(result.dashboard)) {
            // This will redirect to the v1 dashboard dashboard manager to correctly load a v2 dashboard
            throw new DashboardVersionError('v0alpha1', 'Assistant preview contains V1 dashboard');
          }
          return {
            apiVersion: dashboardAPIVersionResolver.getV2(),
            kind: 'DashboardWithAccessInfo',
            metadata: {
              creationTimestamp: '',
              name: '',
              resourceVersion: '0',
            },
            spec: result.dashboard,
            access: {
              canSave: result.meta.canSave ?? false,
              canEdit: result.meta.canEdit ?? false,
              canDelete: result.meta.canDelete ?? false,
              canShare: result.meta.canShare ?? false,
              canStar: result.meta.canStar ?? false,
            },
          };
        }
        case DashboardRoutes.Public: {
          return await this.dashboardLoader.loadDashboard('public', '', uid);
        }
        default:
          if (config.featureToggles.reloadDashboardsOnParamsChange) {
            const queryParamsObject = processQueryParamsForDashboardLoad();
            rsp = await this.dashboardLoader.loadDashboard(type || 'db', slug || '', uid, queryParamsObject);
          } else {
            rsp = await this.dashboardLoader.loadDashboard(type || 'db', slug || '', uid);
          }

          if (route === DashboardRoutes.Embedded) {
            rsp.metadata.annotations = rsp.metadata.annotations || {};
            rsp.metadata.annotations[AnnoKeyEmbedded] = 'embedded';
          }
      }
      // Fix outdated URLs (e.g., old slugs from title changes) but skip during playlist navigation
      // Playlists manage their own URL generation and redirects would break the navigation flow
      if (rsp.access.url && route === DashboardRoutes.Normal && !playlistSrv.state.isPlaying) {
        const dashboardUrl = locationUtil.stripBaseFromUrl(rsp.access.url);
        const currentPath = locationService.getLocation().pathname;
        if (dashboardUrl !== currentPath) {
          // Spread current location to persist search params used for navigation
          locationService.replace({
            ...locationService.getLocation(),
            pathname: dashboardUrl,
          });
          console.log('not correct url correcting', dashboardUrl, currentPath);
        }
      }
      // Populate nav model in global store according to the folder
      if (rsp.metadata.annotations?.[AnnoKeyFolder]) {
        await updateNavModel(rsp.metadata.annotations?.[AnnoKeyFolder]);
      }
      // Do not cache new dashboards
      this.setDashboardCache(cacheKey, rsp);
    } catch (e) {
      // Ignore cancelled errors
      if (isFetchError(e) && e.cancelled) {
        return null;
      }
      throw e;
    }
    return rsp;
  }

  public async reloadDashboard(queryParams: UrlQueryMap): Promise<void> {
    const dashboard = this.state.dashboard;

    if (!dashboard || !dashboard.state.uid) {
      return;
    }

    const uid = dashboard.state.uid;

    try {
      this.setState({ isLoading: true });

      const rsp = await this.dashboardLoader.loadDashboard('db', dashboard.state.meta.slug, uid, queryParams);
      const fromCache = this.getSceneFromCache(uid);

      if (
        fromCache &&
        fromCache.state.version === rsp?.metadata.generation &&
        fromCache.state.version === this.state.dashboard?.state.version
      ) {
        this.setState({ isLoading: false });
        return;
      }

      if (!rsp?.spec) {
        this.setState({
          isLoading: false,
          loadError: {
            status: 404,
            message: t(
              'dashboard-scene.dashboard-scene-page-state-manager-v2.message.dashboard-not-found',
              'Dashboard not found'
            ),
          },
        });
        return;
      }

      const scene = transformSaveModelSchemaV2ToScene(rsp);

      // we need to call and restore dashboard state on every reload that pulls a new dashboard version
      if (config.featureToggles.preserveDashboardStateWhenNavigating && Boolean(uid)) {
        restoreDashboardStateFromLocalStorage(scene);
      }

      this.setSceneCache(uid, scene);

      this.setState({ dashboard: scene, isLoading: false });
    } catch (err) {
      const status = getStatusFromError(err);
      const message = getMessageFromError(err);
      this.setState({
        isLoading: false,
        loadError: {
          message,
          status,
        },
      });
      if (err instanceof DashboardVersionError) {
        throw err;
      }
    }
  }
}

export function shouldForceV2API(): boolean {
  return Boolean(config.featureToggles.dashboardNewLayouts);
}

export class UnifiedDashboardScenePageStateManager extends DashboardScenePageStateManagerBase<
  DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec>
> {
  private v1Manager: DashboardScenePageStateManager;
  private v2Manager: DashboardScenePageStateManagerV2;
  private activeManager: DashboardScenePageStateManager | DashboardScenePageStateManagerV2;

  constructor(initialState: Partial<DashboardScenePageState>) {
    super(initialState);
    this.v1Manager = new DashboardScenePageStateManager(initialState);
    this.v2Manager = new DashboardScenePageStateManagerV2(initialState);

    this.activeManager = shouldForceV2API() ? this.v2Manager : this.v1Manager;
  }

  private syncUnifiedStateToManager(manager: DashboardScenePageStateManager | DashboardScenePageStateManagerV2) {
    manager.setState({
      dashboard: this.state.dashboard,
      panelEditor: this.state.panelEditor,
      isLoading: this.state.isLoading,
      loadError: this.state.loadError,
    });
  }

  private async withVersionHandling<T>(
    operation: (manager: DashboardScenePageStateManager | DashboardScenePageStateManagerV2) => Promise<T>
  ): Promise<T> {
    try {
      return await operation(this.activeManager);
    } catch (error) {
      if (error instanceof DashboardVersionError) {
        const manager = isV2StoredVersion(error.data.storedVersion) ? this.v2Manager : this.v1Manager;
        this.activeManager = manager;
        return await operation(manager);
      } else {
        throw error;
      }
    } finally {
      // need to sync the state of the active manager with the unified manager
      // in cases when components are subscribed to unified manager's state
      this.setState(this.activeManager.state);
    }
  }

  public async fetchDashboard(options: LoadDashboardOptions) {
    return this.withVersionHandling<DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec> | null>((manager) =>
      manager.fetchDashboard(options)
    );
  }

  public async reloadDashboard(queryParams: UrlQueryMap) {
    return this.withVersionHandling((manager) => {
      if (manager instanceof DashboardScenePageStateManagerV2) {
        // V2 reload needs state synced into the active manager before reloading.
        this.syncUnifiedStateToManager(manager);
        return manager.reloadDashboard(queryParams);
      }

      // preserve existing v1 reload behavior
      return manager.reloadDashboard.call(this, queryParams);
    });
  }

  public getDashboardFromCache(uid: string) {
    return this.activeManager.getDashboardFromCache(uid);
  }

  transformResponseToScene(
    rsp: DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec> | null,
    options: LoadDashboardOptions
  ): DashboardScene | null {
    if (!rsp) {
      return null;
    }
    if (isDashboardV2Resource(rsp)) {
      this.activeManager = this.v2Manager;
      return this.v2Manager.transformResponseToScene(rsp, options);
    }

    return this.v1Manager.transformResponseToScene(rsp, options);
  }

  public async loadSnapshotScene(slug: string): Promise<DashboardScene> {
    try {
      return await this.v1Manager.loadSnapshotScene(slug);
    } catch (error) {
      if (error instanceof DashboardVersionError && isV2StoredVersion(error.data.storedVersion)) {
        return await this.v2Manager.loadSnapshotScene(slug);
      }
      throw new Error('Snapshot not found');
    }
  }

  public async loadSnapshot(slug: string) {
    return this.withVersionHandling((manager) => {
      if (manager instanceof DashboardScenePageStateManagerV2) {
        // we need to post-sync here before withVersionHandling will sync the state back to the unified manager
        return manager.loadSnapshot.call(this, slug).then(() => {
          this.syncUnifiedStateToManager(manager);
        });
      }

      return manager.loadSnapshot.call(this, slug);
    });
  }

  public clearDashboardCache() {
    this.v1Manager.clearDashboardCache();
    this.v2Manager.clearDashboardCache();
  }

  public clearSceneCache() {
    this.v1Manager.clearSceneCache();
    this.v2Manager.clearSceneCache();
    this.cache = {};
  }

  public getSceneFromCache(key: string) {
    return this.activeManager.getSceneFromCache(key);
  }

  public setSceneCache(cacheKey: string, scene: DashboardScene): void {
    this.activeManager.setSceneCache(cacheKey, scene);
  }

  public removeSceneCache(cacheKey: string): void {
    this.v1Manager.removeSceneCache(cacheKey);
    this.v2Manager.removeSceneCache(cacheKey);
  }

  public getCache() {
    return this.activeManager.getCache();
  }

  public setDashboardCache(cacheKey: string, dashboard: DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec>) {
    if (isDashboardV2Resource(dashboard)) {
      this.v2Manager.setDashboardCache(cacheKey, dashboard);
    } else {
      this.v1Manager.setDashboardCache(cacheKey, dashboard);
    }
  }

  public async loadDashboard(options: LoadDashboardOptions): Promise<void> {
    if (options.route === DashboardRoutes.New) {
      const newDashboardVersion = shouldForceV2API() ? 'v2' : 'v1';
      this.setActiveManager(newDashboardVersion);
    }

    // Template dashboards are currently in v1 schema format.
    if (options.route === DashboardRoutes.Template) {
      this.setActiveManager('v1');
    }

    return this.withVersionHandling((manager) => {
      if (manager instanceof DashboardScenePageStateManagerV2) {
        // we need to post-sync here before withVersionHandling will sync the state back to the unified manager
        return manager.loadDashboard.call(this, options).then(() => {
          this.syncUnifiedStateToManager(manager);
        });
      }

      return manager.loadDashboard.call(this, options);
    });
  }

  public setActiveManager(manager: 'v1' | 'v2') {
    if (manager === 'v1') {
      this.activeManager = this.v1Manager;
    } else {
      this.activeManager = this.v2Manager;
    }
  }
  public resetActiveManager() {
    this.activeManager = shouldForceV2API() ? this.v2Manager : this.v1Manager;
  }
}

const managers: {
  v1?: DashboardScenePageStateManager;
  v2?: DashboardScenePageStateManagerV2;
  unified?: UnifiedDashboardScenePageStateManager;
} = {
  v1: undefined,
  v2: undefined,
  unified: undefined,
};

export function getDashboardScenePageStateManager(): UnifiedDashboardScenePageStateManager;
export function getDashboardScenePageStateManager(v: 'v1'): DashboardScenePageStateManager;
export function getDashboardScenePageStateManager(v: 'v2'): DashboardScenePageStateManagerV2;

export function getDashboardScenePageStateManager(v?: 'v1' | 'v2') {
  if (v === 'v1') {
    if (!managers.v1) {
      managers.v1 = new DashboardScenePageStateManager({});
    }
    return managers.v1;
  }

  if (v === 'v2') {
    if (!managers.v2) {
      managers.v2 = new DashboardScenePageStateManagerV2({});
    }
    return managers.v2;
  }

  if (!managers.unified) {
    managers.unified = new UnifiedDashboardScenePageStateManager({});
  }

  return managers.unified;
}
