import {
  pgTable,
  pgEnum,
  uuid,
  text,
  integer,
  bigint,
  boolean,
  timestamp,
  jsonb,
  index,
  uniqueIndex,
} from "drizzle-orm/pg-core";
import { mediaItems } from "./media";
import { storageFiles } from "./storage";
import { users } from "./users";
import { qualityEnum } from "./policy";

// The life of a grab, from "we asked qBittorrent" to "the file is in the
// library". `imported` is terminal and happy; `failed` and `orphaned` are
// terminal and not.
//
// `orphaned` specifically means: we recorded this grab, and qBittorrent no
// longer knows about the torrent. That is a real state -- someone deleted it in
// the WebUI -- and it must not be confused with "still downloading".
export const grabStatusEnum = pgEnum("grab_status", [
  "queued",
  "downloading",
  "completed",
  "importing",
  "imported",
  "failed",
  "orphaned",
]);

export const grabSourceEnum = pgEnum("grab_source", ["auto", "manual"]);

/**
 * Every torrent Ampelos has handed to qBittorrent.
 *
 * This table is the safety mechanism, not bookkeeping. `/Niflheim/Downloads` is
 * shared storage that the old Sonarr/Radarr/transmission stack on .96 still
 * writes to and seeds from. An organizer that scanned that directory and moved
 * what it found would eventually move a file the old stack was mid-import on,
 * or delete something still seeding.
 *
 * So the organizer never scans. It reads this table, asks qBittorrent about
 * those specific infohashes, and touches nothing else. A file with no row here
 * is, by definition, not ours.
 */
export const grabs = pgTable(
  "grabs",
  {
    id: uuid("id").primaryKey().defaultRandom(),

    // What this grab is FOR. Null season/episode means the whole item, which is
    // how movies and season packs are recorded.
    mediaItemId: uuid("media_item_id").notNull().references(() => mediaItems.id, { onDelete: "cascade" }),
    seasonNumber: integer("season_number"),
    episodeNumber: integer("episode_number"),

    // The torrent itself. infoHash is the join key to qBittorrent and the
    // reason this table can be authoritative about what is ours.
    infoHash: text("info_hash").notNull(),
    releaseTitle: text("release_title").notNull(),
    sizeBytes: bigint("size_bytes", { mode: "bigint" }),
    magnetUrl: text("magnet_url"),
    downloadUrl: text("download_url"),

    // Where it came from, for health attribution and for explaining a choice
    // back to the user. `origin` is the tracker behind an aggregator: Knaben
    // returning a RuTracker release is Knaben/RuTracker.
    indexerName: text("indexer_name").notNull(),
    origin: text("origin"),
    seeders: integer("seeders"),

    // Why this release and not another. Populated by the decision engine, and
    // shown in the UI so an automatic choice is never a black box.
    quality: qualityEnum("quality"),
    score: integer("score"),
    scoreReasons: jsonb("score_reasons").$type<string[]>().default([]),

    source: grabSourceEnum("source").notNull(),
    requestedBy: uuid("requested_by").references(() => users.id),

    status: grabStatusEnum("status").notNull().default("queued"),
    statusDetail: text("status_detail"),
    progress: integer("progress").default(0),
    // When progress last actually MOVED, not when the row was last touched.
    // A torrent that stalls at 40% still gets polled every five minutes, so
    // updated_at keeps advancing and cannot distinguish "downloading slowly"
    // from "dead since March". This column is only written when the percentage
    // changes, which makes stall age a straightforward subtraction.
    progressChangedAt: timestamp("progress_changed_at"),

    // Absolute path qBittorrent reported once the download finished, and where
    // the organizer put it. Both recorded because a failed import needs to say
    // what it was holding and where it tried to go.
    contentPath: text("content_path"),
    importedPath: text("imported_path"),

    // Set when the organizer has hard-linked the file into the library. Kept
    // separate from status so a re-import can be forced.
    importedAt: timestamp("imported_at"),

    // THE SEED, AND WHY IT MUST BE RELEASED.
    //
    // Import is a hardlink, so one inode carries two names: this path under
    // /Niflheim/Downloads, and the library path. qBittorrent seeds from its
    // name at no extra disk cost, which is the whole point.
    //
    // But it means archiving does not free anything on its own. Moving a file
    // live -> archive copies it to edda and deletes the LIBRARY name; the
    // download name still holds the inode, so the bytes stay on /Niflheim
    // forever and the archive tier has bought nothing. Space comes back only
    // when the last link goes.
    //
    // So the seed is tracked here and released deliberately, and `seedPath` is
    // recorded rather than recomputed: qBittorrent is the authority on where it
    // put things, and a path we derived could be wrong in exactly the case
    // where being wrong deletes the only copy.
    seedPath: text("seed_path"),
    seedReleasedAt: timestamp("seed_released_at"),
    seedReleaseReason: text("seed_release_reason"),

    createdAt: timestamp("created_at").notNull().defaultNow(),
    updatedAt: timestamp("updated_at").notNull().defaultNow(),
  },
  (t) => [
    // One grab per torrent. Re-grabbing the same release is an update, not a
    // second row, or the organizer would import it twice.
    uniqueIndex("grabs_info_hash_idx").on(t.infoHash),
    // The organizer's query: everything not yet finished.
    index("grabs_status_idx").on(t.status),
    index("grabs_media_item_idx").on(t.mediaItemId, t.seasonNumber, t.episodeNumber),
  ],
);

