import { Database } from "bun:sqlite";
import { createHash } from "crypto";
import { watch, type FSWatcher } from "fs";
import { readdir, stat } from "fs/promises";
import { join, relative } from "path";
import { parseFile } from "music-metadata";
import { type Track } from "./db";

const HASH_CHUNK_SIZE = 64 * 1024; // 64KB
const AUDIO_EXTENSIONS = new Set([".mp3", ".ogg", ".flac", ".wav", ".m4a", ".aac", ".opus", ".wma", ".mp4"]);

export interface LibraryTrack extends Track {
  filename: string;
  filepath: string;
  available: boolean;
}

interface CacheEntry {
  path: string;
  track_id: string;
  size: number;
  mtime_ms: number;
  cached_at: number;
}

type LibraryEventType = "added" | "removed" | "changed";
type LibraryEventCallback = (track: LibraryTrack) => void;

export class Library {
  private cacheDb: Database;
  private musicDir: string;
  private trackMap = new Map<string, string>(); // trackId -> filepath
  private trackInfo = new Map<string, LibraryTrack>(); // trackId -> full info
  private watcher: FSWatcher | null = null;
  private eventListeners = new Map<LibraryEventType, Set<LibraryEventCallback>>();
  private pendingFiles = new Map<string, NodeJS.Timeout>(); // filepath -> debounce timer
  
  // Scan progress tracking
  private _scanProgress = { scanning: false, processed: 0, total: 0 };
  private scanCompleteCallbacks: Set<() => void> = new Set();
  
  get scanProgress() {
    return { ...this._scanProgress };
  }

  constructor(musicDir: string, cacheDbPath: string = "./library_cache.db") {
    this.musicDir = musicDir;
    this.cacheDb = new Database(cacheDbPath);
    this.cacheDb.run("PRAGMA journal_mode = WAL");
    this.initCacheDb();
  }

  private initCacheDb(): void {
    this.cacheDb.run(`
      CREATE TABLE IF NOT EXISTS file_cache (
        path TEXT PRIMARY KEY,
        track_id TEXT NOT NULL,
        size INTEGER NOT NULL,
        mtime_ms INTEGER NOT NULL,
        cached_at INTEGER DEFAULT (unixepoch())
      )
    `);
    this.cacheDb.run(`CREATE INDEX IF NOT EXISTS idx_file_cache_track ON file_cache(track_id)`);
    
    // Tracks table - stores metadata for each unique track
    this.cacheDb.run(`
      CREATE TABLE IF NOT EXISTS tracks (
        id TEXT PRIMARY KEY,
        title TEXT,
        artist TEXT,
        album TEXT,
        duration REAL NOT NULL,
        size INTEGER NOT NULL,
        created_at INTEGER DEFAULT (unixepoch())
      )
    `);
    
    // Library activity log
    this.cacheDb.run(`
      CREATE TABLE IF NOT EXISTS library_log (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        timestamp INTEGER DEFAULT (unixepoch()),
        action TEXT NOT NULL,
        track_id TEXT,
        filename TEXT,
        title TEXT,
        user_id INTEGER,
        username TEXT
      )
    `);
    this.cacheDb.run(`CREATE INDEX IF NOT EXISTS idx_library_log_time ON library_log(timestamp DESC)`);
  }
  
  logActivity(action: string, track: { id?: string; filename?: string; title?: string | null }, user?: { id: number; username: string } | null): void {
    this.cacheDb.query(`
      INSERT INTO library_log (action, track_id, filename, title, user_id, username)
      VALUES (?, ?, ?, ?, ?, ?)
    `).run(action, track.id || null, track.filename || null, track.title || null, user?.id || null, user?.username || null);
  }
  
  getActivityLog(limit: number = 100): Array<{ id: number; timestamp: number; action: string; track_id: string | null; filename: string | null; title: string | null; user_id: number | null; username: string | null }> {
    return this.cacheDb.query(`SELECT * FROM library_log ORDER BY timestamp DESC LIMIT ?`).all(limit) as any;
  }
  
