import { Model } from "@localsecurity/types";

/**
 * Models // todo -> add Models / ModelNames enum to types package
 * */

enum Models {
  ActivityLog = "ActivityLog",
  Address = "Address",
  AppliedCameraPlan = "AppliedCameraPlan",
  AppliedLivePatrol = "AppliedLivePatrol",
  AppliedOperatorMinutes = "AppliedOperatorMinutes",
  AppliedProduct = "AppliedProduct",
  AppliedSetupFee = "AppliedSetupFee",
  AudioCustomMessageType = "AudioCustomMessageType",
  AuditLog = "AuditLog",
  CallLog = "CallLog",
  CallNotice = "CallNotice",
  Camera = "Camera",
  CameraAccessUrls = "CameraAccessUrls",
  CameraCredentials = "CameraCredentials",
  CameraEvent = "CameraEvent",
  CameraImmixConfig = "CameraImmixConfig",
  CameraPlan = "CameraPlan",
  CameraQualitySettings = "CameraQualitySettings",
  CameraSceneDetectionResult = "CameraSceneDetectionResult",
  CameraSceneDetectionResultItem = "CameraSceneDetectionResultItem",
  CameraSceneDetectionSettings = "CameraSceneDetectionSettings",
  CameraSceneDetectionThreshold = "CameraSceneDetectionThreshold",
  Contact = "Contact",
  Customer = "Customer",
  CustomerUserPermission = "CustomerUserPermission",
  CustomerUserPermissionSet = "CustomerUserPermissionSet",
  DashboardDetail = "DashboardDetail",
  EmailLog = "EmailLog",
  ExchangeRateSetting = "ExchangeRateSetting",
  Incident = "Incident",
  IncidentActionDetail = "IncidentActionDetail",
  IncidentActionList = "IncidentActionList",
  IncidentBundleTime = "IncidentBundleTime",
  IncidentCodeDetail = "IncidentCodeDetail",
  IncidentDataToShare = "IncidentDataToShare",
  IncidentNote = "IncidentNote",
  IncidentShareActionItems = "IncidentShareActionItems",
  IncidentShareContactInformation = "IncidentShareContactInformation",
  IncidentShareEntry = "IncidentShareEntry",
  IncidentShareEntryAccess = "IncidentShareEntryAccess",
  IncidentShareEntyNote = "IncidentShareEntyNote",
  IncidentShareEvent = "IncidentShareEvent",
  IncidentShareIntegrator = "IncidentShareIntegrator",
  IncidentShareSettings = "IncidentShareSettings",
  IncidentShareSite = "IncidentShareSite",
  Invoice = "Invoice",
  InvoicePayment = "InvoicePayment",
  InvoiceTransferPayment = "InvoiceTransferPayment",
  LivePatrol = "LivePatrol",
  Master = "Master",
  Notification = "Notification",
  NotificationSetting = "NotificationSetting",
  ObjectGroup = "ObjectGroup",
  OperatorMinutes = "OperatorMinutes",
  PaymentTransferSettings = "PaymentTransferSettings",
  Phone = "Phone",
  ProcedureTask = "ProcedureTask",
  Product = "Product",
  RapidSOSActivity = "RapidSOSActivity",
  RapidSOSAttachment = "RapidSOSAttachment",
  RapidSOSCivicAddress = "RapidSOSCivicAddress",
  RapidSOSDetails = "RapidSOSDetails",
  RapidSOSEmergencyContact = "RapidSOSEmergencyContact",
  RapidSOSEventDetails = "RapidSOSEventDetails",
  RapidSOSIncident = "RapidSOSIncident",
  RapidSOSLocation = "RapidSOSLocation",
  RapidSOSLogMessage = "RapidSOSLogMessage",
  RspndrActivity = "RspndrActivity",
  RspndrEvents = "RspndrEvents",
  RspndrIncident = "RspndrIncident",
  RspndrIncidentCheckItem = "RspndrIncidentCheckItem",
  RspndrIncidentGroundingRule = "RspndrIncidentGroundingRule",
  SMSLog = "SMSLog",
  SOP = "SOP",
  SecondaryCameraEvent = "SecondaryCameraEvent",
  SetupFee = "SetupFee",
  SignalTestLog = "SignalTestLog",
  Site = "Site",
  SiteEmergencyContact = "SiteEmergencyContact",
  SiteIntegration = "SiteIntegration",
  SiteSubscription = "SiteSubscription",
  SiteSupvisdSetting = "SiteSupvisdSetting",
  SiteVMSDetails = "SiteVMSDetails",
  StripePaymentMethod = "StripePaymentMethod",
  StripePayoutDetails = "StripePayoutDetails",
  SubscriptionStripeDetail = "SubscriptionStripeDetail",
  SupvisdEventRule = "SupvisdEventRule",
  Tax = "Tax",
  TaxType = "TaxType",
  TaxesTaxType = "TaxesTaxType",
  TrackTikActivity = "TrackTikActivity",
  TrackTikDetails = "TrackTikDetails",
  TrackTikIncident = "TrackTikIncident",
  TrackTikTask = "TrackTikTask",
  TransactionLineItem = "TransactionLineItem",
  TransactionLineItemTax = "TransactionLineItemTax",
  Trigger = "Trigger",
  User = "User",
  UserVMSDetails = "UserVMSDetails",
  VMSDetails = "VMSDetails",
  WalletTransfer = "WalletTransfer",
  WebhookHealth = "WebhookHealth",
}
type ModelName = Model[keyof Model]["__typename"];

