import type { DatabaseSync } from './sqlite';
import { recomputeLifetimeAnimeAggregatesInTransaction } from './lifetime';
import { toDbTimestamp } from './query-shared';
import { nowMs } from './time';

/** Thrown when a move names an episode or destination entry that is not there. */
export const UNKNOWN_MOVE_TARGET_MESSAGE = 'Unknown episode or target library entry';

export interface AnimeMergeSummary {
  /** Library entry that owns every moved episode once the merge finishes. */
  survivingAnimeId: number;
  /** Entries that were folded into the survivor and deleted. */
  mergedAnimeIds: number[];
  movedVideos: number;
}

export interface VideoMoveSummary {
  targetAnimeId: number;
  /** Previous owner, or null when the episode had no library entry yet. */
  previousAnimeId: number | null;
  /** True when the previous owner was left empty and pruned. */
  removedPreviousAnime: boolean;
}

interface AnimeMetadataRow {
  normalized_title_key: string;
  anilist_id: number | null;
  title_romaji: string | null;
  title_english: string | null;
  title_native: string | null;
  episodes_total: number | null;
  description: string | null;
}

function emptyMergeSummary(survivingAnimeId: number): AnimeMergeSummary {
  return { survivingAnimeId, mergedAnimeIds: [], movedVideos: 0 };
}

function runInTransaction<T>(db: DatabaseSync, work: () => T): T {
  db.exec('BEGIN IMMEDIATE');
  try {
    const result = work();
    db.exec('COMMIT');
    return result;
  } catch (error) {
    db.exec('ROLLBACK');
    throw error;
  }
}

function readAnimeMetadata(db: DatabaseSync, animeId: number): AnimeMetadataRow | null {
  return (db
    .prepare(
      `
        SELECT normalized_title_key, anilist_id, title_romaji, title_english, title_native, episodes_total, description
        FROM imm_anime
        WHERE anime_id = ?
      `,
    )
    .get(animeId) ?? null) as AnimeMetadataRow | null;
}

function animeExists(db: DatabaseSync, animeId: number): boolean {
  return Boolean(db.prepare('SELECT 1 FROM imm_anime WHERE anime_id = ?').get(animeId));
}

function hasAnimeReferences(db: DatabaseSync, animeId: number): boolean {
  const row = db
    .prepare(
      `
        SELECT 1 AS found
        WHERE EXISTS (SELECT 1 FROM imm_videos WHERE anime_id = ?)
           OR EXISTS (SELECT 1 FROM imm_subtitle_lines WHERE anime_id = ?)
      `,
    )
    .get(animeId, animeId) as { found: number } | null;
  return Boolean(row);
}

/**
 * Distinct explicit seasons behind a library entry. Videos with no parsed
 * season are ignored, so an entry built from `Show - 03.mkv` style filenames
 * reports an empty set rather than a bogus season.
 */
export function getParsedSeasonsForAnime(db: DatabaseSync, animeId: number): Set<number> {
  const rows = db
    .prepare(
      `
        SELECT DISTINCT parsed_season AS season
        FROM imm_videos
        WHERE anime_id = ?
          AND parsed_season IS NOT NULL
          AND parsed_season > 0
      `,
    )
    .all(animeId) as Array<{ season: number }>;
  return new Set(rows.map((row) => row.season));
}

/**
 * Two entries are safe to fold together when neither spans more than one
 * explicit season and they do not disagree about which season that is. A
 * seasonless entry is compatible with anything single-season: those are the
 * `Show - 03.mkv` vs `Show.S01E03.mkv` splits that produce duplicate cards.
 */
export function animeSeasonsAreMergeCompatible(a: Set<number>, b: Set<number>): boolean {
  if (a.size > 1 || b.size > 1) return false;
  if (a.size === 0 || b.size === 0) return true;
  return [...a][0] === [...b][0];
}

