import type { Identifier } from './types/identifiers';
import type { AssetObject, AssetLike } from './types/assets';
import type { DocumentVersion, Document } from './types/documents';

type Nullable<T> = T | null;

export type { AssetObject, AssetLike, DocumentVersion as DocumentVersionLike, Document };

export const resolveAssetExpiresAt = (asset?: { download?: { expires_at: number } | null } | null): number | null =>
  asset?.download?.expires_at ?? null;

export const resolveAssetUrl = (asset?: { download?: { url: string } | null } | null): string | null =>
  asset?.download?.url ?? null;

export type EnsureAssetUrl = (
  documentId: Identifier,
  asset: AssetLike,
  options?: { force?: boolean;[key: string]: unknown },
) => Promise<unknown>;

export type GetAsset = (document: Document, assetType: string) => Nullable<AssetLike>;

export const getAssetFromGroup = (
  assets?: AssetLike[] | Record<string, AssetLike> | null,
  assetType: string = '',
): Nullable<AssetLike> => {
  if (!assetType || !assets) {
    return null;
  }

  if (Array.isArray(assets)) {
    return assets.find((entry) => entry?.asset_type === assetType) || null;
  }

  return assets?.[assetType] || null;
};

export const getAssetFromVersion = (currentVersion: Nullable<DocumentVersion>, assetType: string) => {
  if (!currentVersion) {
    return null;
  }
  return getAssetFromGroup(currentVersion.assets, assetType);
};

const normalizeAssetObjects = (objects?: AssetObject[] | null): AssetObject[] => {
  if (!Array.isArray(objects)) {
    return [];
  }
  return objects
    .filter((entry) => Number.isInteger(entry?.ordinal))
    .slice()
    .sort((a, b) => a.ordinal - b.ordinal);
};

export class AssetView {
  asset: AssetLike | null;
  private _objectsRef: AssetObject[] | null;
  private _sortedObjects: AssetObject[];

  constructor(asset?: AssetLike | null) {
    this.asset = asset || null;
    this._objectsRef = null;
    this._sortedObjects = [];
  }

  getCardinality(): number {
    if (!this.asset) {
      return 0;
    }

    const objectsCount = this.getObjects().length;
    if (objectsCount > 0) {
      return objectsCount;
    }

    if (this.asset.url || this.asset.metadata) {
      return 1;
    }

    return 0;
  }

  getObjects(): AssetObject[] {
    if (!this.asset || !Array.isArray(this.asset.objects) || this.asset.objects.length === 0) {
      return [];
    }

    if (this._objectsRef === this.asset.objects) {
      return this._sortedObjects;
    }

    this._objectsRef = this.asset.objects;
    this._sortedObjects = normalizeAssetObjects(this.asset.objects);
    return this._sortedObjects;
  }

  getObject(ordinal = 1): AssetObject | null {
    const fromObjects = this.getObjects().find((entry) => entry.ordinal === ordinal);
    if (fromObjects) {
      return fromObjects;
    }

    if (ordinal === 1 && this.asset) {
      const primaryUrl = resolveAssetUrl(this.asset);
      if (primaryUrl || this.asset.metadata) {
        return {
          ordinal: 1,
          url: primaryUrl || null,
          metadata: this.asset.metadata || null,
          expires_at: resolveAssetExpiresAt(this.asset),
        };
      }
    }

    return null;
  }

  getPrimaryObject(): AssetObject | null {
    return this.getObject(1);
  }

  getPrimaryMetadata(): Record<string, unknown> | null {
    return this.getPrimaryObject()?.metadata || null;
  }

  getPrimaryUrl(): string | null {
    return this.getPrimaryObject()?.url || null;
  }

  hasObject(ordinal: number): boolean {
    return Boolean(this.getObject(ordinal));
  }
}

export const createAssetView = (asset?: AssetLike | null): AssetView => new AssetView(asset);

