import type { AxiosInstance } from 'axios';

export type Identifier = string | number;

export interface AssetObject {
  ordinal?: number;
  url?: string | null;
  metadata?: Record<string, unknown> | null;
  expires_at?: number | null;
  [key: string]: unknown;
}

export interface AssetLike {
  id?: Identifier;
  asset_type?: string;
  cardinality?: number | null;
  url?: string | null;
  metadata?: Record<string, unknown> | null;
  expiresAt?: number | null;
  assets?: Record<string, AssetLike> | AssetLike[] | null;
  objects?: AssetObject[] | null;
  [key: string]: unknown;
}

export interface DocumentVersionLike {
  assets?: Record<string, AssetLike> | AssetLike[] | null;
  metadata?: Record<string, unknown> & { page_count?: number } | null;
  [key: string]: unknown;
}

export interface DocumentLike {
  id?: Identifier;
  current_version?: DocumentVersionLike | null;
  [key: string]: unknown;
}

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

export type GetAsset = (document: DocumentLike, assetType: string) => AssetLike | null | undefined;

export const getAssetFromGroup = (
  assets: AssetLike[] | Record<string, AssetLike> | null | undefined,
  assetType: string,
): AssetLike | null => {
  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, assetType) => {
  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);
};

const mergeAssetObjects = (
  existingObjects?: AssetObject[] | null,
  incomingObjects?: AssetObject[] | null,
): AssetObject[] => {
  const merged = new Map<number, AssetObject>();

  normalizeAssetObjects(existingObjects).forEach((entry) => {
    if (typeof entry.ordinal === 'number') {
      merged.set(entry.ordinal, { ...entry });
    }
  });

  normalizeAssetObjects(incomingObjects).forEach((entry) => {
    if (typeof entry.ordinal === 'number') {
      const current = merged.get(entry.ordinal) || {};
      merged.set(entry.ordinal, { ...current, ...entry });
    }
  });

  return Array.from(merged.entries())
    .sort((a, b) => a[0] - b[0])
    .map(([, value]) => value);
};

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 reported = Number(this.asset.cardinality);
    if (Number.isFinite(reported) && reported > 0) {
      return reported;
    }

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

    return this.asset.metadata ? 1 : 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) {
      if (this.asset.url || this.asset.metadata) {
        return {
          ordinal: 1,
          url: this.asset.url || null,
          metadata: this.asset.metadata || null,
          expires_at: this.asset.expiresAt ?? null,
        };
      }
    }

    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: DocumentLike | null | undefined,
  type: string,
  {
    ensureAssetUrl,
    getAsset,
    ensureOptions,
    objectOrdinal = 1,
  }: {
    ensureAssetUrl?: EnsureAssetUrl;
    getAsset?: GetAsset;
    ensureOptions?: { start?: number; limit?: number; [key: string]: unknown };
    objectOrdinal?: number;
  } = {},
): string | null => {
  if (!doc || !type) {
    return null;
  }
  const asset = typeof getAsset === 'function' ? getAsset(doc, type) : null;
  if (!asset) {
    return null;
  }
  const view = createAssetView(asset);
  const object = view.getObject(objectOrdinal);
  const url = object?.url || (objectOrdinal === 1 ? view.getPrimaryUrl() : null);
  const expiresAt = typeof object?.expires_at === 'number'
    ? object.expires_at
    : objectOrdinal === 1 && typeof asset.expiresAt === 'number'
      ? asset.expiresAt
      : null;
  const now = Date.now();
  if (url && (!expiresAt || expiresAt > now)) {
    return url;
  }
  if (doc.id && asset.id && typeof ensureAssetUrl === 'function') {
    const force = Boolean(url && expiresAt && expiresAt <= now);
    const options: { force: boolean; start?: number | null; limit?: number | null; [key: string]: unknown } = {
      force,
      start: objectOrdinal,
      limit: 1,
      ...(ensureOptions || {}),
    };
    if (!options.start) {
      options.start = objectOrdinal;
    }
    if (!options.limit) {
      options.limit = 1;
    }
    ensureAssetUrl(doc.id, asset, options).catch(() => {});
  }
  return null;
};

class AssetManager {
  api: AxiosInstance | null;
  assetPresignTtlMs: number;
  assetCache: Map<Identifier, AssetLike>;
  assetInflight: Map<string, Promise<AssetLike | null>>;

  constructor({ api, assetPresignTtlMs }: { api: AxiosInstance | null; assetPresignTtlMs: number }) {
    this.api = api;
    this.assetPresignTtlMs = assetPresignTtlMs;
    this.assetCache = new Map();
    this.assetInflight = new Map();
  }

  setApi(api: AxiosInstance | null) {
    this.api = api;
  }

  rememberAsset(entry: AssetLike | null | undefined) {
    if (entry?.id) {
      this.assetCache.set(entry.id, entry);
    }
  }

  hydrateAsset(asset: AssetLike | null | undefined): AssetLike | null | undefined {
    if (!asset || !asset.id) {
      return asset;
    }
    const cached = this.assetCache.get(asset.id);
    if (!cached) {
      const normalized = mergeAssetObjects(null, asset.objects);
      if (normalized.length) {
        return { ...asset, objects: normalized };
      }
      return asset;
    }
    const merged = { ...cached, ...asset };
    if (cached.url && !asset.url) {
      merged.url = cached.url;
    }
    if (cached.expiresAt) {
      const cachedExpires = Number(cached.expiresAt) || null;
      const assetExpires = Number(asset.expiresAt) || null;
      if (!assetExpires || (cachedExpires && cachedExpires > assetExpires)) {
        merged.expiresAt = cachedExpires;
      }
    }
    const mergedObjects = mergeAssetObjects(cached.objects, asset.objects);
    if (mergedObjects.length) {
      merged.objects = mergedObjects;
    }
    return merged;
  }

