/**
 * Persistent file-backed cache with in-memory L1.
 *
 * Storage layout:
 *   ~/.mcpctl/cache/<namespace>/<key>.dat
 *
 * - namespace = sanitized "provider--model--proxymodel"
 * - key = 16-char hex content hash (from CacheProvider.hash)
 * - .dat = raw cached content (no JSON wrapper)
 * - mtime tracks last access for LRU eviction
 *
 * L1 in-memory Map provides fast repeated lookups within a session.
 * L2 disk provides persistence across mcplocal restarts.
 */
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import type { CacheProvider } from './types.js';

export interface FileCacheConfig {
  /** Cache root directory. Default: ~/.mcpctl/cache */
  dir?: string;
  /**
   * Max total cache size. Accepts:
   *  - number: bytes (e.g. 268435456)
   *  - string with unit: "256MB", "1GB", "500KB", "2TB"
   *  - percentage of partition: "10%", "50%"
   * Default: 256MB
   */
  maxSize?: number | string;
  /** @deprecated Use maxSize instead */
  maxSizeBytes?: number;
  /** TTL in ms since last access. Default: 30 days */
  ttlMs?: number;
  /** Max in-memory L1 entries per instance. Default: 500 */
  maxMemoryEntries?: number;
}

const DEFAULT_DIR = path.join(os.homedir(), '.mcpctl', 'cache');
const DEFAULT_MAX_SIZE = 256 * 1024 * 1024; // 256MB
const DEFAULT_TTL = 30 * 24 * 60 * 60 * 1000; // 30 days
const DEFAULT_MEMORY = 500;

const SIZE_UNITS: Record<string, number> = {
  B: 1,
  KB: 1024,
  MB: 1024 * 1024,
  GB: 1024 * 1024 * 1024,
  TB: 1024 * 1024 * 1024 * 1024,
};

/**
 * Parse a human-readable size spec into bytes.
 *  - number → bytes
 *  - "256MB", "1.5GB", "500KB", "2TB" → bytes
 *  - "10%", "50%" → percentage of the filesystem hosting cacheDir
 */
export function parseMaxSize(spec: number | string, cacheDir?: string): number {
  if (typeof spec === 'number') return spec;
  const s = spec.trim().toUpperCase();

  // Percentage of partition
  const pctMatch = s.match(/^(\d+(?:\.\d+)?)\s*%$/);
  if (pctMatch && pctMatch[1]) {
    const pct = parseFloat(pctMatch[1]);
    if (pct <= 0 || pct > 100) throw new Error(`Invalid cache size percentage: ${spec}`);
    const dir = cacheDir ?? DEFAULT_DIR;
    try {
      const stat = fs.statfsSync(dir);
      const totalBytes = stat.bsize * stat.blocks;
      return Math.floor(totalBytes * (pct / 100));
    } catch {
      // Partition not stat-able (dir doesn't exist yet) — try parent
      try {
        const stat = fs.statfsSync(path.dirname(dir));
        const totalBytes = stat.bsize * stat.blocks;
        return Math.floor(totalBytes * (pct / 100));
      } catch {
        // Fallback to default
        return DEFAULT_MAX_SIZE;
      }
    }
  }

  // Unit-based: "256MB", "1.5GB" etc.
  const unitMatch = s.match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)$/);
  if (unitMatch && unitMatch[1] && unitMatch[2]) {
    const val = parseFloat(unitMatch[1]);
    const multiplier = SIZE_UNITS[unitMatch[2]];
    if (multiplier !== undefined) return Math.floor(val * multiplier);
  }

  // Plain number string
  const n = Number(spec);
  if (!isNaN(n) && n > 0) return Math.floor(n);

  throw new Error(`Invalid cache maxSize: "${spec}". Use bytes, "256MB", "1GB", or "10%"`);
}

/** Sanitize namespace for filesystem use. */
function sanitize(s: string): string {
  return s.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 100);
}

export class FileCache implements CacheProvider {
  private readonly memory = new Map<string, string>();
  private readonly nsDir: string;
  private readonly rootDir: string;
  private readonly maxSizeBytes: number;
  private readonly ttlMs: number;
  private readonly maxMemoryEntries: number;
  private dirReady = false;
  private cleanupScheduled = false;

  constructor(
    readonly namespace: string,
    config?: FileCacheConfig,
  ) {
    this.rootDir = config?.dir ?? DEFAULT_DIR;
    this.nsDir = path.join(this.rootDir, sanitize(namespace));
    this.maxSizeBytes = config?.maxSize
      ? parseMaxSize(config.maxSize, this.rootDir)
      : (config?.maxSizeBytes ?? DEFAULT_MAX_SIZE);
    this.ttlMs = config?.ttlMs ?? DEFAULT_TTL;
    this.maxMemoryEntries = config?.maxMemoryEntries ?? DEFAULT_MEMORY;
  }