export const resolveDocumentAssetUrl = (
  doc: Nullable<Document>,
  type: string,
  {
    ensureAssetUrl,
    getAsset,
    ensureOptions,
  }: {
    ensureAssetUrl?: EnsureAssetUrl;
    getAsset?: GetAsset;
    ensureOptions?: { force?: boolean;[key: string]: unknown };
  } = {},
): Nullable<string> => {
  if (!doc || !type) {
    return null;
  }
  const asset = getAsset ? getAsset(doc, type) : null;
  if (!asset) {
    return null;
  }
  const view = createAssetView(asset);
  const object = view.getPrimaryObject();
  const url = object?.url || view.getPrimaryUrl();
  const expiresAt = object?.expires_at ?? resolveAssetExpiresAt(asset);
  const now = Date.now();
  if (url && (!expiresAt || expiresAt > now)) {
    return url;
  }
  if (doc.id && asset.id && ensureAssetUrl) {
    const force = Boolean(url && expiresAt && expiresAt <= now);
    const options: { force: boolean;[key: string]: unknown } = {
      force,
      ...(ensureOptions || {}),
    };
    ensureAssetUrl(doc.id, asset, options).catch(() => { });
  }
  return null;
};

class AssetManager {
  fetchAsset: ((id: Identifier) => Promise<AssetLike | null>) | null;

  assetCache: Map<Identifier, AssetLike>;
  assetInflight: Map<string, Promise<AssetLike | null>>;

  constructor({ fetchAsset }: { fetchAsset: ((id: Identifier) => Promise<AssetLike | null>) | null }) {
    this.fetchAsset = fetchAsset;
    this.assetCache = new Map();
    this.assetInflight = new Map();
  }

  setFetchAsset(fetchAsset: ((id: Identifier) => Promise<AssetLike | null>) | null) {
    this.fetchAsset = fetchAsset;
  }

  rememberAsset(entry?: Nullable<AssetLike>) {
    if (entry?.id) {
      this.assetCache.set(entry.id, entry);
    }
  }

  ensureAsset(
    documentId?: Identifier | null,
    asset?: Nullable<AssetLike>,
    { force = false }: { force?: boolean } = {},
  ): Promise<Nullable<AssetLike>> {
    if (!documentId || !asset?.id) {
      return Promise.resolve(asset);
    }

    const baseAsset = this.assetCache.get(asset.id) || asset;
    const view = createAssetView(baseAsset);
    const assetExpiresAt = resolveAssetExpiresAt(baseAsset);
    const now = Date.now();

    const isPrimarySatisfied = () => {
      const object = view.getObject(1);
      if (object?.url) {
        const objectExpiresAt = object.expires_at ?? null;
        if (!objectExpiresAt || objectExpiresAt > now) {
          return true;
        }
      }
      const assetUrl = resolveAssetUrl(baseAsset);
      if (assetUrl && (!assetExpiresAt || assetExpiresAt > now)) {
        return true;
      }
      return false;
    };

    let needsFetch = force;
    if (!needsFetch) {
      needsFetch = !isPrimarySatisfied();
    }

    if (!needsFetch) {
      this.rememberAsset(baseAsset);
      return Promise.resolve(baseAsset);
    }

    const inflightKey = `${documentId}:${asset.id}`;
    if (!force && this.assetInflight.has(inflightKey)) {
      return this.assetInflight.get(inflightKey);
    }

    if (!this.fetchAsset) {
      return Promise.reject(new Error('AssetManager fetcher is not configured.'));
    }

    const request: Promise<AssetLike | null> = this.fetchAsset(asset.id)
      .then((data) => {
        if (!data) return null;
        const cachedEntry = this.assetCache.get(asset.id) || baseAsset;
        const combined = { ...cachedEntry, ...asset, ...data };
        const expires_at = resolveAssetExpiresAt(combined);
        const entry = {
          ...combined,
          url: resolveAssetUrl(combined),
          expires_at,
        };

        this.rememberAsset(entry);
        return entry;
      })
      .finally(() => {
        this.assetInflight.delete(inflightKey);
      });

    this.assetInflight.set(inflightKey, request);
    return request;
  }

  reset() {
    this.assetCache.clear();
    this.assetInflight.clear();
  }
}

export default AssetManager;
