import { App, Notice, TFile } from "obsidian";
import ExcalidrawPlugin from "src/core/main";
import { convertSVGStringToElement } from "../utils/utils";
import { FILENAMEPARTS, PreviewImageType } from "../types/utilTypes";
import { hasExcalidrawEmbeddedImagesTreeChanged } from "../utils/fileUtils";

//@ts-ignore
const DB_NAME = "Excalidraw " + app.appId;
const CACHE_STORE = "imageCache";
const BACKUP_STORE = "drawingBAK";

type FileCacheData = { mtime: number; blob?: Blob; svg?: string};
type BackupData = string;
type BackupKey = string;

export type ImageKey = {
  filepath: string;
  blockref: string;
  sectionref: string;
  isDark: boolean;
  previewImageType: PreviewImageType;
  scale: number;
  isTransparent: boolean;
  inlineFonts: boolean;
} & FILENAMEPARTS;

const getKey = (key: ImageKey): string =>
  `${key.filepath}#${key.blockref??""}#${key.sectionref??""}#${key.isDark ? 1 : 0}#${
    key.hasGroupref}#${key.hasArearef}#${key.hasFrameref}#${key.hasClippedFrameref}#${
    key.hasSectionref}#${key.inlineFonts}#${
    key.previewImageType === PreviewImageType.SVGIMG
      ? 1
      : key.previewImageType === PreviewImageType.PNG
        ? 0
        : 2 
  }#${key.scale}${key.isTransparent?"#t":""}`; //key.isSVG ? 1 : 0

class ImageCache {
  private dbName: string;
  private cacheStoreName: string;
  private backupStoreName: string;
  private db: IDBDatabase | null;
  private isInitializing: boolean;
  private plugin: ExcalidrawPlugin;
  private app: App;
  public initializationNotice: boolean = false;
  private obsidanURLCache = new Map<string, string>();
  private purgeInvalidCacheTimer: number = null;
  private purgeInvalidBackupTimer: number = null;

  public destroy(): void {
    this.isInitializing = true;
    if(this.purgeInvalidCacheTimer) clearTimeout(this.purgeInvalidCacheTimer);
    if(this.purgeInvalidBackupTimer) clearTimeout(this.purgeInvalidBackupTimer);
    this.db = null;
    this.plugin = null;
    this.app = null;
    this.obsidanURLCache.forEach((url) => URL.revokeObjectURL(url));
    this.obsidanURLCache.clear();
    this.obsidanURLCache = null;
  }

  constructor(dbName: string, cacheStoreName: string, backupStoreName: string) {
    this.dbName = dbName;
    this.cacheStoreName = cacheStoreName;
    this.backupStoreName = backupStoreName;
    this.db = null;
    this.isInitializing = false;
    this.plugin = null;
  }

