/**
 * Copyright 2015 CANAL+ Group
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import type { IMediaKeySession } from "../../../compat/browser_compatibility_types";
import log from "../../../log";
import type {
  IPersistentLicenseConfig,
  IPersistentSessionInfo,
} from "../../../public_types";
import areArraysOfNumbersEqual from "../../../utils/are_arrays_of_numbers_equal";
import { assertInterface } from "../../../utils/assert";
import { bytesToBase64 } from "../../../utils/base64";
import hashBuffer from "../../../utils/hash_buffer";
import isNonEmptyString from "../../../utils/is_non_empty_string";
import isNullOrUndefined from "../../../utils/is_null_or_undefined";
import type { IProcessedProtectionData } from "../types";
import areInitializationValuesCompatible from "./are_init_values_compatible";
import type { IFormattedInitDataValue } from "./init_data_values_container";
import SerializableBytes from "./serializable_bytes";

/**
 * Throw if the given storage does not respect the right interface.
 * @param {Object} storage
 */
function checkStorage(storage: IPersistentLicenseConfig): void {
  assertInterface(
    storage,
    { save: "function", load: "function" },
    "persistentLicenseConfig",
  );
}

/**
 * Set representing persisted licenses. Depends on a simple
 * implementation with a `save`/`load` synchronous interface
 * to persist information on persisted sessions.
 *
 * This set is used only for a cdm/keysystem with license persistency
 * supported.
 * @class PersistentSessionsStore
 */
export default class PersistentSessionsStore {
  private readonly _storage: IPersistentLicenseConfig;
  private _entries: IPersistentSessionInfo[];

  /**
   * Create a new PersistentSessionsStore.
   * @param {Object} storage
   */
  constructor(storage: IPersistentLicenseConfig) {
    checkStorage(storage);
    this._entries = [];
    this._storage = storage;
    try {
      let entries = this._storage.load();
      if (!Array.isArray(entries)) {
        entries = [];
      }
      this._entries = entries;
    } catch (e) {
      log.warn(
        "DRM",
        "Persistent store: Could not get entries from license storage",
        e instanceof Error ? e : "",
      );
      this.dispose();
    }
  }

  /**
   * Returns the number of stored values.
   * @returns {number}
   */
  public getLength(): number {
    return this._entries.length;
  }

  /**
   * Returns information about all stored MediaKeySession, in the order in which
   * the MediaKeySession have been created.
   * @returns {Array.<Object>}
   */
  public getAll(): IPersistentSessionInfo[] {
    return this._entries;
  }

  /**
   * Retrieve an entry based on its initialization data.
   * @param {Object}  initData
   * @returns {Object|null}
   */
  public get(initData: IProcessedProtectionData): IPersistentSessionInfo | null {
    const index = this._getIndex(initData);
    return index === -1 ? null : this._entries[index];
  }

  /**
   * Like `get`, but also move the corresponding value at the end of the store
   * (as returned by `getAll`) if found.
   * This can be used for example to tell when a previously-stored value is
   * re-used to then be able to implement a caching replacement algorithm based
   * on the least-recently-used values by just evicting the first values
   * returned by `getAll`.
   * @param {Object} initData
   * @returns {*}
   */
  public getAndReuse(initData: IProcessedProtectionData): IPersistentSessionInfo | null {
    const index = this._getIndex(initData);
    if (index === -1) {
      return null;
    }
    const item = this._entries.splice(index, 1)[0];
    this._entries.push(item);
    return item;
  }

