services_ngx-cachr.service.ts

import { Injectable, Inject, signal, WritableSignal, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { 
  CACHE_CONFIG, 
  CacheConfig, 
  CacheEntry, 
  CacheKey, 
  CachedResourceOptions, 
  CacheStrategy, 
  CacheSnapshot
} from '../core/types';
import { MemoryDriver } from '../drivers/memory.driver';
import { StorageDriver } from '../drivers/storage.driver';
import { serializeKey } from '../utils/key-serializer';

/**
 * The core service responsible for managing the cache layers (Memory and Storage).
 * It handles the execution of caching strategies and coordination between drivers.
 * 
 * Generally, you shouldn't need to inject this service directly. 
 * Use the {@link cachedResource} function instead for a higher-level, reactive API.
 * 
 * @class
 * @providedIn root
 */
@Injectable({
  providedIn: 'root'
})
export class NgxCachrService {
  private memoryDriver: MemoryDriver;
  private storageDriver: StorageDriver | null = null;
  private pendingRequests = new Map<string, Promise<unknown>>();

  constructor(
    @Inject(CACHE_CONFIG) private config: CacheConfig,
    @Inject(PLATFORM_ID) private platformId: Object
  ) {
    this.memoryDriver = new MemoryDriver(this.config.memory?.maxEntries);
    
    const storageImpl = this.getStorage();
    if (storageImpl) {
      this.storageDriver = new StorageDriver(storageImpl, this.config.prefix);
      this.checkVersion();
    }
  }

  protected getStorage(): Storage | null {
    return isPlatformBrowser(this.platformId) ? window.localStorage : null;
  }

  private async checkVersion() {
    if (!this.storageDriver) return;
    
    const storage = this.getStorage();
    if (!storage) return;

    const versionKey = `${this.config.prefix}version`;
    const savedVersion = storage.getItem(versionKey);

    if (savedVersion !== String(this.config.version)) {
      // Version mismatch, clear storage
      await this.storageDriver.clear();
      storage.setItem(versionKey, String(this.config.version));
    }
  }

  /**
   * Resolves a resource using the configured caching strategy.
   * 
   * @template T
   * @param {CachedResourceOptions<T>} options - Options defining the resource, key, and strategy.
   * @param {Function} [updateState] - Optional callback to receive state updates (loading, success, error, revalidating).
   * @returns {Promise<T | undefined>} A promise that resolves to the data (if strategy allows immediate return) or undefined.
   * 
   * @example
   * const data = await service.get({
   *   key: 'user:1',
   *   loader: () => fetch('/api/user/1').then(r => r.json()),
   *   strategy: 'swr'
   * });
   */
  async get<T>(
    options: CachedResourceOptions<T>, 
    updateState?: (state: Partial<{ data: T, status: 'loading' | 'success' | 'error' | 'revalidating', error: unknown }>) => void
  ): Promise<T | undefined> {
    const key = serializeKey(options.key);
    const ttl = options.ttl ?? this.config.defaultTtl;
    const strategy = options.strategy ?? this.config.defaultStrategy;

    // Helper to execute the loader and cache result
    const fetchAndCache = async (): Promise<T> => {
      if (this.pendingRequests.has(key)) {
        return this.pendingRequests.get(key) as Promise<T>;
      }

      const promise = (async () => {
        try {
          const data = await options.loader();
          const entry: CacheEntry<T> = {
            data,
            metadata: {
              createdAt: Date.now(),
              ttl,
              tags: options.tags || [],
              version: this.config.version
            }
          };
          
          // Save to memory
          await this.memoryDriver.set(key, entry);
          
          // Save to storage (if allowed)
          // TODO: Check excludeSecure logic if we add that flag to options
          if (this.storageDriver && options.storage !== 'memory') {
            this.storageDriver.set(key, entry);
          }
          
          return data;
        } finally {
          this.pendingRequests.delete(key);
        }
      })();

      this.pendingRequests.set(key, promise);
      return promise;
    };

    // 1. Check Memory
    const memoryEntry = await this.memoryDriver.get<T>(key);
    if (memoryEntry) {
      if (this.isFresh(memoryEntry)) {
         if (strategy !== 'network-first') {
             return memoryEntry.data;
         }
      } else {
        // Stale
        if (strategy === 'swr') {
          // Return stale data immediately, then revalidate
          if (updateState) updateState({ data: memoryEntry.data, status: 'revalidating' });
          
          fetchAndCache().then(newData => {
             if (updateState) updateState({ data: newData, status: 'success' });
          }).catch(err => {
             if (updateState) updateState({ error: err, status: 'error' });
          });
          
          return memoryEntry.data;
        }
      }
    }

    // 2. Check Storage (if not in memory or strategy forces it)
    if (this.storageDriver && options.storage !== 'memory') {
        const storageEntry = await this.storageDriver.get<T>(key);
        if (storageEntry) {
            // Populate memory
            await this.memoryDriver.set(key, storageEntry);

            if (this.isFresh(storageEntry)) {
                if (strategy !== 'network-first') {
                    return storageEntry.data;
                }
            } else {
                if (strategy === 'swr') {
                    if (updateState) updateState({ data: storageEntry.data, status: 'revalidating' });
                    fetchAndCache().then(newData => {
                        if (updateState) updateState({ data: newData, status: 'success' });
                    }).catch(err => {
                        if (updateState) updateState({ error: err, status: 'error' });
                    });
                    return storageEntry.data;
                }
            }
        }
    }

    // 3. Network (if not found or network-first)
    if (updateState) updateState({ status: 'loading' });
    try {
        const data = await fetchAndCache();
        if (updateState) updateState({ data, status: 'success' });
        return data;
    } catch (error) {
        if (updateState) updateState({ error, status: 'error' });
        throw error;
    }
  }

  private isFresh(entry: CacheEntry<unknown>): boolean {
    return Date.now() - entry.metadata.createdAt < entry.metadata.ttl;
  }

  /**
   * Invalidates a cache entry by removing it from both memory and storage.
   * 
   * @param {CacheKey} key - The key to invalidate.
   * @returns {Promise<void>}
   */
  async invalidate(key: CacheKey): Promise<void> {
    const serializedKey = serializeKey(key);
    await this.memoryDriver.delete(serializedKey);
    if (this.storageDriver) {
      await this.storageDriver.delete(serializedKey);
    }
  }

  /**
   * Manually sets a cache entry.
   * 
   * @template T
   * @param {CacheKey} key - The key to set.
   * @param {T} data - The data to store.
   * @param {number} [ttl] - Optional time-to-live in ms.
   * @returns {Promise<void>}
   */
  async set<T>(key: CacheKey, data: T, ttl?: number): Promise<void> {
    const serializedKey = serializeKey(key);
    const entry: CacheEntry<T> = {
        data,
        metadata: {
            createdAt: Date.now(),
            ttl: ttl ?? this.config.defaultTtl,
            tags: [],
            version: this.config.version
        }
    };
    await this.memoryDriver.set(serializedKey, entry);
    if (this.storageDriver) {
        this.storageDriver.set(serializedKey, entry);
    }
  }

  /**
   * Returns a debug snapshot of the current cache state.
   */
  getDebugSnapshot(): CacheSnapshot {
    const keys = this.memoryDriver.keys();
    const memory: Record<string, CacheEntry<any>> = {};

    // this isnt ideal. we should come back and clean this up
    // TODO: clean up this implementation to be more structured.

    // just cast to any to bypass private for the sake of the 'hack'.
    const rawCache = (this.memoryDriver as any).cache as Map<string, CacheEntry<any>>;
    rawCache.forEach((val, key) => {
        memory[key] = val;
    });

    return {
      keys,
      memory,
      pending: Array.from(this.pendingRequests.keys())
    };
  }

  // TODO: Invalidate by tag
}