/**
 * Fill in whatever the target is missing from a source row that is on its way
 * out. Must run after the source row is deleted: imm_anime.anilist_id is
 * UNIQUE, so the two rows cannot hold the same id at once.
 */
function absorbAnimeMetadata(
  db: DatabaseSync,
  targetAnimeId: number,
  source: AnimeMetadataRow | null,
  updatedAt: string,
): void {
  if (!source) return;
  db.prepare(
    `
      UPDATE imm_anime
      SET
        anilist_id = COALESCE(anilist_id, ?),
        title_romaji = COALESCE(title_romaji, ?),
        title_english = COALESCE(title_english, ?),
        title_native = COALESCE(title_native, ?),
        episodes_total = COALESCE(episodes_total, ?),
        description = COALESCE(description, ?),
        LAST_UPDATE_DATE = ?
      WHERE anime_id = ?
    `,
  ).run(
    source.anilist_id,
    source.title_romaji,
    source.title_english,
    source.title_native,
    source.episodes_total,
    source.description,
    updatedAt,
    targetAnimeId,
  );
}

/**
 * Fold `sourceAnimeIds` into `targetAnimeId`: every episode and subtitle line
 * is repointed, metadata the target is missing is inherited from the sources,
 * and the emptied source rows are deleted.
 *
 * Assumes the caller already holds a write transaction and refreshes the
 * per-anime lifetime aggregates afterwards; use {@link mergeAnimeRecords}
 * otherwise.
 */
export function mergeAnimeRecordsInTransaction(
  db: DatabaseSync,
  targetAnimeId: number,
  sourceAnimeIds: number[],
): AnimeMergeSummary {
  const summary = emptyMergeSummary(targetAnimeId);
  if (!animeExists(db, targetAnimeId)) {
    return summary;
  }

  const updatedAt = toDbTimestamp(nowMs());
  const sourceVideosStmt = db.prepare(
    'SELECT video_id AS videoId FROM imm_videos WHERE anime_id = ?',
  );
  const moveVideosStmt = db.prepare(
    'UPDATE imm_videos SET anime_id = ?, LAST_UPDATE_DATE = ? WHERE anime_id = ?',
  );
  // Repointed per video rather than by anime_id: lines recorded before the
  // async title parse assigns the link are stored with a NULL anime_id, and
  // matching on the source id would strand them unattributed.
  const moveLinesStmt = db.prepare(
    'UPDATE imm_subtitle_lines SET anime_id = ?, LAST_UPDATE_DATE = ? WHERE video_id = ?',
  );
  const dropLifetimeStmt = db.prepare('DELETE FROM imm_lifetime_anime WHERE anime_id = ?');
  const sourceAliasesStmt = db.prepare(
    'SELECT normalized_title_key AS normalizedTitleKey FROM imm_anime_title_aliases WHERE anime_id = ?',
  );
  const upsertAliasStmt = db.prepare(
    `INSERT INTO imm_anime_title_aliases(normalized_title_key, anime_id, CREATED_DATE, LAST_UPDATE_DATE)
     VALUES (?, ?, ?, ?)
     ON CONFLICT(normalized_title_key) DO UPDATE SET
       anime_id = excluded.anime_id,
       LAST_UPDATE_DATE = excluded.LAST_UPDATE_DATE`,
  );
  const dropSourceAliasesStmt = db.prepare(
    'DELETE FROM imm_anime_title_aliases WHERE anime_id = ?',
  );
  const dropAnimeStmt = db.prepare('DELETE FROM imm_anime WHERE anime_id = ?');

  for (const sourceAnimeId of new Set(sourceAnimeIds)) {
    if (sourceAnimeId === targetAnimeId || !animeExists(db, sourceAnimeId)) {
      continue;
    }

    const sourceMetadata = readAnimeMetadata(db, sourceAnimeId);
    const sourceAliases = sourceAliasesStmt.all(sourceAnimeId) as Array<{
      normalizedTitleKey: string;
    }>;
    const sourceVideoIds = (sourceVideosStmt.all(sourceAnimeId) as Array<{ videoId: number }>).map(
      (row) => row.videoId,
    );
    const moved = moveVideosStmt.run(targetAnimeId, updatedAt, sourceAnimeId) as {
      changes: number;
    };
    for (const videoId of sourceVideoIds) {
      moveLinesStmt.run(targetAnimeId, updatedAt, videoId);
    }
    dropSourceAliasesStmt.run(sourceAnimeId);
    for (const alias of [
      ...(sourceMetadata ? [sourceMetadata.normalized_title_key] : []),
      ...sourceAliases.map((row) => row.normalizedTitleKey),
    ]) {
      upsertAliasStmt.run(alias, targetAnimeId, updatedAt, updatedAt);
    }
    dropLifetimeStmt.run(sourceAnimeId);
    dropAnimeStmt.run(sourceAnimeId);
    absorbAnimeMetadata(db, targetAnimeId, sourceMetadata, updatedAt);

    summary.mergedAnimeIds.push(sourceAnimeId);
    summary.movedVideos += moved.changes;
  }

  return summary;
}