  /**
   * Add a new entry in the PersistentSessionsStore.
   * @param {Object}  initData
   * @param {Array.<Uint8Array>|undefined} keyIds
   * @param {MediaKeySession} session
   */
  public add(
    initData: IProcessedProtectionData,
    keyIds: Uint8Array[] | undefined,
    session: IMediaKeySession,
  ): void {
    if (isNullOrUndefined(session) || !isNonEmptyString(session.sessionId)) {
      log.warn("DRM", "Persistent Store: Invalid Persisten Session given.");
      return;
    }
    const { sessionId } = session;
    const currentIndex = this._getIndex(initData);
    if (currentIndex >= 0) {
      const currVersion = keyIds === undefined ? 3 : 4;
      const currentEntry = this._entries[currentIndex];
      const entryVersion = currentEntry.version ?? -1;
      if (entryVersion >= currVersion && sessionId === currentEntry.sessionId) {
        return;
      }
      log.info("DRM", "Persistent Store: Updating session info.", { sessionId });
      this._entries.splice(currentIndex, 1);
    } else {
      log.info("DRM", "Persistent Store: Add new session", { sessionId });
    }

    const storedValues = prepareValuesForStore(initData.values.getFormattedValues());
    if (keyIds === undefined) {
      this._entries.push({
        version: 3,
        sessionId,
        values: storedValues,
        initDataType: initData.type,
      });
    } else {
      this._entries.push({
        version: 4,
        sessionId,
        keyIds: keyIds.map((k) => new SerializableBytes(k)),
        values: storedValues,
        initDataType: initData.type,
      });
    }
    this._save();
  }

  /**
   * Delete stored MediaKeySession information based on its session id.
   * @param {string} sessionId
   */
  public delete(sessionId: string): void {
    let index = -1;
    for (let i = 0; i < this._entries.length; i++) {
      const entry = this._entries[i];
      if (entry.sessionId === sessionId) {
        index = i;
        break;
      }
    }
    if (index === -1) {
      log.warn(
        "DRM",
        "Persistent Store: initData to delete not found in persistent store.",
      );
      return;
    }
    const entry = this._entries[index];
    log.warn("DRM", "Persistent Store: Delete session from persistent store", {
      sessionId: entry.sessionId,
    });
    this._entries.splice(index, 1);
    this._save();
  }

  public deleteOldSessions(sessionsToDelete: number): void {
    log.info("DRM", "Persistent Store: Deleting last sessions.", { sessionsToDelete });
    if (sessionsToDelete <= 0) {
      return;
    }
    if (sessionsToDelete <= this._entries.length) {
      this._entries.splice(0, sessionsToDelete);
    } else {
      log.warn(
        "DRM",
        "Persistent Store: Asked to remove more information that it contains",
        sessionsToDelete,
        this._entries.length,
      );
      this._entries = [];
    }
    this._save();
  }

  /**
   * Delete all saved entries.
   */
  public dispose(): void {
    this._entries = [];
    this._save();
  }