/**
 * A sortable ID in the format "{model}_{reversedTimestamp}{random}".
 */
type NameIdString = `${Lowercase<Models>}_${string}` & {
  __brand: "NameIdString";
};

/**
 * A uuid v4 (ish)
 */
type UUIDString = `${string}-${string}-${string}-${string}-${string}`;

/**
 * A sortable ID in the format "{model}_{reversedTimestamp}{random}".
 */
const nameIdStringRegex = new RegExp(
  `^(${Object.values(Models)
    .map((n) => n.toLowerCase())
    .join("|")})_[2-7a-z]{22,}$`,
);

/**
 * A valid uuid v4
 */
const uuidStringRegex =
  /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;

/**
 * Checks if the value is a valid uuid v4
 * @param value A uuid string hopefully
 * @returns true or false
 */
function isUUID(value: unknown): value is UUIDString {
  const regexMatch = typeof value === "string" && uuidStringRegex.test(value);
  return regexMatch;
}

/**
 * Checks if the value is a String
 * @param value A string hopefully
 * @returns true or false
 */
function isString(value: unknown): boolean {
  try {
    return typeof value == "string";
  } catch (e) {
    return false;
  }
}

/**
 * Checks if the value is a NameID (a sortable ID in the format "{model}_{reversedTimestamp}{random}")
 * @param value A valid NameID string hopefully
 * @returns true or false
 */
function isNameId(value: unknown): value is NameIdString {
  const parts = typeof value == "string" ? value.split("_") : [];
  if (parts.length !== 2) return false;
  const timestamp = parts[1].slice(0, 6);
  if (timestamp.length !== 6) return false;
  const regexMatch = typeof value === "string" && nameIdStringRegex.test(value);
  if (regexMatch == false) return false;
  return true;
}

/**
 * Checks if the value, or value['id'], is a valid uuid v4
 * @param value A string or object with an 'id' property
 * @returns true or false
 * @example
 * isHasUUID("xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx") // true
 * isHasUUID({"id":"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"}) // true
 */
function isHasUUID(value: unknown): boolean {
  try {
    if (isString(value)) return isUUID(value);
    if (isString((value as any)["id"])) return isUUID((value as any)["id"]);
    return false;
  } catch (e) {
    return false;
  }
}

/**
 * Checks if the value is a known name for a Model
 * @param value
 * @returns true or false
 */
