import {
  computed,
  DestroyRef,
  effect,
  inject,
  isSignal,
  resource,
  ResourceStatus,
  Signal,
  signal,
} from '@angular/core';

/**
 * ResourceRef-like return type for batched resources.
 * Provides item-specific loading, error, and status signals.
 */
export type BatchingResourceRef<TResourceValue> = {
  /** Signal containing the resource value or undefined if not loaded */
  value: Signal<TResourceValue | undefined>;

  /** Signal indicating if the resource has been loaded */
  hasValue: Signal<boolean>;

  /** Signal indicating if the resource is currently loading */
  isLoading: Signal<boolean>;

  /** Signal containing any error that occurred */
  error: Signal<unknown>;

  /** Signal indicating the current status */
  status: Signal<ResourceStatus>;

  /** Reload this specific item */
  reload: () => void;
};

/**
 * Generic batching resource for optimizing multiple API requests.
 * Collects params from multiple components, waits for a batching window,
 * then makes a single API request with all params to minimize network calls.
 *
 * Features:
 * - Automatic request batching with configurable window
 * - Smart caching (including empty results to prevent infinite loops)
 * - Reference counting for automatic cleanup
 * - Per-item loading/error status tracking
 * - Support for custom cache keys
 *
 * @example
 * // Create a stock info resource
 * export class StockInfoResource extends BatchingResource<number, FetchStockParams, StockInfo> {
 *   #stockService = inject(RemissionStockService);
 *
 *   constructor() {
 *     super(250); // batchWindowMs
 *   }
 *
 *   protected fetchFn(params: FetchStockParams, signal: AbortSignal) {
 *     return this.#stockService.fetchStockInfos(params, signal);
 *   }
 *
 *   protected buildParams(itemIds: number[]) {
 *     return { itemIds };
 *   }
 *
 *   protected getKeyFromResult(stock: StockInfo) {
 *     return stock.itemId;
 *   }
 *
 *   protected getCacheKey(itemId: number) {
 *     return String(itemId);
 *   }
 *
 *   protected extractKeysFromParams(params: FetchStockParams): number[] {
 *     return params.itemIds;
 *   }
 * }
 *
 * // Use in a component
 * const stockResource = inject(StockInfoResource);
 * const stockInfo = stockResource.resource(itemId);
 * const inStock = computed(() => stockInfo.value()?.inStock ?? 0);
 */
export abstract class BatchingResource<
  TParams,
  TResourceParams,
  TResourceValue,
