/*
 * SubMiner - Subtitle mining overlay for mpv
 * Copyright (C) 2024 sudacode
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

import { normalizePlainSubtitleText } from './core/services/ass-text';

interface TimingEntry {
  startTime: number;
  endTime: number;
  timestamp: number;
}

interface HistoryEntry {
  displayText: string;
  timingKey: string;
  startTime: number;
  endTime: number;
  secondaryText?: string;
  timestamp: number;
}

export interface SubtitleTimingBlock {
  displayText: string;
  startTime: number;
  endTime: number;
  secondaryText?: string;
}

export class SubtitleTimingTracker {
  private timings = new Map<string, TimingEntry>();
  private history: HistoryEntry[] = [];
  private readonly maxHistory = 200;
  private readonly ttlMs = 5 * 60 * 1000;
  private cleanupInterval: ReturnType<typeof setInterval> | null = null;

  constructor() {
    this.startCleanup();
  }

  recordSubtitle(text: string, startTime: number, endTime: number, secondaryText?: string): void {
    const normalizedText = this.normalizeText(text);
    if (!normalizedText) return;

    const displayText = this.prepareDisplayText(text);
    const displaySecondaryText = secondaryText ? this.prepareDisplayText(secondaryText) : undefined;
    const timingKey = normalizedText;

    this.timings.set(timingKey, {
      startTime,
      endTime,
      timestamp: Date.now(),
    });

    // Check for duplicate of most recent entry (deduplicate adjacent repeats)
    const lastEntry = this.history[this.history.length - 1];
    if (
      lastEntry &&
      lastEntry.timingKey === timingKey &&
      lastEntry.startTime === startTime &&
      lastEntry.endTime === endTime
    ) {
      // Refresh metadata for repeated notifications of the same subtitle event.
      lastEntry.startTime = startTime;
      lastEntry.endTime = endTime;
      lastEntry.secondaryText = displaySecondaryText;
      lastEntry.timestamp = Date.now();
      return;
    }

    this.history.push({
      displayText,
      timingKey,
      startTime,
      endTime,
      secondaryText: displaySecondaryText,
      timestamp: Date.now(),
    });

    // Prune history if too large
    if (this.history.length > this.maxHistory) {
      this.history = this.history.slice(-this.maxHistory);
    }
  }

  findTiming(text: string): { startTime: number; endTime: number } | null {
    const normalizedText = this.normalizeText(text);
    if (!normalizedText) return null;

    const entry = this.timings.get(normalizedText);
    if (!entry) {
      return this.findFuzzyMatch(normalizedText);
    }

    return {
      startTime: entry.startTime,
      endTime: entry.endTime,
    };
  }

  /**
   * Get recent subtitle blocks in timeline order.
   * Returns up to `count` known subtitle events ending at the current event.
   * Blocks preserve internal line breaks and are joined with blank lines.
   */
  getRecentBlocks(count: number): string[] {
    return this.getRecentTimelineEntries(count).map((entry) => entry.displayText);
  }

  /**
   * Get recent subtitle blocks with their original event timings in timeline order.
   * Returns up to `count` known subtitle events ending at the current event.
   */
  getRecentEntries(count: number): SubtitleTimingBlock[] {
    return this.getRecentTimelineEntries(count).map((entry) => ({
      displayText: entry.displayText,
      startTime: entry.startTime,
      endTime: entry.endTime,
      secondaryText: entry.secondaryText,
    }));
  }

  /**
   * Get display text for the most recent subtitle.
   */
  getCurrentSubtitle(): string | null {
    const lastEntry = this.history[this.history.length - 1];
    return lastEntry ? lastEntry.displayText : null;
  }

  private getRecentTimelineEntries(count: number): HistoryEntry[] {
    if (count <= 0) return [];

    const currentEntry = this.history[this.history.length - 1];
    if (!currentEntry) return [];

    const timelineEntries: HistoryEntry[] = [];
    for (const entry of this.history) {
      const existingIndex = timelineEntries.findIndex((candidate) =>
        this.isSameSubtitleEvent(candidate, entry),
      );
      if (existingIndex === -1) {
        timelineEntries.push(entry);
      } else {
        timelineEntries[existingIndex] = entry;
      }
    }

    timelineEntries.sort(
      (left, right) => left.startTime - right.startTime || left.endTime - right.endTime,
    );
    const currentIndex = timelineEntries.findIndex((entry) =>
      this.isSameSubtitleEvent(entry, currentEntry),
    );
    if (currentIndex === -1) return [];

    return timelineEntries.slice(Math.max(0, currentIndex - count + 1), currentIndex + 1);
  }

  private isSameSubtitleEvent(left: HistoryEntry, right: HistoryEntry): boolean {
    return (
      left.timingKey === right.timingKey &&
      left.startTime === right.startTime &&
      left.endTime === right.endTime
    );
  }

  private findFuzzyMatch(text: string): { startTime: number; endTime: number } | null {
    let bestMatch: TimingEntry | null = null;
    let bestScore = 0;

    for (const [key, entry] of this.timings.entries()) {
      const score = this.calculateSimilarity(text, key);
      if (score > bestScore && score > 0.7) {
        bestScore = score;
        bestMatch = entry;
      }
    }

    if (bestMatch) {
      return {
        startTime: bestMatch.startTime,
        endTime: bestMatch.endTime,
      };
    }

    return null;
  }

  private calculateSimilarity(a: string, b: string): number {
    const longer = a.length > b.length ? a : b;
    const shorter = a.length > b.length ? b : a;

    if (longer.length === 0) return 1;

    const editDistance = this.getEditDistance(longer, shorter);
    return (longer.length - editDistance) / longer.length;
  }

  private getEditDistance(longer: string, shorter: string): number {
    const costs: number[] = [];
    for (let i = 0; i <= shorter.length; i++) {
      let lastValue = i;
      for (let j = 1; j <= longer.length; j++) {
        let newValue = costs[j - 1] || 0;
        if (longer.charAt(j - 1) !== shorter.charAt(i - 1)) {
          newValue = Math.min(Math.min(newValue, lastValue), costs[j] || 0) + 1;
        }
        costs[j - 1] = lastValue;
        lastValue = newValue;
      }
      costs[shorter.length] = lastValue;
    }
    return costs[shorter.length] || 0;
  }

  // Both sides take text mpv has already decoded from ASS; only whitespace differs
  // between the lookup key (single line) and the display form (line breaks kept).
  private normalizeText(text: string): string {
    return normalizePlainSubtitleText(text, { collapseLineBreaks: true });
  }

  private prepareDisplayText(text: string): string {
    return normalizePlainSubtitleText(text);
  }

  private startCleanup(): void {
    this.cleanupInterval = setInterval(() => {
      this.cleanup();
    }, 60000);
  }

  cleanup(): void {
    const now = Date.now();
    // Clean up old timing entries
    for (const [key, entry] of this.timings.entries()) {
      if (now - entry.timestamp > this.ttlMs) {
        this.timings.delete(key);
      }
    }
    // Clean up old history entries
    this.history = this.history.filter((entry) => now - entry.timestamp <= this.ttlMs);
  }

  destroy(): void {
    if (this.cleanupInterval) {
      clearInterval(this.cleanupInterval);
      this.cleanupInterval = null;
    }
    this.timings.clear();
    this.history = [];
  }
}