  private upsertTrack(track: { id: string; title: string | null; artist: string | null; album: string | null; duration: number; size: number }): void {
    this.cacheDb.query(`
      INSERT INTO tracks (id, title, artist, album, duration, size)
      VALUES (?, ?, ?, ?, ?, ?)
      ON CONFLICT(id) DO UPDATE SET
        title = COALESCE(excluded.title, title),
        artist = COALESCE(excluded.artist, artist),
        album = COALESCE(excluded.album, album)
    `).run(track.id, track.title, track.artist, track.album, track.duration, track.size);
  }

  async computeTrackId(filePath: string): Promise<string> {
    const file = Bun.file(filePath);
    const size = file.size;

    // Read first 64KB
    const chunk = await file.slice(0, HASH_CHUNK_SIZE).arrayBuffer();

    // Get duration from metadata
    let duration = 0;
    try {
      const metadata = await parseFile(filePath, { duration: true });
      duration = metadata.format.duration || 0;
    } catch {
      // If metadata parsing fails, use size only
    }

    // Hash: size + duration + first 64KB
    const hash = createHash("sha256");
    hash.update(`${size}:${duration.toFixed(3)}:`);
    hash.update(new Uint8Array(chunk));

    return "sha256:" + hash.digest("hex").substring(0, 32);
  }

  private getCacheEntry(relativePath: string): CacheEntry | null {
    return this.cacheDb.query("SELECT * FROM file_cache WHERE path = ?").get(relativePath) as CacheEntry | null;
  }

  private setCacheEntry(relativePath: string, trackId: string, size: number, mtimeMs: number): void {
    this.cacheDb.query(`
      INSERT OR REPLACE INTO file_cache (path, track_id, size, mtime_ms, cached_at)
      VALUES (?, ?, ?, ?, unixepoch())
    `).run(relativePath, trackId, size, mtimeMs);
  }

  private removeCacheEntry(relativePath: string): void {
    this.cacheDb.query("DELETE FROM file_cache WHERE path = ?").run(relativePath);
  }

  private isAudioFile(filename: string): boolean {
    const ext = filename.substring(filename.lastIndexOf(".")).toLowerCase();
    return AUDIO_EXTENSIONS.has(ext);
  }

  private async processFile(filePath: string): Promise<LibraryTrack | null> {
    const relativePath = relative(this.musicDir, filePath);
    const filename = filePath.substring(filePath.lastIndexOf("/") + 1).replace(/\\/g, "/").split("/").pop() || filePath;

    try {
      const fileStat = await stat(filePath);
      const size = fileStat.size;
      const mtimeMs = fileStat.mtimeMs;

      // Check cache
      const cached = this.getCacheEntry(relativePath);
      let trackId: string;

      if (cached && cached.size === size && cached.mtime_ms === Math.floor(mtimeMs)) {
        trackId = cached.track_id;
      } else {
        // Compute new hash
        trackId = await this.computeTrackId(filePath);
        this.setCacheEntry(relativePath, trackId, size, Math.floor(mtimeMs));
      }

      // Get metadata
      let title = filename.replace(/\.[^.]+$/, "");
      let artist: string | null = null;
      let album: string | null = null;
      let duration = 0;

      try {
        const metadata = await parseFile(filePath, { duration: true });
        title = metadata.common.title || title;
        artist = metadata.common.artist || null;
        album = metadata.common.album || null;
        duration = metadata.format.duration || 0;
      } catch {
        // Use defaults
      }

      const track: LibraryTrack = {
        id: trackId,
        filename,
        filepath: filePath,
        title,
        artist,
        album,
        duration,
        size,
        available: true,
        created_at: Math.floor(Date.now() / 1000),
      };

      // Upsert to cache database
      this.upsertTrack({
        id: trackId,
        title,
        artist,
        album,
        duration,
        size,
      });

      return track;
    } catch (e) {
      console.error(`[Library] Failed to process ${filePath}:`, e);
      return null;
    }
  }