function isModelName(value: unknown | ModelName): value is ModelName {
  return (
    typeof value === "string" && Object.values(Models).includes(value as any)
  );
}

/**
 * Extracts the Model Name from a NameID
 * @param nameId  A string in the format "{model}_{reversedTimestamp}{random}"
 * @returns A ModelName
 * @example
 * extractModelFromNameId('user_012345234567abcdefghijklmnopqrstuv') // returns 'User'
 */
function extractModelFromNameId(nameId: NameIdString): ModelName {
  const typenames = Object.values(Models);
  const nameIdPrefix = nameId.split("_")[0];
  for (let name of typenames) {
    if (name.toLowerCase() === nameIdPrefix) {
      return name as ModelName;
    }
  }
  throw Error("get __typename failed");
}

/**
 * Extracts the Model Name from a NameID with a novel (unknown) model prefix
 * @param nameId  A string in the format "{model}_{reversedTimestamp}{random}"
 * @returns A ModelName
 * @example
 * extractModelFromNameId('cat_012345234567abcdefghijklmnopqrstuv') // returns 'Cat'
 */
function extractModelFromNameIdSkipValidation(nameId: NameIdString): string {
  let parts = nameId.split("_")[0].split("");
  const firstLetter = parts.shift()?.toUpperCase();
  return firstLetter + parts.join("");
}

/**
 * Extracts the createdAt timestamp from a NameID
 * @param nameId A string in the format "{model}_{reversedTimestamp}{random}"
 * @param offset Optionally you can include an offset if a custom offset was used
 * @returns a Date
 */
function extractTimestampFromNameId(
  nameId: NameIdString,
  offset: number = 1500000000000,
): Date {
  const timestamp = new Date(
    (offset / 1000 +
      parseInt(
        nameId.split("_")[1].slice(0, 6).split("").reverse().join(""),
        32,
      )) *
      1000,
  );
  return timestamp;
}

/**
 * Create a new Model Object with a valid NameID from an existing Model Object or a Model Name
 */
abstract class MutableModel {
  id!: UUIDString | NameIdString;
  nameId!: NameIdString;
  createdAt!: string;
  updatedAt!: string;

  get __typename(): ModelName {
    const typenames = Object.values(Models);
    const nameIdPrefix = this.nameId.split("_")[0];
    for (let name of typenames) {
      if (name.toLowerCase() === nameIdPrefix) {
        return name as ModelName;
      }
    }
    throw Error("get __typename failed");
  }

  /**
   * @param init An existing Model Object, or a Model Name
   */
  constructor(init: Partial<Model[keyof Model]> | ModelName) {
    if (init instanceof Object) {
      const maybeTypename = (init as any).__typename;
      const hasTypename =
        typeof maybeTypename === "string" && isModelName(maybeTypename)
          ? maybeTypename
          : false;

      const maybeNameId = (init as any).nameId;
      const hasNameId =
        typeof maybeNameId === "string" && isNameId(maybeNameId)
          ? maybeNameId
          : false;

      if (hasNameId) {
        Object.assign(this, init);
      } else if (hasTypename) {
        this.nameId = new NameID(hasTypename).value;
        Object.assign(this, init);
      } else {
        throw Error("no valid __typename or name id in object");
      }
    } else {
      this.nameId = new NameID(init).value;
    }
    if (this.createdAt == undefined) this.createdAt = new Date().toISOString();
    if (this.updatedAt == undefined) this.updatedAt = new Date().toISOString();
    if (this.id == undefined) this.id = this.nameId;
  }
}

/**
 * Represents a sortable ID.
 *
 * This class encapsulates the generation, validation, and manipulation of sortable IDs,
 * which are used as primary keys in our database, particularly for time-series data.
 */
class NameID {
  private _lexicographicBase32 = "234567abcdefghijklmnopqrstuvwxyz";
  private _randomLength = 16;
  private _offset = 1500000000000; // 2022-11-11T01:53:20 UTC