  /**
   * Retrieve index of an entry.
   * Returns `-1` if not found.
   * @param {Object} initData
   * @returns {number}
   */
  private _getIndex(initData: IProcessedProtectionData): number {
    // Older versions of the format include a concatenation of all
    // initialization data and its hash.
    // This is only computed lazily, the first time it is needed.
    let lazyConcatenatedData: null | {
      initData: Uint8Array;
      initDataHash: number;
    } = null;
    function getConcatenatedInitDataInfo() {
      if (lazyConcatenatedData === null) {
        const concatInitData = initData.values.constructRequestData();
        lazyConcatenatedData = {
          initData: concatInitData,
          initDataHash: hashBuffer(concatInitData),
        };
      }
      return lazyConcatenatedData;
    }

    for (let i = 0; i < this._entries.length; i++) {
      const entry = this._entries[i];
      if (entry.initDataType === initData.type) {
        switch (entry.version) {
          case 4:
            if (Array.isArray(initData.keyIds) && initData.keyIds.length > 0) {
              const foundCompatible = initData.keyIds.every((keyId) => {
                const keyIdB64 = bytesToBase64(keyId);
                for (const entryKid of entry.keyIds) {
                  if (typeof entryKid === "string") {
                    if (keyIdB64 === entryKid) {
                      log.debug(
                        "DRM",
                        "Persistent Store: Found wanted kid stored on entry",
                        {
                          index: i,
                          keyId: keyIdB64,
                          sessionId: entry.sessionId,
                        },
                      );
                      return true;
                    }
                  } else if (areArraysOfNumbersEqual(entryKid.initData, keyId)) {
                    if (log.hasLevel("DEBUG")) {
                      log.debug(
                        "DRM",
                        "Persistent Store: Found wanted kid stored on entry",
                        {
                          index: i,
                          keyId: keyIdB64,
                          sessionId: entry.sessionId,
                        },
                      );
                    }
                    return true;
                  }
                }
                return false;
              });
              if (foundCompatible) {
                log.debug("DRM", "Persistent Store: Found compatible entry of v4", {
                  index: i,
                  sessionId: entry.sessionId,
                });
                return i;
              }
            } else {
              const formatted = initData.values.getFormattedValues();
              if (areInitializationValuesCompatible(formatted, entry.values)) {
                log.debug(
                  "DRM",
                  "Persistent Store: Found compatible entry of v4 without init data",
                  {
                    index: i,
                    sessionId: entry.sessionId,
                  },
                );
                return i;
              }
            }
            break;

          case 3: {
            const formatted = initData.values.getFormattedValues();
            if (areInitializationValuesCompatible(formatted, entry.values)) {
              log.debug(
                "DRM",
                "Persistent Store: Found compatible entry of v3 - same values",
                {
                  index: i,
                  sessionId: entry.sessionId,
                },
              );
              return i;
            }
            break;
          }

          case 2: {
            const { initData: concatInitData, initDataHash: concatHash } =
              getConcatenatedInitDataInfo();
            if (entry.initDataHash === concatHash) {
              try {
                const decodedInitData: Uint8Array =
                  typeof entry.initData === "string"
                    ? SerializableBytes.decode(entry.initData)
                    : entry.initData.initData;
                if (areArraysOfNumbersEqual(decodedInitData, concatInitData)) {
                  log.debug(
                    "DRM",
                    "Persistent Store: Found compatible entry of v2 - same concatenated init data",
                    {
                      index: i,
                      sessionId: entry.sessionId,
                    },
                  );
                  return i;
                }
              } catch (e) {
                log.warn(
                  "DRM",
                  "Persistent Store: Could not decode initialization data.",
                  e instanceof Error ? e : "",
                );
              }
            }
            break;
          }

          case 1: {
            const { initData: concatInitData, initDataHash: concatHash } =
              getConcatenatedInitDataInfo();
            if (entry.initDataHash === concatHash) {
              if (typeof entry.initData.length === "undefined") {
                // If length is undefined, it has been linearized. We could still
                // convert it back to an Uint8Array but this would necessitate some
                // ugly unreadable logic for a very very minor possibility.
                // Just consider that it is a match based on the hash.
                log.debug(
                  "DRM",
                  "Persistent Store: Found compatible entry of v1 - same hash only",
                  {
                    index: i,
                    sessionId: entry.sessionId,
                    hash: concatHash,
                  },
                );
                return i;
              } else if (areArraysOfNumbersEqual(entry.initData, concatInitData)) {
                log.debug(
                  "DRM",
                  "Persistent Store: Found compatible entry of v1 - same hash and initData",
                  {
                    index: i,
                    sessionId: entry.sessionId,
                    hash: concatHash,
                  },
                );
                return i;
              }
            }
            break;
          }

          default: {
            const { initDataHash: concatHash } = getConcatenatedInitDataInfo();
            if (entry.initData === concatHash) {
              log.debug(
                "DRM",
                "Persistent Store: Found compatible entry - same hash only",
                {
                  index: i,
                  sessionId: entry.sessionId,
                  hash: concatHash,
                },
              );
              return i;
            }
          }
        }
      }
    }
    return -1;
  }

  /**
   * Use the given storage to store the current entries.
   */
  private _save(): void {
    try {
      this._storage.save(this._entries);
    } catch (e) {
      const err = e instanceof Error ? e : undefined;
      log.warn(
        "DRM",
        "Persistent Store: Could not save MediaKeySession information",
        err,
      );
    }
  }
}

/**
 * Format given initializationData's values so they are ready to be stored:
 *   - sort them by systemId, so they are faster to compare
 *   - add hash for each initialization data encountered.
 * @param {Array.<Object>} initialValues
 * @returns {Array.<Object>}
 */
function prepareValuesForStore(initialValues: IFormattedInitDataValue[]): Array<{
  systemId: string | undefined;
  hash: number;
  data: SerializableBytes;
}> {
  return initialValues.map(({ systemId, data, hash }) => ({
    systemId,
    hash,
    data: new SerializableBytes(data),
  }));
}