/**
 * Which library files a grab produced.
 *
 * One grab is not one file. A season pack is a single torrent that hardlinks
 * into ten episodes, and each of those is an independent thing that can be
 * promoted, demoted or deleted on its own.
 *
 * This table is what makes seed release correct rather than approximate. The
 * rule it enables: a grab's seed may be released once NONE of its files are on
 * the live tier any more. Releasing when the first episode is archived would
 * stop seeding a torrent whose other nine episodes are still live and still
 * costing nothing to seed.
 */
export const grabFiles = pgTable(
  "grab_files",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    grabId: uuid("grab_id").notNull().references(() => grabs.id, { onDelete: "cascade" }),
    storageFileId: uuid("storage_file_id").references(() => storageFiles.id, { onDelete: "set null" }),
    // Kept alongside the FK because the FK is nulled when a file is removed
    // from the library, and a released seed still needs to say what it held.
    importedPath: text("imported_path").notNull(),
    seasonNumber: integer("season_number"),
    episodeNumber: integer("episode_number"),
    createdAt: timestamp("created_at").notNull().defaultNow(),
  },
  (t) => [
    uniqueIndex("grab_files_grab_path_idx").on(t.grabId, t.importedPath),
    index("grab_files_storage_file_idx").on(t.storageFileId),
  ],
);

/**
 * A record of each search, so a failed acquisition can be explained.
 *
 * "Why is this episode still missing?" is otherwise unanswerable: the fan-out
 * happened in a container, minutes ago, and left no trace. Storing the counts
 * plus which indexers failed turns that into a question the UI can answer.
 */
export const searchRuns = pgTable(
  "search_runs",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    mediaItemId: uuid("media_item_id").references(() => mediaItems.id, { onDelete: "cascade" }),
    seasonNumber: integer("season_number"),
    episodeNumber: integer("episode_number"),

    source: grabSourceEnum("source").notNull(),
    query: text("query"),

    indexersQueried: integer("indexers_queried").notNull().default(0),
    resultsFound: integer("results_found").notNull().default(0),
    // Releases that survived the quality and size rules. A large gap between
    // this and resultsFound means the rules are the problem, not the indexers.
    resultsAccepted: integer("results_accepted").notNull().default(0),

    // [{ indexer, reason }] for skips, [{ indexer, error }] for failures. The
    // difference between "nothing exists" and "nobody answered".
    skipped: jsonb("skipped").$type<Array<{ indexer: string; reason: string }>>().default([]),
    errors: jsonb("errors").$type<Array<{ indexer: string; error: string }>>().default([]),

    grabId: uuid("grab_id").references(() => grabs.id, { onDelete: "set null" }),
    decision: text("decision"),

    startedAt: timestamp("started_at").notNull().defaultNow(),
    durationMs: integer("duration_ms"),
  },
  (t) => [index("search_runs_media_item_idx").on(t.mediaItemId, t.startedAt)],
);

/**
 * Releases a user or the engine has rejected, so they are never offered again.
 *
 * Without this, an auto search that picks a broken release will pick the same
 * broken release on every subsequent run, forever.
 */
export const releaseBlocklist = pgTable(
  "release_blocklist",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    // Nullable, because a block is not always about one torrent. A release
    // GROUP that turns out to be consistently bad -- desynced audio, wrong
    // aspect, mislabelled quality -- needs banning wholesale, and that is only
    // possible because the organizer keeps the group legible in the filename.
    // Exactly one of infoHash and releaseGroup is meaningful per row.
    infoHash: text("info_hash"),
    releaseGroup: text("release_group"),
    releaseTitle: text("release_title"),
    mediaItemId: uuid("media_item_id").references(() => mediaItems.id, { onDelete: "cascade" }),
    reason: text("reason"),
    permanent: boolean("permanent").notNull().default(false),
    createdBy: uuid("created_by").references(() => users.id),
    createdAt: timestamp("created_at").notNull().defaultNow(),
  },
  (t) => [uniqueIndex("release_blocklist_info_hash_idx").on(t.infoHash)],
);