  public async initializeDB(plugin: ExcalidrawPlugin): Promise<void> {
    this.plugin = plugin;
    this.app = plugin.app;
    if (this.isInitializing || this.db !== null) {
      return;
    }

    this.isInitializing = true;

    try {
      const request = indexedDB.open(this.dbName);

      request.onupgradeneeded = (event: IDBVersionChangeEvent) => {
        const db = (event.target as IDBOpenDBRequest).result;
        if (!db.objectStoreNames.contains(this.cacheStoreName)) {
          db.createObjectStore(this.cacheStoreName);
        }
        if (!db.objectStoreNames.contains(this.backupStoreName)) {
          db.createObjectStore(this.backupStoreName);
        }
      };

      this.db = await new Promise<IDBDatabase>((resolve, reject) => {
        request.onsuccess = (event: Event) => {
          const db = (event.target as IDBOpenDBRequest).result;
          resolve(db);
        };

        request.onerror = () => {
          reject(new Error(`Failed to open or create IndexedDB database: ${this.dbName}`));
        };
      });

      // Pre-create the object stores to reduce delay when accessing them later
      if (
        !this.db.objectStoreNames.contains(this.cacheStoreName) ||
        !this.db.objectStoreNames.contains(this.backupStoreName)
      ) {
        const version = this.db.version + 1;
        this.db.close();

        const upgradeRequest = indexedDB.open(this.dbName, version);
        upgradeRequest.onupgradeneeded = (event: IDBVersionChangeEvent) => {
          const db = (event.target as IDBOpenDBRequest).result;
          if (!db.objectStoreNames.contains(this.cacheStoreName)) {
            db.createObjectStore(this.cacheStoreName);
          }
          if (!db.objectStoreNames.contains(this.backupStoreName)) {
            db.createObjectStore(this.backupStoreName);
          }
        };

        await new Promise<void>((resolve, reject) => {
          upgradeRequest.onsuccess = () => {
            const db = upgradeRequest.result as IDBDatabase;
            db.close();
            resolve();
          };

          upgradeRequest.onerror = () => {
            reject(new Error(`Failed to upgrade IndexedDB database: ${this.dbName}`));
          };
        });

        this.db = await new Promise<IDBDatabase>((resolve, reject) => {
          const openRequest = indexedDB.open(this.dbName);
          openRequest.onsuccess = () => {
            const db = openRequest.result as IDBDatabase;
            resolve(db);
          };
          openRequest.onerror = () => {
            reject(new Error(`Failed to open IndexedDB database: ${this.dbName}`));
          };
        });
      }

      this.purgeInvalidCacheTimer = window.setTimeout(async ()=>{
        this.purgeInvalidCacheTimer = null;
        this.purgeInvalidCacheFiles();
      }, 60000);

      this.purgeInvalidBackupTimer = window.setTimeout(async ()=>{
        this.purgeInvalidBackupTimer = null;
        this.purgeInvalidBackupFiles();
      }, 120000);
    } finally {
      this.isInitializing = false;
      if(this.initializationNotice) {
        new Notice("Excalidraw Image Cache is Initialized - You may now retry opening your damaged drawing.");
        this.initializationNotice = false;
      }
      console.log("Initialized Excalidraw Image Cache");
    }
  }

  private async purgeInvalidCacheFiles(): Promise<void> {
    return new Promise<void>((resolve, reject) => {
      const transaction = this.db!.transaction(this.cacheStoreName, "readwrite");
      const store = transaction.objectStore(this.cacheStoreName);
      const files = this.app.vault.getFiles();
      const deletePromises: Promise<void>[] = [];
      const request = store.openCursor();
      request.onsuccess = (event: Event) => {
        const cursor = (event.target as IDBRequest<IDBCursorWithValue | null>).result;
        if(cursor) {
          const key = cursor.key as string;
          const isLegacyKey = key.split("#").length-1 < 12; // introduced hasGroupref, etc. in 1.9.28 // introduced hasClippedFrameref in 2.2.10 //introduced inlineFonts 2.2.11
          const filepath = key.split("#")[0];
          const fileExists = files.some((f: TFile) => f.path === filepath);
          const file = fileExists ? files.find((f: TFile) => f.path === filepath) : null;
          if (isLegacyKey || !file || (file && (file.stat.mtime > cursor.value.mtime)) || (!cursor.value.blob && !cursor.value.svg)) {
            if(this.obsidanURLCache.has(key)) {
              URL.revokeObjectURL(this.obsidanURLCache.get(key));
              this.obsidanURLCache.delete(key);
            }
            deletePromises.push(
              new Promise<void>((innerResolve, innerReject) => {
                const deleteRequest = store.delete(cursor.primaryKey);
                deleteRequest.onsuccess = () => innerResolve();
                deleteRequest.onerror = (ev: Event) => {
                  const error = deleteRequest.error;
                  const errorMsg = `Failed to delete file with key: ${key}. Error: ${error.message}`
                  innerReject(new Error(errorMsg));
                }
              })
            );
          }
          cursor.continue();
        } else {
          Promise.all(deletePromises)
            .then(() => {
              transaction.commit();
              resolve();
            })
            .catch((error) => reject(error));
        }
      };

      request.onerror = () => {
        const error = request.error;
        console.log(error);
        const errorMsg = `Failed to purge invalid files from IndexedDB. Error: ${error.message}`
        reject(new Error(errorMsg));
      };
    });
  }