  private _value: NameIdString;

  /**
   * @param { ModelName | Model | NameId } input - Can be a ModelName, a Model object (with __typename and createdAt), or a valid nameId string.
   * @param { string | number | Date } time - Defaults to now.
   * @param {boolean} skip - Skip 'Is Valid Model Name' check.
   * @throws { Error } - Throws an error if the input is invalid.
   *
   * @example
   * const NameID_For_Model = new NameID("User");
   * const NameID_From_Model = new NameID({ __typename: "User", ...});
   * const NameID_From_Model_With_UUID = new NameID({id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", __typename: "User", ... });
   */
  constructor(
    input: Model[ModelName] | ModelName | NameIdString,
    time: string | number | Date = Date.now(),
    skip: boolean = false,
  ) {
    if (isModelName(input)) {
      this._value = this.generateNameID(input, time);
    } else if (isNameId(input)) {
      let name = extractModelFromNameId(input);
      time = extractTimestampFromNameId(input, this._offset);
      this._value = this.generateNameID(name, time);
    } else if (
      typeof input["__typename"] == "string" &&
      isModelName(input["__typename"]) &&
      isHasUUID(input)
    ) {
      this._value = this.fromUUID(input);
    } else if (
      typeof input["__typename"] == "string" &&
      isModelName(input["__typename"])
    ) {
      let name = input["__typename"];
      time = (input as any)["createdAt"] ?? time;
      this._value = this.generateNameID(name, time);
    } else if (
      typeof (input as any)["nameId"] == "string" &&
      isNameId((input as any)["nameId"])
    ) {
      let name = extractModelFromNameId((input as any)["nameId"]);
      time = extractTimestampFromNameId((input as any)["nameId"], this._offset);
      this._value = this.generateNameID(name, time);
    } else if (skip) {
      if (typeof input !== "string")
        throw Error("You must pass a string with skip");
      else this._value = this.generateNameID(input as ModelName, time);
    } else {
      throw Error(`invalid new NameID(${JSON.stringify({ input })}`);
    }
  }

  /**
   * Returns The string representation of the NameID.
   * @example
   * const nameId = new NameID("User");
   * console.log(nameId.toString()); // Output: user_012345234567abcdefghijklmnopqrstuv
   */
  get value(): NameIdString {
    return this._value as NameIdString;
  }

  /**
   * Convert a legacy object with a uuid id to a NameIdString
   * @param object Any valid Model object
   * @returns
   */
  private fromUUID(object: any): any {
    let id: string = "";
    const model = object["__typename"];
    const hasFX =
      typeof object["country"] == "string" ? object["country"] : false;
    const hasId =
      typeof object["id"] == "string" ? object["id"].replace(/-/g, "") : false;
    const hasUsername =
      typeof object["username"] == "string" ? object["username"] : false;
    if (hasId) id = hasId;
    else if (hasFX) id = hasFX;
    else if (hasUsername) id = hasUsername;
    else throw Error(`no id or username or country: ${JSON.stringify(object)}`);
    return `${model}_${this.generateEncodedTimestamp(object["createdAt"])}${id}`;
  }

  /**
   * Generates a sortable ID with model prefix and reversed timestamp.
   *
   * This function creates a unique identifier suitable for use as a primary key
   * in a database, particularly for time-series data or event-driven systems.
   * It combines a model identifier, a reversed timestamp for reverse chronological
   * sorting, and a random component for uniqueness.
   *
   * @param name - The model type name (e.g., "User").
   * @param time - The timestamp to encode. If not provided, the current time is used.
   *
   * @returns The generated sortable ID in the format "{model}_{reversedTimestamp}{random}".
   *
   * @example
   * const userId = generateNameID("User");
   * const cameraEventId = generateNameID("CameraEvent", new Date("2024-08-15T12:30:00Z"));
   */
  private generateNameID(
    name: ModelName | Lowercase<ModelName>,
    time: string | number | Date = new Date(),
  ): NameIdString {
    const encodedTimestamp = this.generateEncodedTimestamp(time);
    let randomPart = "";
    for (let i = 0; i < this._randomLength; i++) {
      randomPart +=
        this._lexicographicBase32[
          Math.floor(Math.random() * this._lexicographicBase32.length)
        ];
    }
    return `${name.toLowerCase()}_${encodedTimestamp}${randomPart}` as NameIdString;
  }