  hash(content: string): string {
    return createHash('sha256').update(content).digest('hex').slice(0, 16);
  }

  async getOrCompute(key: string, compute: () => Promise<string>): Promise<string> {
    const cached = await this.get(key);
    if (cached !== null) return cached;
    const value = await compute();
    await this.set(key, value);
    return value;
  }

  async get(key: string): Promise<string | null> {
    // L1: memory
    const memVal = this.memory.get(key);
    if (memVal !== undefined) {
      // LRU: move to end
      this.memory.delete(key);
      this.memory.set(key, memVal);
      return memVal;
    }

    // L2: disk
    const filePath = this.keyPath(key);
    try {
      const value = fs.readFileSync(filePath, 'utf-8');
      // Touch mtime for LRU (fire-and-forget)
      const now = new Date();
      fs.utimes(filePath, now, now, () => {});
      this.setMemory(key, value);
      return value;
    } catch {
      return null;
    }
  }

  async set(key: string, value: string): Promise<void> {
    this.setMemory(key, value);

    // L2: persist to disk
    this.ensureDir();
    const filePath = this.keyPath(key);
    try {
      fs.writeFileSync(filePath, value);
    } catch {
      // Disk write failure is non-fatal — memory cache still works
    }

    this.scheduleCleanup();
  }

  /** Number of in-memory entries. */
  get memorySize(): number {
    return this.memory.size;
  }

  /** Clear in-memory L1 cache. */
  clearMemory(): void {
    this.memory.clear();
  }

  // -- Static helpers for CLI / lifecycle --

  /** Get stats for all namespaces under the cache root. */
  static stats(rootDir?: string): CacheStats {
    const dir = rootDir ?? DEFAULT_DIR;
    const namespaces: NamespaceStats[] = [];
    let totalSize = 0;
    let totalEntries = 0;

    try {
      const dirs = fs.readdirSync(dir, { withFileTypes: true });
      for (const d of dirs) {
        if (!d.isDirectory()) continue;
        const nsPath = path.join(dir, d.name);
        const ns = scanNamespace(nsPath);
        namespaces.push({ name: d.name, ...ns });
        totalSize += ns.size;
        totalEntries += ns.entries;
      }
    } catch {
      // Cache dir doesn't exist yet
    }

    return { rootDir: dir, totalSize, totalEntries, namespaces };
  }

  /** Clear a specific namespace or all namespaces. */
  static clear(options?: { rootDir?: string; namespace?: string; olderThanMs?: number }): ClearResult {
    const dir = options?.rootDir ?? DEFAULT_DIR;
    let removed = 0;
    let freedBytes = 0;

    if (options?.namespace) {
      const nsPath = path.join(dir, sanitize(options.namespace));
      const result = clearDir(nsPath, options.olderThanMs);
      removed = result.removed;
      freedBytes = result.freedBytes;
    } else {
      try {
        const dirs = fs.readdirSync(dir, { withFileTypes: true });
        for (const d of dirs) {
          if (!d.isDirectory()) continue;
          const result = clearDir(path.join(dir, d.name), options?.olderThanMs);
          removed += result.removed;
          freedBytes += result.freedBytes;
        }
      } catch {
        // No cache dir
      }
    }

    return { removed, freedBytes };
  }

  /** Evict expired entries and enforce size limit across all namespaces. */
  static cleanup(rootDir?: string, maxSizeBytes?: number, ttlMs?: number): ClearResult {
    const dir = rootDir ?? DEFAULT_DIR;
    const maxSize = maxSizeBytes ?? DEFAULT_MAX_SIZE;
    const ttl = ttlMs ?? DEFAULT_TTL;
    let removed = 0;
    let freedBytes = 0;

    // Phase 1: remove expired entries
    const expired = FileCache.clear({ rootDir: dir, olderThanMs: ttl });
    removed += expired.removed;
    freedBytes += expired.freedBytes;

    // Phase 2: if still over size limit, evict LRU
    const stats = FileCache.stats(dir);
    if (stats.totalSize > maxSize) {
      const entries = collectAllEntries(dir);
      // Sort by mtime ascending (oldest first)
      entries.sort((a, b) => a.mtimeMs - b.mtimeMs);

      let currentSize = stats.totalSize;
      for (const entry of entries) {
        if (currentSize <= maxSize * 0.8) break; // Evict down to 80%
        try {
          fs.unlinkSync(entry.path);
          currentSize -= entry.size;
          removed++;
          freedBytes += entry.size;
        } catch {
          // Already deleted
        }
      }
    }

    // Phase 3: remove empty namespace dirs
    try {
      const dirs = fs.readdirSync(dir, { withFileTypes: true });
      for (const d of dirs) {
        if (!d.isDirectory()) continue;
        const nsPath = path.join(dir, d.name);
        try {
          const files = fs.readdirSync(nsPath);
          if (files.length === 0) fs.rmdirSync(nsPath);
        } catch { /* ignore */ }
      }
    } catch { /* no cache dir */ }

    return { removed, freedBytes };
  }