  private async purgeInvalidBackupFiles(): Promise<void> {
    const transaction = this.db!.transaction(this.backupStoreName, "readwrite");
    const store = transaction.objectStore(this.backupStoreName);
    const files = this.app.vault.getFiles();
    const deletePromises: Promise<void>[] = [];
    const request = store.openCursor();
    return await new Promise<void>((resolve, reject) => {
      request.onsuccess = (event: Event) => {
        const cursor = (event.target as IDBRequest<IDBCursorWithValue | null>).result;
        if (cursor) {
          const key = cursor.key as BackupKey;
          const fileExists = files.some((f: TFile) => f.path === key);
          if (!fileExists) {
            deletePromises.push(
              new Promise<void>((resolve, reject) => {
                const deleteRequest = store.delete(cursor.primaryKey);
                deleteRequest.onsuccess = () => resolve();
                deleteRequest.onerror = () =>
                  reject(new Error(`Failed to delete backup file with key: ${key}`));
              })
            );
          }
          cursor.continue();
        } else {
          Promise.all(deletePromises)
            .then(() => {
              transaction.commit();
              resolve();
            })
            .catch((error) => reject(error));
        }
      };
  
      request.onerror = () => {
        const error = request.error;
        const errorMsg = `Failed to purge invalid backup files from IndexedDB. Error: ${error.message}`
        console.log(error);
        reject(new Error(errorMsg));
      };
    });
  }

  private getObjectStore(mode: IDBTransactionMode, storeName: string): IDBObjectStore {
    const transaction = this.db!.transaction(storeName, mode);
    return transaction.objectStore(storeName);
  }

  private async getCacheData(key: string): Promise<FileCacheData | null> {
    const store = this.getObjectStore("readonly", this.cacheStoreName);
    const request = store.get(key);

    return await new Promise<FileCacheData | null>((resolve, reject) => {
      request.onsuccess = () => {
        const result = request.result as FileCacheData;
        resolve(result || null);
      };

      request.onerror = () => {
        reject(new Error("Failed to retrieve data from IndexedDB."));
      };
    });
  }

  private async getBackupData(key: BackupKey): Promise<BackupData | null> {
    const store = this.getObjectStore("readonly", this.backupStoreName);
    const request = store.get(key);

    return new Promise<BackupData | null>((resolve, reject) => {
      request.onsuccess = () => {
        const result = request.result as BackupData;
        resolve(result || null);
      };

      request.onerror = () => {
        reject(new Error("Failed to retrieve backup data from IndexedDB."));
      };
    });
  }

  public isReady(): boolean {
    return !!this.db && !this.isInitializing && !!this.plugin && this.plugin.settings.allowImageCache;
  }

  private fullyInitialized = false;

  public async getImageFromCache(key_: ImageKey): Promise<string | SVGSVGElement | undefined> {
    if (!this.isReady()) {
      return null; // Database not initialized yet
    }

    const key = getKey(key_);

    try {
      const cachedData = this.fullyInitialized
      ? await this.getCacheData(key)
      : await Promise.race([
          this.getCacheData(key),
          new Promise<undefined>((_,reject) => setTimeout(() => {
            reject(undefined);
          }, 100))
        ]);
      this.fullyInitialized = true;
      if(!cachedData) return undefined;

      const file = this.app.vault.getAbstractFileByPath(key_.filepath.split("#")[0]);
      if (!file || !(file instanceof TFile)) return undefined;
      if (cachedData && (cachedData.mtime >= file.stat.mtime)) {
        if(hasExcalidrawEmbeddedImagesTreeChanged(file, cachedData.mtime, this.plugin)) {
          return undefined;
        }
        if(cachedData.svg) {
          return convertSVGStringToElement(cachedData.svg);
        }
        if(this.obsidanURLCache.has(key)) {
          return this.obsidanURLCache.get(key);
        }
        const obsidianURL = URL.createObjectURL(cachedData.blob);
        this.obsidanURLCache.set(key, obsidianURL);
        return obsidianURL;
      }
      return undefined;
    } catch(e) {
      return undefined;
    }
  }

  public async getBAKFromCache(filepath: string): Promise<BackupData | null> {
    if (!this.isReady()) {
      return null; // Database not initialized yet
    }

    return this.getBackupData(filepath);
  }

  //cache SVG should have the width and height parameters and not the embedded font
  public addImageToCache(key_: ImageKey, obsidianURL: string, image: Blob|SVGSVGElement): void {
    if (!this.isReady()) {
      return; // Database not initialized yet
    }

    const file = this.app.vault.getAbstractFileByPath(key_.filepath.split("#")[0]);
    if (!file || !(file instanceof TFile)) return;
    

    let svg: string = null;
    let blob: Blob = null;
    if(image instanceof SVGSVGElement) {
      svg = image.outerHTML;
    } else {
     blob = image as Blob;
    }
    const data: FileCacheData = { mtime: Date.now(), blob, svg};
    const transaction = this.db.transaction(this.cacheStoreName, "readwrite");
    const store = transaction.objectStore(this.cacheStoreName);
    const key = getKey(key_);
    store.put(data, key);
    if(!Boolean(svg)) {
      if(this.obsidanURLCache.has(key) && this.obsidanURLCache.get(key) !== obsidianURL) {
        URL.revokeObjectURL(this.obsidanURLCache.get(key));
      }
      this.obsidanURLCache.set(key, obsidianURL);
    }
  }