  hydrateDocument(document: DocumentLike | null | undefined): DocumentLike | null | undefined {
    if (!document) {
      return document;
    }

    const currentVersion = document.current_version || null;
    if (!currentVersion) {
      return document;
    }

    let changed = false;
    let nextAssets = currentVersion.assets;

    if (nextAssets && !Array.isArray(nextAssets)) {
      const hydrated = {};
      Object.keys(nextAssets).forEach((key) => {
        hydrated[key] = this.hydrateAsset(nextAssets[key]);
        if (hydrated[key] !== nextAssets[key]) {
          changed = true;
        }
      });
      if (changed) {
        nextAssets = { ...nextAssets, ...hydrated };
      }
    } else if (Array.isArray(nextAssets)) {
      const hydratedList = nextAssets.map((item) => this.hydrateAsset(item));
      if (
        hydratedList.length !== nextAssets.length ||
        hydratedList.some((item, index) => item !== nextAssets[index])
      ) {
        changed = true;
        nextAssets = hydratedList;
      }
    }

    if (!changed) {
      return document;
    }

    const nextCurrentVersion: DocumentVersionLike = { ...currentVersion, assets: nextAssets };
    return { ...document, current_version: nextCurrentVersion };
  }

  hydrateDocuments(documents: DocumentLike[] | null | undefined) {
    if (!Array.isArray(documents)) {
      return documents;
    }
    return documents.map((doc) => this.hydrateDocument(doc));
  }

  hydrateDetail(detail: { document?: DocumentLike; assets?: AssetLike[] } | null | undefined) {
    if (!detail) {
      return detail;
    }
    let changed = false;
    const next = { ...detail };

    if (detail.document) {
      const hydratedDocument = this.hydrateDocument(detail.document);
      if (hydratedDocument !== detail.document) {
        next.document = hydratedDocument;
        changed = true;
      }
    }

    if (Array.isArray(detail.assets)) {
      const hydratedAssets = detail.assets.map((item) => this.hydrateAsset(item));
      if (
        hydratedAssets.length !== detail.assets.length ||
        hydratedAssets.some((item, index) => item !== detail.assets[index])
      ) {
        next.assets = hydratedAssets;
        changed = true;
      }
    }

    return changed ? next : detail;
  }

  hydrateFolderContents(contents: {
    documents?: DocumentLike[];
    document?: DocumentLike;
  } | null | undefined) {
    if (!contents) {
      return contents;
    }
    const next = { ...contents };
    if (Array.isArray(contents.documents)) {
      next.documents = this.hydrateDocuments(contents.documents);
    }
    if (contents.document) {
      next.document = this.hydrateDocument(contents.document);
    }
    return next;
  }

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

    const requestedStart = Number.isInteger(start) && start > 0 ? start : 1;
    const requestedLimit = Number.isInteger(limit) && limit > 0 ? limit : 1;
    const requestedEnd = requestedStart + requestedLimit - 1;

    const baseAsset = this.assetCache.get(asset.id) || asset;
    const view = createAssetView(baseAsset);
    const assetExpiresAt = typeof baseAsset.expiresAt === 'number' ? baseAsset.expiresAt : null;
    const now = Date.now();

    const isOrdinalSatisfied = (ordinal) => {
      const object = view.getObject(ordinal);
      if (!object) {
        return false;
      }
      if (!object.url) {
        return false;
      }
      if (typeof object.expires_at === 'number') {
        return object.expires_at > now;
      }
      if (ordinal === 1 && baseAsset.url && (!assetExpiresAt || assetExpiresAt > now)) {
        return true;
      }
      return true;
    };

    let needsFetch = force;
    if (!needsFetch) {
      for (let ordinal = requestedStart; ordinal <= requestedEnd; ordinal += 1) {
        if (!isOrdinalSatisfied(ordinal)) {
          needsFetch = true;
          break;
        }
      }
    }

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

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

    if (!this.api) {
      return Promise.reject(new Error('AssetManager API client is not configured.'));
    }

    const params: Record<string, number> = {};
    if (Number.isInteger(start) && start > 0) {
      params.start = start;
    }
    if (Number.isInteger(limit) && limit > 0) {
      params.limit = limit;
    }

    const requestConfig = Object.keys(params).length ? { params } : undefined;

    const request: Promise<AssetLike | null> = this.api
      .get(`/assets/${asset.id}`, requestConfig)
      .then(({ data }) => {
        const incomingObjects = Array.isArray(data.objects) ? data.objects : [];
        const cachedEntry = this.assetCache.get(asset.id) || baseAsset;
        const mergedObjects = mergeAssetObjects(cachedEntry?.objects, incomingObjects);
        const combined = { ...cachedEntry, ...asset, ...data, objects: mergedObjects };
        const view = createAssetView(combined);
        const primaryObject = view.getPrimaryObject();
        const expiresAt = typeof primaryObject?.expires_at === 'number'
          ? primaryObject.expires_at
          : Date.now() + this.assetPresignTtlMs;
        const cardinality = (() => {
          const reported = Number(data.cardinality ?? asset.cardinality ?? cachedEntry?.cardinality);
          const objectsCount = mergedObjects.length;
          if (Number.isFinite(reported) && reported > 0) {
            return Math.max(reported, objectsCount) || null;
          }
          return objectsCount || null;
        })();

        const entry = {
          ...combined,
          cardinality,
          url: view.getPrimaryUrl(),
          expiresAt,
        };

        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;