> {
  readonly #batchWindowMs: number;

  /**
   * Pending params waiting in the batch queue.
   */
  #pendingKeys = signal<TParams[]>([]);

  /**
   * Committed params that trigger the actual API request.
   */
  #committedKeys = signal<TParams[]>([]);

  /**
   * Reference counting for active components per key.
   */
  #keyRefCounts = new Map<string, number>();

  /**
   * Cache for fetched results keyed by cache key.
   */
  #cachedResults = signal<Map<string, TResourceValue>>(new Map());

  /**
   * Timeout ID for the batch window.
   */
  #batchTimeoutId: ReturnType<typeof setTimeout> | null = null;

  /**
   * Per-item status tracking for loading, error, and hasValue states.
   */
  #itemStatuses = signal<
    Map<
      string,
      {
        isLoading: boolean;
        error: unknown;
        hasValue: boolean;
      }
    >
  >(new Map());

  constructor(batchWindowMs = 100) {
    this.#batchWindowMs = batchWindowMs;
  }

  /**
   * Fetch data for multiple params at once.
   * Implement this method to call your API service.
   */
  protected abstract fetchFn(
    params: TResourceParams,
    abortSignal: AbortSignal,
  ): Promise<TResourceValue[]>;

  /**
   * Build request parameters from a list of params.
   * Implement this method to convert params array to API request format.
   */
  protected abstract buildParams(params: TParams[]): TResourceParams;

  /**
   * Extract the params from a result item.
   * Implement this method to match results back to requested params.
   */
  protected abstract getKeyFromResult(
    result: TResourceValue,
  ): TParams | undefined;

  /**
   * Generate a cache key from params.
   * Implement this method for caching and deduplication.
   */
  protected abstract getCacheKey(params: TParams): string;

  /**
   * Updates the status for a specific key.
   */
  #updateItemStatus(
    cacheKey: string,
    status: Partial<{
      isLoading: boolean;
      error: unknown;
      hasValue: boolean;
    }>,
  ) {
    this.#itemStatuses.update((map) => {
      const newMap = new Map(map);
      const currentStatus = newMap.get(cacheKey) || {
        isLoading: false,
        error: undefined,
        hasValue: false,
      };
      newMap.set(cacheKey, { ...currentStatus, ...status });
      return newMap;
    });
  }

  /**
   * Computed params for the resource loader.
   * Only includes uncached params to minimize API payload.
   */
  #params = computed<TResourceParams | undefined>(() => {
    const cacheKeys = Array.from(
      new Set(this.#committedKeys().map((params) => this.getCacheKey(params))),
    );
    const cached = this.#cachedResults();

    // Filter out params that are already cached
    const uncachedCacheKeys = cacheKeys.filter(
      (cacheKey) => !cached.has(cacheKey),
    );

    // Convert cache keys back to original params for buildParams
    return uncachedCacheKeys.length > 0
      ? this.buildParams(
          this.#committedKeys().filter((params) =>
            uncachedCacheKeys.includes(this.getCacheKey(params)),
          ),
        )
      : undefined;
  });

  /**
   * Angular resource that fetches data.
   */
  #resource = resource({
    params: () => this.#params(),
    loader: async ({ params, abortSignal }) => {
      if (!params) return [];

      const paramsList = this.#extractKeysFromParams(params);

      // Mark all requested items as loading
      paramsList.forEach((itemParams) => {
        const cacheKey = this.getCacheKey(itemParams);
        this.#updateItemStatus(cacheKey, {
          isLoading: true,
          error: undefined,
        });
      });

      try {
        const results = await this.fetchFn(params, abortSignal);

        // Cache the fetched results
        this.#cachedResults.update((map) => {
          const newMap = new Map(map);

          // First, cache all items that were returned with data
          results.forEach((result) => {
            const itemParams = this.getKeyFromResult(result);
            if (itemParams !== undefined) {
              const cacheKey = this.getCacheKey(itemParams);
              newMap.set(cacheKey, result);

              // Mark item as successfully loaded
              this.#updateItemStatus(cacheKey, {
                isLoading: false,
                hasValue: true,
                error: undefined,
              });
            }
          });

          // CRITICAL: Cache items that weren't returned (empty results) to prevent infinite loops
          paramsList.forEach((itemParams) => {
            const hasResult = results.some((r) => {
              const resultKey = this.getKeyFromResult(r);
              return (
                resultKey !== undefined &&
                this.getCacheKey(resultKey) === this.getCacheKey(itemParams)
              );
            });
            if (!hasResult) {
              const cacheKey = this.getCacheKey(itemParams);
              // Cache with undefined value to indicate "no data available"
              newMap.set(cacheKey, undefined as unknown as TResourceValue);

              // Mark item as loaded (but with no value)
              this.#updateItemStatus(cacheKey, {
                isLoading: false,
                hasValue: false,
                error: undefined,
              });
            }
          });

          return newMap;
        });

        return results;
      } catch (error) {
        // Check if the error is an abort error (request was cancelled)
        const isAbortError =
          error instanceof DOMException && error.name === 'AbortError';

        // Mark all items as no longer loading
        paramsList.forEach((itemParams) => {
          const cacheKey = this.getCacheKey(itemParams);
          this.#updateItemStatus(cacheKey, {
            isLoading: false,
            // Only set error if it's not an abort (abort is normal during fast scrolling)
            error: isAbortError ? undefined : error,
          });
        });

        // Don't re-throw abort errors to prevent infinite loops
        if (isAbortError) {
          return [];
        }

        throw error;
      }
    },
  });

  /**
   * Extract params from resource params for status tracking.
   * Subclasses must implement this to extract the original params from resource params.
   */
  protected abstract extractKeysFromParams(params: TResourceParams): TParams[];

  /**
   * Wrapper to call the abstract method
   */
  #extractKeysFromParams(params: TResourceParams): TParams[] {
    return this.extractKeysFromParams(params);
  }

  /**
   * Adds params to the pending batch queue and increments reference count.
   */
  #addKeys(params: TParams[]) {
    // Increment reference count for each params
    params.forEach((itemParams) => {
      const cacheKey = this.getCacheKey(itemParams);
      const currentCount = this.#keyRefCounts.get(cacheKey) || 0;
      this.#keyRefCounts.set(cacheKey, currentCount + 1);
    });

    // Add to pending batch
    this.#pendingKeys.update((current) => [...current, ...params]);

    // Clear existing timeout if any
    if (this.#batchTimeoutId !== null) {
      clearTimeout(this.#batchTimeoutId);
    }

    // Schedule batch commit after batch window
    this.#batchTimeoutId = setTimeout(() => {
      const pending = this.#pendingKeys();

      this.#committedKeys.update((current) =>
        Array.from(new Set([...current, ...pending])),
      );

      this.#pendingKeys.set([]);
      this.#batchTimeoutId = null;
    }, this.#batchWindowMs);
  }

  /**
   * Removes params from pending and committed tracking.
   */
  #removeKeys(params: TParams[]) {
    // Decrement reference count and collect cache keys with zero refs
    const cacheKeysToRemove: string[] = [];
    params.forEach((itemParams) => {
      const cacheKey = this.getCacheKey(itemParams);
      const currentCount = this.#keyRefCounts.get(cacheKey) || 0;
      const newCount = Math.max(0, currentCount - 1);

      if (newCount === 0) {
        this.#keyRefCounts.delete(cacheKey);
        cacheKeysToRemove.push(cacheKey);
      } else {
        this.#keyRefCounts.set(cacheKey, newCount);
      }
    });

    // Only remove params that have zero references
    if (cacheKeysToRemove.length === 0) {
      return;
    }

    // Remove from pending batch
    this.#pendingKeys.update((current) =>
      current.filter((p) => !cacheKeysToRemove.includes(this.getCacheKey(p))),
    );

    // Remove from committed keys
    this.#committedKeys.update((current) =>
      current.filter((p) => !cacheKeysToRemove.includes(this.getCacheKey(p))),
    );

    // Clear status for removed items to prevent memory accumulation
    this.#itemStatuses.update((map) => {
      const newMap = new Map(map);
      cacheKeysToRemove.forEach((cacheKey) => {
        newMap.delete(cacheKey);
      });
      return newMap;
    });
  }

  /**
   * Reloads all committed items by clearing the cache.
   */
  reload() {
    this.#cachedResults.set(new Map());
    this.#itemStatuses.set(new Map());
    return this.#resource.reload();
  }

  /**
   * Reloads specific items by removing them from cache.
   */
  reloadKeys(params: TParams[]) {
    const cacheKeysToRemove = params.map((p) => this.getCacheKey(p));

    // Remove from cache
    this.#cachedResults.update((map) => {
      const newMap = new Map(map);
      cacheKeysToRemove.forEach((cacheKey) => {
        newMap.delete(cacheKey);
      });
      return newMap;
    });

    // Clear status
    this.#itemStatuses.update((map) => {
      const newMap = new Map(map);
      cacheKeysToRemove.forEach((cacheKey) => {
        newMap.delete(cacheKey);
      });
      return newMap;
    });

    return this.#resource.reload();
  }

  /**
   * Creates a ResourceRef-like object for specific params.
   * Automatically registers the params for batched fetching and cleans up on component destruction.
   */
  resource(
    params: Signal<TParams> | TParams,
  ): BatchingResourceRef<TResourceValue> {
    const destroyRef = inject(DestroyRef);
    const paramsSignal = isSignal(params) ? params : signal(params);

    // Track the last registered params
    let lastRegisteredParams: TParams | null = null;

    // Use effect to handle params registration
    effect(() => {
      const currentParams = paramsSignal();

      // Register new params if changed
      if (currentParams !== lastRegisteredParams) {
        // Remove old params if exists
        if (lastRegisteredParams !== null) {
          this.#removeKeys([lastRegisteredParams]);
        }

        // Add new params
        this.#addKeys([currentParams]);
        lastRegisteredParams = currentParams;
      }
    });

    // Cleanup when component is destroyed
    destroyRef.onDestroy(() => {
      if (lastRegisteredParams !== null) {
        this.#removeKeys([lastRegisteredParams]);
      }
    });

    // Helper to get current cache key
    const getCacheKey = () => this.getCacheKey(paramsSignal());

    // Create item-specific computed signals
    const value = computed(() => {
      const cacheKey = getCacheKey();
      return this.#cachedResults().get(cacheKey);
    });

    const hasValue = computed(() => {
      return this.#cachedResults().has(getCacheKey());
    });

    const isLoading = computed(() => {
      const cacheKey = getCacheKey();
      const statusMap = this.#itemStatuses();
      const itemStatus = statusMap.get(cacheKey);

      // Return loading status from item-specific status map
      return itemStatus?.isLoading ?? false;
    });

    const error = computed(() => {
      const statusMap = this.#itemStatuses();
      return statusMap.get(getCacheKey())?.error;
    });

    const status = computed<ResourceStatus>(() => {
      if (error()) return 'error' as ResourceStatus;
      if (isLoading()) return 'loading' as ResourceStatus;
      if (hasValue()) return 'resolved' as ResourceStatus;
      return 'idle' as ResourceStatus;
    });

    const reload = () => {
      this.reloadKeys([paramsSignal()]);
    };

    return {
      value,
      hasValue,
      isLoading,
      error,
      status,
      reload,
    };
  }
}