  async scan(): Promise<void> {
    console.log(`[Library] Quick loading from cache...`);
    
    // Single query joining file_cache and tracks - all in cacheDb now
    const cachedTracks = this.cacheDb.query(`
      SELECT fc.path, fc.track_id, t.title, t.artist, t.album, t.duration
      FROM file_cache fc
      LEFT JOIN tracks t ON fc.track_id = t.id
    `).all() as Array<{
      path: string;
      track_id: string;
      title: string | null;
      artist: string | null;
      album: string | null;
      duration: number | null;
    }>;
    
    for (const row of cachedTracks) {
      const fullPath = join(this.musicDir, row.path);
      const filename = row.path.split(/[/\\]/).pop() || row.path;
      
      const track: LibraryTrack = {
        id: row.track_id,
        filename,
        filepath: fullPath,
        title: row.title || filename.replace(/\.[^.]+$/, ""),
        artist: row.artist || null,
        album: row.album || null,
        duration: row.duration || 0,
        available: true,
      };
      
      this.trackMap.set(track.id, fullPath);
      this.trackInfo.set(track.id, track);
    }
    
    console.log(`[Library] Quick loaded ${cachedTracks.length} tracks from cache`);
    
    // Start background scan for new/changed files
    this.startBackgroundScan();
  }
  
  private async startBackgroundScan(): Promise<void> {
    console.log(`[Library] Starting background scan...`);
    const startTime = Date.now();
    let processed = 0;
    let skipped = 0;
    const BATCH_SIZE = 10;
    const BATCH_DELAY_MS = 50; // Pause between batches to not block
    
    const filesToProcess: string[] = [];
    
    // Collect all files first (fast operation)
    const collectFiles = async (dir: string) => {
      try {
        const entries = await readdir(dir, { withFileTypes: true });
        for (const entry of entries) {
          const fullPath = join(dir, entry.name);
          if (entry.isDirectory()) {
            await collectFiles(fullPath);
          } else if (entry.isFile() && this.isAudioFile(entry.name)) {
            filesToProcess.push(fullPath);
          }
        }
      } catch (e) {
        console.error(`[Library] Error reading directory ${dir}:`, e);
      }
    };
    
    await collectFiles(this.musicDir);
    console.log(`[Library] Found ${filesToProcess.length} audio files to check`);
    
    // Initialize scan progress
    this._scanProgress = { scanning: true, processed: 0, total: filesToProcess.length };
    
    // Process in batches with delays
    for (let i = 0; i < filesToProcess.length; i += BATCH_SIZE) {
      const batch = filesToProcess.slice(i, i + BATCH_SIZE);
      
      for (const fullPath of batch) {
        const relativePath = relative(this.musicDir, fullPath);
        const cacheEntry = this.getCacheEntry(relativePath);
        
        // Check if already loaded and cache is valid
        if (cacheEntry && this.trackInfo.has(cacheEntry.track_id)) {
          try {
            const fileStat = await stat(fullPath);
            if (cacheEntry.size === fileStat.size && cacheEntry.mtime_ms === Math.floor(fileStat.mtimeMs)) {
              skipped++;
              this._scanProgress.processed++;
              continue;
            }
          } catch {}
        }
        
        // Need to process this file
        const track = await this.processFile(fullPath);
        if (track) {
          const isNew = !this.trackInfo.has(track.id);
          this.trackMap.set(track.id, fullPath);
          this.trackInfo.set(track.id, track);
          processed++;
          
          if (isNew) {
            this.emit("added", track);
          }
        }
        this._scanProgress.processed++;
      }
      
      // Yield to other operations
      if (i + BATCH_SIZE < filesToProcess.length) {
        await new Promise(resolve => setTimeout(resolve, BATCH_DELAY_MS));
      }
      
      // Progress log every 100 files
      if ((i + BATCH_SIZE) % 100 === 0) {
        console.log(`[Library] Background scan progress: ${Math.min(i + BATCH_SIZE, filesToProcess.length)}/${filesToProcess.length}`);
      }
    }
    
    this._scanProgress = { scanning: false, processed: filesToProcess.length, total: filesToProcess.length };
    const elapsed = Date.now() - startTime;
    console.log(`[Library] Background scan complete: ${processed} new/updated, ${skipped} unchanged in ${elapsed}ms`);
    
    // Notify listeners that scan is complete
    this.scanCompleteCallbacks.forEach(cb => cb());
  }
  