export function mergeAnimeRecords(
  db: DatabaseSync,
  targetAnimeId: number,
  sourceAnimeIds: number[],
): AnimeMergeSummary {
  return runInTransaction(db, () => {
    const summary = mergeAnimeRecordsInTransaction(db, targetAnimeId, sourceAnimeIds);
    if (summary.mergedAnimeIds.length > 0) {
      recomputeLifetimeAnimeAggregatesInTransaction(db);
    }
    return summary;
  });
}

/**
 * Move a single episode to another library entry, pruning the previous owner
 * when it is left with nothing.
 */
export function moveVideoToAnime(
  db: DatabaseSync,
  videoId: number,
  targetAnimeId: number,
): VideoMoveSummary {
  return runInTransaction(db, () => {
    const videoRow = db
      .prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = ?')
      .get(videoId) as { animeId: number | null } | null;
    if (!videoRow || !animeExists(db, targetAnimeId)) {
      throw new Error(UNKNOWN_MOVE_TARGET_MESSAGE);
    }

    const previousAnimeId = videoRow.animeId;
    if (previousAnimeId === targetAnimeId) {
      db.prepare(
        'UPDATE imm_videos SET anime_assignment_locked = 1, LAST_UPDATE_DATE = ? WHERE video_id = ?',
      ).run(toDbTimestamp(nowMs()), videoId);
      return { targetAnimeId, previousAnimeId, removedPreviousAnime: false };
    }

    const updatedAt = toDbTimestamp(nowMs());
    db.prepare(
      `UPDATE imm_videos
       SET anime_id = ?, anime_assignment_locked = 1, LAST_UPDATE_DATE = ?
       WHERE video_id = ?`,
    ).run(targetAnimeId, updatedAt, videoId);
    db.prepare(
      'UPDATE imm_subtitle_lines SET anime_id = ?, LAST_UPDATE_DATE = ? WHERE video_id = ?',
    ).run(targetAnimeId, updatedAt, videoId);

    let removedPreviousAnime = false;
    if (previousAnimeId !== null && !hasAnimeReferences(db, previousAnimeId)) {
      // The emptied entry's metadata is deliberately dropped rather than
      // absorbed. A move says "this episode belongs elsewhere", not "these are
      // the same show", and the entry being emptied is usually a mis-parse
      // whose AniList link would be wrong for the target.
      db.prepare('DELETE FROM imm_lifetime_anime WHERE anime_id = ?').run(previousAnimeId);
      db.prepare('DELETE FROM imm_anime WHERE anime_id = ?').run(previousAnimeId);
      removedPreviousAnime = true;
    }

    recomputeLifetimeAnimeAggregatesInTransaction(db);
    return { targetAnimeId, previousAnimeId, removedPreviousAnime };
  });
}