  private generateEncodedTimestamp(
    time: string | number | Date = new Date(),
  ): string {
    time = new Date(time);
    const timestampMillis = time.getTime();
    const timestampSeconds = Math.floor(
      (timestampMillis - this._offset) / 1000,
    );
    const timestampBase32 = timestampSeconds.toString(32).padStart(6, "0");
    const encodedTimestamp = timestampBase32.split("").reverse().join("");
    return encodedTimestamp;
  }

  /**
   * Checks if a given ID is valid based on the expected format.
   *
   * This function validates if a string conforms to the expected sortable ID
   * format: "model_reversedTimestamp_random".
   * @returns {boolean} - True if the ID is valid, false otherwise.
   *
   * @example
   * const validId = generateNameID("user");
   * const invalidId = "user-12345-abcde";
   *
   * console.log(isValidId(validId));   // true
   * console.log(isValidId(invalidId)); // false
   */
  get isValid(): boolean {
    return isNameId(this._value);
  }

  /**
   * Decodes the timestamp from a NameID.
   *
   * @returns {Date | null} - The decoded timestamp as a Date object, or null if the ID is invalid.
   *
   * @example
   * const { timestamp } = new NameID("event_abcdefghi");
   * console.log(timestamp.toISOString());
   */
  get timestamp(): Date | null {
    try {
      if (!this.isValid) throw Error("invalid nameId");
      return extractTimestampFromNameId(this._value, this._offset);
    } catch (e) {
      return null;
    }
  }

  /**
   * Generates a sortable ID with model prefix and reversed timestamp.
   *
   * This function creates a unique identifier suitable for use as a primary key
   * in a database, particularly for time-series data or event-driven systems.
   * It combines a model identifier, a reversed timestamp for reverse chronological
   * sorting, and a random component for uniqueness.
   *
   * @param value - Can be a ModelName, a Model object (with __typename and createdAt), or a valid nameId string.
   * @param time - The timestamp to encode. If not provided, the current time is used.
   *
   * @returns The generated sortable ID in the format "model_{reversedTimestamp}{random}".
   *
   * @example
   * const userId = generateNameID("User");
   * const cameraEventId = generateNameID("CameraEvent", new Date("2024-08-15T12:30:00Z"));
   */
  static from(value: unknown, time?: string | number | Date): NameID {
    return new NameID(value as any);
  }

  /**
   * Checks if the input is a value nameId
   * @param value A nameId.
   * @param skip Skip 'Is Valid Model Name' check.
   * @returns A model name and timestamp
   */
  static parse(
    value: string,
    skip: boolean = false,
  ): { name: ModelName | string; timestamp: Date } {
    if (skip) {
      const name = extractModelFromNameIdSkipValidation(value as NameIdString);
      const timestamp = extractTimestampFromNameId(value as NameIdString);
      return { name, timestamp };
    }
    if (!isNameId(value)) throw Error("Invalid NameIdString");
    const name = extractModelFromNameId(value);
    const timestamp = extractTimestampFromNameId(value);
    return { name, timestamp };
  }
}

/**
 * Classes
 */
export { NameID, MutableModel };

/**
 * Regexp
 */
export { uuidStringRegex, nameIdStringRegex };

/**
 * Utilities
 */
export {
  isUUID,
  isString,
  isNameId,
  isHasUUID,
  isModelName,
  extractModelFromNameId,
  extractTimestampFromNameId,
  extractModelFromNameIdSkipValidation,
};

/**
 * Types
 */
export type { Models, ModelName, UUIDString, NameIdString };

export default NameID;