  onScanComplete(callback: () => void): void {
    this.scanCompleteCallbacks.add(callback);
  }

  startWatching(): void {
    if (this.watcher) return;

    console.log(`[Library] Watching ${this.musicDir} for changes...`);

    this.watcher = watch(this.musicDir, { recursive: true }, async (event, filename) => {
      if (!filename) return;

      // Normalize path separators
      const normalizedFilename = filename.replace(/\\/g, "/");
      if (!this.isAudioFile(normalizedFilename)) return;

      const fullPath = join(this.musicDir, filename);

      // Debounce: wait 5 seconds after last change before processing
      const existing = this.pendingFiles.get(fullPath);
      if (existing) clearTimeout(existing);

      this.pendingFiles.set(fullPath, setTimeout(async () => {
        this.pendingFiles.delete(fullPath);
        await this.processFileChange(fullPath);
      }, 5000));
    });
  }

  private async processFileChange(fullPath: string): Promise<void> {
    try {
      const exists = await Bun.file(fullPath).exists();

      if (exists) {
        // File added or modified
        const track = await this.processFile(fullPath);
        if (track) {
          const wasNew = !this.trackMap.has(track.id);
          this.trackMap.set(track.id, fullPath);
          this.trackInfo.set(track.id, track);
          this.emit(wasNew ? "added" : "changed", track);
          console.log(`[Library] ${wasNew ? "Added" : "Updated"}: ${track.title}`);
        }
      } else {
        // File deleted
        const relativePath = relative(this.musicDir, fullPath);
        const cacheEntry = this.getCacheEntry(relativePath);

        if (cacheEntry) {
          const track = this.trackInfo.get(cacheEntry.track_id);
          if (track) {
            track.available = false;
            this.trackMap.delete(cacheEntry.track_id);
            this.emit("removed", track);
            console.log(`[Library] Removed: ${track.title}`);
          }
          this.removeCacheEntry(relativePath);
        }
      }
    } catch (e) {
      console.error(`[Library] Watch error for ${fullPath}:`, e);
    }
  }

  stopWatching(): void {
    if (this.watcher) {
      this.watcher.close();
      this.watcher = null;
    }
  }

  // Event handling
  on(event: LibraryEventType, callback: LibraryEventCallback): void {
    if (!this.eventListeners.has(event)) {
      this.eventListeners.set(event, new Set());
    }
    this.eventListeners.get(event)!.add(callback);
  }

  off(event: LibraryEventType, callback: LibraryEventCallback): void {
    this.eventListeners.get(event)?.delete(callback);
  }

  private emit(event: LibraryEventType, track: LibraryTrack): void {
    this.eventListeners.get(event)?.forEach((cb) => cb(track));
  }

  // Accessors
  getTrack(id: string): LibraryTrack | null {
    return this.trackInfo.get(id) || null;
  }

  getFilePath(id: string): string | null {
    return this.trackMap.get(id) || null;
  }

  getAllTracks(): LibraryTrack[] {
    return Array.from(this.trackInfo.values()).filter((t) => t.available);
  }

  getTrackCount(): number {
    return this.trackMap.size;
  }

  // Check if a track ID is available (file exists)
  isAvailable(id: string): boolean {
    return this.trackMap.has(id);
  }
}
