import { debounce, isEqual } from 'lodash';

import { urlUtil } from '@grafana/data';
import { SceneObject, SceneObjectRef, SceneObjectUrlValues, sceneUtils } from '@grafana/scenes';
import { dispatch } from 'app/store/store';

import { notifyApp } from '../../../core/reducers/appNotification';
import { DataTrail } from '../DataTrail';
import { TrailStepType } from '../DataTrailsHistory';
import { TRAIL_BOOKMARKS_KEY, RECENT_TRAILS_KEY } from '../shared';
import { newMetricsTrail } from '../utils';

import { createBookmarkSavedNotification } from './utils';

const MAX_RECENT_TRAILS = 20;

export interface SerializedTrailHistory {
  urlValues: SceneObjectUrlValues;
  type: TrailStepType;
  description: string;
  parentIndex: number;
}

export interface SerializedTrail {
  history: SerializedTrailHistory[];
  currentStep?: number; // Assume last step in history if not specified
  createdAt?: number;
}

export interface DataTrailBookmark {
  urlValues: SceneObjectUrlValues;
  createdAt: number;
}

export class TrailStore {
  private _recent: Array<SceneObjectRef<DataTrail>> = [];
  private _bookmarks: DataTrailBookmark[] = [];
  private _save: () => void;

  constructor() {
    this.load();

    const doSave = () => {
      const serializedRecent = this._recent
        .slice(0, MAX_RECENT_TRAILS)
        .map((trail) => this._serializeTrail(trail.resolve()));
      localStorage.setItem(RECENT_TRAILS_KEY, JSON.stringify(serializedRecent));

      localStorage.setItem(TRAIL_BOOKMARKS_KEY, JSON.stringify(this._bookmarks));
    };

    this._save = debounce(doSave, 1000);

    window.addEventListener('beforeunload', (ev) => {
      // Before closing or reloading the page, we want to remove the debounce from `_save` so that
      // any calls to is on event `unload` are actualized. Debouncing would cause a delay until after the page has been unloaded.
      this._save = doSave;
    });
  }

  private _loadRecentTrailsFromStorage() {
    const list: Array<SceneObjectRef<DataTrail>> = [];
    const storageItem = localStorage.getItem(RECENT_TRAILS_KEY);

    if (storageItem) {
      const serializedTrails: SerializedTrail[] = JSON.parse(storageItem);
      for (const t of serializedTrails) {
        const trail = this._deserializeTrail(t);
        list.push(trail.getRef());
      }
    }
    return list;
  }

  private _loadBookmarksFromStorage() {
    const storageItem = localStorage.getItem(TRAIL_BOOKMARKS_KEY);

    const list: Array<DataTrailBookmark | SerializedTrail> = storageItem ? JSON.parse(storageItem) : [];

    return list.map((item) => {
      if (isSerializedTrail(item)) {
        // Take the legacy SerializedTrail implementation of bookmark storage, and extract a DataTrailBookmark
        const step = item.currentStep != null ? item.currentStep : item.history.length - 1;
        const bookmark: DataTrailBookmark = {
          urlValues: item.history[step].urlValues,
          createdAt: item.createdAt || Date.now(),
        };
        return bookmark;
      }
      return item;
    });
  }

  private _deserializeTrail(t: SerializedTrail): DataTrail {
    // reconstruct the trail based on the serialized history
    const trail = new DataTrail({ createdAt: t.createdAt });

    t.history.map((step) => {
      this._loadFromUrl(trail, step.urlValues);
      const parentIndex = step.parentIndex ?? trail.state.history.state.steps.length - 1;
      // Set the parent of the next trail step by setting the current step in history.
      trail.state.history.setState({ currentStep: parentIndex });
      trail.state.history.addTrailStepFromStorage(trail, step);
    });

    const currentStep = t.currentStep ?? trail.state.history.state.steps.length - 1;

    trail.state.history.setState({ currentStep });

    trail.setState(
      sceneUtils.cloneSceneObjectState(trail.state.history.state.steps[currentStep].trailState, {
        history: trail.state.history,
      })
    );

    return trail;
  }

  private _serializeTrail(trail: DataTrail): SerializedTrail {
    const history = trail.state.history.state.steps.map((step) => {
      const stepTrail = new DataTrail(sceneUtils.cloneSceneObjectState(step.trailState));
      return {
        urlValues: sceneUtils.getUrlState(stepTrail),
        type: step.type,
        description: step.description,
        parentIndex: step.parentIndex,
      };
    });
    return {
      history,
      currentStep: trail.state.history.state.currentStep,
      createdAt: trail.state.createdAt,
    };
  }

  public getTrailForBookmarkIndex(index: number) {
    const bookmark = this._bookmarks[index];
    if (!bookmark) {
      // Create a blank trail
      return newMetricsTrail();
    }
    return this.getTrailForBookmark(bookmark);
  }