  public async addBAKToCache(filepath: string, data: BackupData): Promise<void> {
    if (!this.isReady()) {
      return; // Database not initialized yet
    }

    const transaction = this.db.transaction(this.backupStoreName, "readwrite");
    const store = transaction.objectStore(this.backupStoreName);
    store.put(data, filepath);
  }

  public async removeBAKFromCache(filepath: string): Promise<void> {
    if (!this.isReady()) {
      return; // Database not initialized yet
    }

    const transaction = this.db.transaction(this.backupStoreName, "readwrite");
    const store = transaction.objectStore(this.backupStoreName);
    
    return new Promise<void>((resolve, reject) => {
      const request = store.delete(filepath);
      request.onsuccess = () => {
        resolve();
      };
      request.onerror = () => {
        reject(new Error(`Failed to remove backup file with key: ${filepath}`));
      };
    });
  }

  public async clearImageCache(): Promise<void> {
    if (!this.isReady()) {
      return; // Database not initialized yet
    }

    this.obsidanURLCache.forEach((url) => URL.revokeObjectURL(url));
    this.obsidanURLCache.clear();

    return this.clear(this.cacheStoreName, "Image cache was cleared");
  }

  public async clearBackupCache(): Promise<void> {
    if (!this.isReady()) {
      return; // Database not initialized yet
    }

    return this.clear(this.backupStoreName, "All backups were cleared");
  }

  private async clear(storeName: string, message: string): Promise<void> {
    if (!this.isReady()) {
      return; // Database not initialized yet
    }
  
    const transaction = this.db.transaction([storeName], "readwrite");
    const store = transaction.objectStore(storeName);
  
    return new Promise<void>((resolve, reject) => {
      const request = store.clear();
      request.onsuccess = () => {
        new Notice(message);
        resolve();
      };
      request.onerror = () => reject(new Error(`Failed to clear ${storeName}.`));
    });
  }

}

export const imageCache = new ImageCache(DB_NAME, CACHE_STORE, BACKUP_STORE);

/* -------------------------------------------------------- */
/*                      Legacy cleanup                      */
// early implementation used localStorage as the image cache,
// which if not cleaned can cause Obsidian performance issues 
/* -------------------------------------------------------- */
const LEGACY_LS_PURGE_MARKER = "excalidraw-legacy-ls-purge-v1";

type LegacyKeyShape = {
  filepath: unknown;
  blockref: unknown;
  isDark: unknown;
  isSVG: unknown;
  scale: unknown;
};

const isObject = (value: unknown): value is Record<string, unknown> =>
  typeof value === "object" && value !== null && !Array.isArray(value);

const hasLegacyShape = (value: unknown): value is LegacyKeyShape => {
  if (!isObject(value)) return false;
  return (
    "filepath" in value &&
    "blockref" in value &&
    "isDark" in value &&
    "isSVG" in value &&
    "scale" in value
  );
};

export const purgeLegacyLocalStorageImageCache = (): string | null => {
  try {
    if (typeof window === "undefined" || !window.localStorage) {
      return null;
    }

    if (localStorage.getItem(LEGACY_LS_PURGE_MARKER) === "1") {
      return null;
    }

    const keys = Object.keys(localStorage);
    let deleted = 0;

    for (const key of keys) {
      let parsed: unknown;
      try {
        parsed = JSON.parse(key);
      } catch {
        continue;
      }

      if (!hasLegacyShape(parsed)) {
        continue;
      }

      localStorage.removeItem(key);
      deleted += 1;
    }

    localStorage.setItem(LEGACY_LS_PURGE_MARKER, "1");

    if (deleted === 0) {
      return null;
    }

    return "Purged " + deleted + " legacy localStorage image cache entr" + (deleted === 1 ? "y." : "ies.");
  } catch {
    return null;
  }
};

const report = purgeLegacyLocalStorageImageCache();
if (report) {
  console.info(report);
}