  // -- Private --

  private keyPath(key: string): string {
    // Key may contain colons (e.g. "summary:abc123:200"), hash it for safe filename
    const safeKey = key.length <= 32 && /^[a-zA-Z0-9_-]+$/.test(key)
      ? key
      : this.hash(key);
    return path.join(this.nsDir, `${safeKey}.dat`);
  }

  private setMemory(key: string, value: string): void {
    if (this.memory.size >= this.maxMemoryEntries) {
      const oldest = this.memory.keys().next().value as string;
      this.memory.delete(oldest);
    }
    this.memory.set(key, value);
  }

  private ensureDir(): void {
    if (this.dirReady) return;
    try {
      fs.mkdirSync(this.nsDir, { recursive: true });
      this.dirReady = true;
    } catch { /* ignore */ }
  }

  private scheduleCleanup(): void {
    if (this.cleanupScheduled) return;
    this.cleanupScheduled = true;
    // Run cleanup in background after a short delay
    setTimeout(() => {
      FileCache.cleanup(this.rootDir, this.maxSizeBytes, this.ttlMs);
      this.cleanupScheduled = false;
    }, 5000);
  }
}

// -- Types --

export interface CacheStats {
  rootDir: string;
  totalSize: number;
  totalEntries: number;
  namespaces: NamespaceStats[];
}

export interface NamespaceStats {
  name: string;
  entries: number;
  size: number;
  oldestMs: number;
  newestMs: number;
}

export interface ClearResult {
  removed: number;
  freedBytes: number;
}

// -- Helpers --

function scanNamespace(nsPath: string): { entries: number; size: number; oldestMs: number; newestMs: number } {
  let entries = 0;
  let size = 0;
  let oldestMs = Infinity;
  let newestMs = 0;

  try {
    const files = fs.readdirSync(nsPath);
    for (const f of files) {
      if (!f.endsWith('.dat')) continue;
      try {
        const st = fs.statSync(path.join(nsPath, f));
        entries++;
        size += st.size;
        if (st.mtimeMs < oldestMs) oldestMs = st.mtimeMs;
        if (st.mtimeMs > newestMs) newestMs = st.mtimeMs;
      } catch { /* skip */ }
    }
  } catch { /* dir gone */ }

  return { entries, size, oldestMs: entries > 0 ? oldestMs : 0, newestMs };
}

function clearDir(nsPath: string, olderThanMs?: number): { removed: number; freedBytes: number } {
  let removed = 0;
  let freedBytes = 0;
  const cutoff = olderThanMs ? Date.now() - olderThanMs : 0;

  try {
    const files = fs.readdirSync(nsPath);
    for (const f of files) {
      if (!f.endsWith('.dat')) continue;
      const fp = path.join(nsPath, f);
      try {
        if (olderThanMs) {
          const st = fs.statSync(fp);
          if (st.mtimeMs >= cutoff) continue;
          freedBytes += st.size;
        } else {
          const st = fs.statSync(fp);
          freedBytes += st.size;
        }
        fs.unlinkSync(fp);
        removed++;
      } catch { /* already gone */ }
    }
  } catch { /* dir gone */ }

  return { removed, freedBytes };
}

function collectAllEntries(rootDir: string): Array<{ path: string; size: number; mtimeMs: number }> {
  const entries: Array<{ path: string; size: number; mtimeMs: number }> = [];
  try {
    const dirs = fs.readdirSync(rootDir, { withFileTypes: true });
    for (const d of dirs) {
      if (!d.isDirectory()) continue;
      const nsPath = path.join(rootDir, d.name);
      try {
        const files = fs.readdirSync(nsPath);
        for (const f of files) {
          if (!f.endsWith('.dat')) continue;
          const fp = path.join(nsPath, f);
          try {
            const st = fs.statSync(fp);
            entries.push({ path: fp, size: st.size, mtimeMs: st.mtimeMs });
          } catch { /* skip */ }
        }
      } catch { /* skip */ }
    }
  } catch { /* skip */ }
  return entries;
}