  public getTrailForBookmark(bookmark: DataTrailBookmark) {
    const key = getBookmarkKey(bookmark);
    // Match for recent trails that have the exact same state as the current step
    for (const recent of this._recent) {
      const trail = recent.resolve();
      if (getBookmarkKey(trail) === key) {
        return trail;
      }
    }
    // Just create a new trail with that state

    const trail = new DataTrail({});
    this._loadFromUrl(trail, bookmark.urlValues);
    return trail;
  }

  private _loadFromUrl(node: SceneObject, urlValues: SceneObjectUrlValues) {
    const urlState = urlUtil.renderUrl('', urlValues);
    sceneUtils.syncStateFromSearchParams(node, new URLSearchParams(urlState));
  }

  // Recent Trails
  get recent() {
    return this._recent;
  }

  load() {
    this._recent = this._loadRecentTrailsFromStorage();
    this._bookmarks = this._loadBookmarksFromStorage();
    this._refreshBookmarkIndexMap();
  }

  setRecentTrail(recentTrail: DataTrail) {
    const { steps } = recentTrail.state.history.state;
    if (steps.length === 0 || (steps.length === 1 && steps[0].type === 'start')) {
      // We do not set an uninitialized trail, or a single node "start" trail as recent
      return;
    }

    // Remove the `recentTrail` from the list if it already exists there
    this._recent = this._recent.filter((t) => t !== recentTrail.getRef());

    // Check if any existing "recent" entries have equivalent urlState to the new recentTrail
    const recentUrlState = getUrlStateForComparison(recentTrail); //
    this._recent = this._recent.filter((t) => {
      // Use the current step urlValues to filter out equivalent states
      const urlState = getUrlStateForComparison(t.resolve());
      // Only keep trails with sufficiently unique urlValues on their current step
      return !isEqual(recentUrlState, urlState);
    });

    this._recent.unshift(recentTrail.getRef());
    this._save();
  }

  // Bookmarked Trails
  get bookmarks() {
    return this._bookmarks;
  }

  addBookmark(trail: DataTrail) {
    const urlState = sceneUtils.getUrlState(trail);

    const bookmarkState: DataTrailBookmark = {
      urlValues: urlState,
      createdAt: Date.now(),
    };

    this._bookmarks.unshift(bookmarkState);
    this._refreshBookmarkIndexMap();
    this._save();
    dispatch(notifyApp(createBookmarkSavedNotification()));
  }

  removeBookmark(index: number) {
    if (index < this._bookmarks.length) {
      this._bookmarks.splice(index, 1);
      this._refreshBookmarkIndexMap();
      this._save();
    }
  }

  getBookmarkIndex(trail: DataTrail) {
    const bookmarkKey = getBookmarkKey(trail);
    const bookmarkIndex = this._bookmarkIndexMap.get(bookmarkKey);
    return bookmarkIndex;
  }

  private _bookmarkIndexMap = new Map<string, number>();

  private _refreshBookmarkIndexMap() {
    this._bookmarkIndexMap.clear();
    this._bookmarks.forEach((bookmarked, index) => {
      const key = getBookmarkKey(bookmarked);
      // If there are duplicate bookmarks, the latest index will be kept
      this._bookmarkIndexMap.set(key, index);
    });
  }
}

function getUrlStateForComparison(trail: DataTrail) {
  const urlState = sceneUtils.getUrlState(trail);
  // Make a few corrections
  correctUrlStateForComparison(urlState);

  return urlState;
}

function correctUrlStateForComparison(urlState: SceneObjectUrlValues) {
  // Omit some URL parameters that are not useful for state comparison,
  // as they can change in the URL without creating new steps
  delete urlState.actionView;
  delete urlState.layout;
  delete urlState.metricSearch;
  delete urlState.refresh;

  // Populate defaults
  if (urlState['var-groupby'] === '' || urlState['var-groupby'] === undefined) {
    urlState['var-groupby'] = '$__all';
  }

  if (typeof urlState['var-filters'] !== 'string') {
    urlState['var-filters'] = urlState['var-filters']?.filter((filter) => filter !== '');
  }

  return urlState;
}

export function getBookmarkKey(trail: DataTrail | DataTrailBookmark) {
  if (trail instanceof DataTrail) {
    return JSON.stringify(getUrlStateForComparison(trail));
  }
  return JSON.stringify(correctUrlStateForComparison({ ...trail.urlValues }));
}

let store: TrailStore | undefined;
export function getTrailStore(): TrailStore {
  if (!store) {
    store = new TrailStore();
  }

  return store;
}

function isSerializedTrail(serialized: unknown): serialized is SerializedTrail {
  return serialized != null && typeof serialized === 'object' && 'history' in serialized;
}
