// Everything Ampelos knows about one person, in one shape.
//
// This exists because the same profile is rendered three ways -- the page at
// /profile, the chrome-less fragment charon iframes at /embed/profile, and the
// JSON at /api/profile -- and three readers of the same tables would drift.
// They have drifted before: the catalog board and the placement classifier each
// decided for themselves what "purged" meant and disagreed about which titles
// were.
//
// PROFILE_SCHEMA_VERSION is part of the contract with accounts.sticknife.com.
// Charon renders a panel it does not own from a service it cannot redeploy in
// step, so it needs to be able to say "I understand version 1" and degrade
// rather than break. Bump it when a field's MEANING changes; adding a field
// does not need a bump, because a consumer ignoring an unknown key is fine.

import { and, desc, eq, isNull, sql } from "drizzle-orm";

import { db } from "@/db/client";
import {
  episodes,
  externalIds,
  mediaItems,
  plexAccounts,
  seasons,
  users,
  watchHistory,
  watchingNowItems,
} from "@/db/schema";
import { posterUrlFromStored } from "@/lib/catalog";

// 2: history.recent is one row per EPISODE rather than per play, and its
//    watchedAt is the most recent play of that episode. A consumer written
//    against 1 would double-count. Adding playCount, distinctEpisodes and the
//    catalog refs alone would not have earned a bump; changing what a row
//    MEANS does.
export const PROFILE_SCHEMA_VERSION = 2;

/** How many plays the panel shows. The full history is not a profile panel's job. */
const RECENT_HISTORY_LIMIT = 25;

export type ProfileMediaType = "tv_series" | "movie";

/**
 * What the catalog needs to open its detail panel for a title.
 *
 * The board deals in TMDB ids, not media_items ids, so a profile entry cannot
 * be opened without one. Null when the title has no TMDB link, or has one a
 * reviewer has REJECTED -- a rejected id is a known-wrong id kept as evidence,
 * and following it would open somebody else's film.
 */
export type CatalogRef = {
  tmdbId: string;
  kind: "television" | "movies";
} | null;

export type WatchNowEntry = {
  id: string;
  mediaItemId: string;
  title: string;
  year: number | null;
  posterUrl: string | null;
  slotNumber: number | null;
  addedAt: string;
  catalog: CatalogRef;
};

export type WatchNowList = {
  quota: number;
  used: number;
  items: WatchNowEntry[];
};

export type HistoryEntry = {
  id: string;
  mediaItemId: string;
  mediaType: ProfileMediaType;
  title: string;
  year: number | null;
  posterUrl: string | null;
  /** Null for a movie. */
  seasonNumber: number | null;
  episodeNumber: number | null;
  /** The episode's own title, when this database has a row for it. */
  episodeTitle: string | null;
  /** The MOST RECENT time this episode was played; see getWatchHistory. */
  watchedAt: string;
  /** How many times in total, so collapsing repeats does not hide them. */
  playCount: number;
  source: "plex" | "manual";
  catalog: CatalogRef;
};

export type PlexLink =
  | { linked: false }
  | {
      linked: true;
      username: string;
      email: string | null;
      linkedAt: string;
      /** This account owns the Plex server, so it was never invited to anything. */
      isServerOwner: boolean;
      librariesSharedAt: string | null;
      sharedLibraryCount: number;
    };

export type Profile = {
  service: "ampelos";
  schemaVersion: number;
  generatedAt: string;
  user: {
    id: string;
    displayName: string;
    email: string;
  };
  plex: PlexLink;
  watchNow: {
    television: WatchNowList;
    movies: WatchNowList;
  };
  history: {
    /** Every play on record, counting repeats. */
    total: number;
    /** Distinct episodes and films -- what `recent` is a window onto. */
    distinctEpisodes: number;
    playsLast30Days: number;
    lastWatchedAt: string | null;
    recent: HistoryEntry[];
  };
  /** Absolute, so charon can link back into Ampelos without knowing its address. */
  links: {
    self: string;
    embed: string;
    api: string;
  };
};

function iso(value: Date | string | null | undefined) {
  if (!value) return null;
  return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
}

async function getPlexLink(userId: string): Promise<PlexLink> {
  const [row] = await db
    .select()
    .from(plexAccounts)
    .where(eq(plexAccounts.userId, userId))
    .limit(1);

  if (!row) return { linked: false };

  return {
    linked: true,
    username: row.plexUsername,
    email: row.plexEmail,
    linkedAt: iso(row.linkedAt)!,
    isServerOwner: row.isServerOwner,
    librariesSharedAt: iso(row.librariesSharedAt),
    sharedLibraryCount: row.sharedSectionIds?.length ?? 0,
  };
}

/**
 * The TMDB link to join through, when there is a trustworthy one.
 *
 * `rejected_at is null` is the load-bearing half: external_ids deliberately
 * KEEPS an id a reviewer has rejected, as the evidence of what went wrong. It
 * is a known-wrong id, so a link built on it would open the wrong title.
 */
const tmdbJoin = and(
  eq(externalIds.source, "tmdb"),
  isNull(externalIds.rejectedAt),
);

function catalogRef(tmdbId: string | null, mediaType: ProfileMediaType): CatalogRef {
  if (!tmdbId) return null;
  return { tmdbId, kind: mediaType === "movie" ? "movies" : "television" };
}

async function getWatchNow(userId: string, mediaType: ProfileMediaType, quota: number): Promise<WatchNowList> {
  const rows = await db
    .select({
      id: watchingNowItems.id,
      mediaItemId: mediaItems.id,
      title: mediaItems.title,
      year: mediaItems.year,
      posterPath: mediaItems.posterPath,
      slotNumber: watchingNowItems.slotNumber,
      addedAt: watchingNowItems.addedAt,
      tmdbId: externalIds.externalId,
    })
    .from(watchingNowItems)
    .innerJoin(mediaItems, eq(mediaItems.id, watchingNowItems.mediaItemId))
    .leftJoin(externalIds, and(eq(externalIds.mediaItemId, mediaItems.id), tmdbJoin))
    .where(
      and(
        eq(watchingNowItems.userId, userId),
        isNull(watchingNowItems.removedAt),
        eq(mediaItems.mediaType, mediaType),
      ),
    )
    // Slot order, with the unslotted last rather than first: a null sorts high
    // in Postgres by default and would put the least-placed item at the top.
    .orderBy(sql`${watchingNowItems.slotNumber} asc nulls last`);

  return {
    quota,
    used: rows.length,
    items: rows.map((row) => ({
      id: row.id,
      mediaItemId: row.mediaItemId,
      title: row.title,
      year: row.year,
      posterUrl: posterUrlFromStored(row.posterPath),
      slotNumber: row.slotNumber,
      addedAt: iso(row.addedAt)!,
      catalog: catalogRef(row.tmdbId, mediaType),
    })),
  };
}

/**
 * This person's plays, and only ever this person's.
 *
 * The userId filter is the whole privacy guarantee for the feature. Plex hands
 * the owner's token everybody's history, so the restraint has to live here --
 * there is no separate credential that would have failed if this were widened
 * by accident. Nothing in this file takes an "all users" mode, deliberately:
 * an admin view of who watched what would be a surveillance surface over the
 * people the server is shared with, and it is not one anybody asked for.
 */
async function getWatchHistory(userId: string) {
  // ONE ROW PER EPISODE, NOT PER PLAY.
  //
  // A rewatch, a resume after stopping, and a client that reports the same
  // episode twice all land as separate plays -- so the raw table shows the same
  // SVU episode filling the panel and buries everything else. Collapsing to the
  // most recent play is what makes this readable as "what have I been
  // watching".
  //
  // `distinct on` rather than a group-by because it keeps the whole winning row
  // (the id, the poster, the exact timestamp) without listing every column
  // twice. Its ORDER BY must lead with the distinct expressions, which is why
  // the ordering people actually see is applied in the outer query below.
  //
  // NULLs are treated as equal here, unlike in a unique index -- which is what
  // makes a movie (null season, null episode) collapse to one row correctly.
  const deduped = db
    .selectDistinctOn([watchHistory.mediaItemId, watchHistory.seasonNumber, watchHistory.episodeNumber], {
      id: watchHistory.id,
      mediaItemId: watchHistory.mediaItemId,
      seasonNumber: watchHistory.seasonNumber,
      episodeNumber: watchHistory.episodeNumber,
      watchedAt: watchHistory.watchedAt,
      source: watchHistory.source,
      // Counted before the dedup throws the repeats away, so "watched 3 times"
      // survives showing only one of them.
      playCount: sql<number>`count(*) over (
        partition by ${watchHistory.mediaItemId}, ${watchHistory.seasonNumber}, ${watchHistory.episodeNumber}
      )::int`.as("play_count"),
    })
    .from(watchHistory)
    .where(eq(watchHistory.userId, userId))
    .orderBy(
      watchHistory.mediaItemId,
      watchHistory.seasonNumber,
      watchHistory.episodeNumber,
      desc(watchHistory.watchedAt),
    )
    .as("deduped");

  // Episode titles come from a left join through seasons, because a play can
  // name a season and episode this database has no row for -- see the schema
  // comment on watch_history.season_number.
  const recent = await db
    .select({
      id: deduped.id,
      mediaItemId: mediaItems.id,
      mediaType: mediaItems.mediaType,
      title: mediaItems.title,
      year: mediaItems.year,
      posterPath: mediaItems.posterPath,
      seasonNumber: deduped.seasonNumber,
      episodeNumber: deduped.episodeNumber,
      episodeTitle: episodes.title,
      watchedAt: deduped.watchedAt,
      playCount: deduped.playCount,
      source: deduped.source,
      tmdbId: externalIds.externalId,
    })
    .from(deduped)
    .innerJoin(mediaItems, eq(mediaItems.id, deduped.mediaItemId))
    .leftJoin(externalIds, and(eq(externalIds.mediaItemId, mediaItems.id), tmdbJoin))
    .leftJoin(
      seasons,
      and(
        eq(seasons.seriesId, deduped.mediaItemId),
        eq(seasons.seasonNumber, deduped.seasonNumber),
      ),
    )
    .leftJoin(
      episodes,
      and(
        eq(episodes.seasonId, seasons.id),
        eq(episodes.episodeNumber, deduped.episodeNumber),
      ),
    )
    .orderBy(desc(deduped.watchedAt))
    .limit(RECENT_HISTORY_LIMIT);

  // watched_at is `timestamp without time zone` holding UTC, which is the
  // house-wide fragility recorded in PLANNING.md. Comparing it against now()
  // directly would be wrong by the server's Europe/Stockholm offset, so the
  // cutoff is computed in UTC on both sides.
  const [totals] = await db
    .select({
      total: sql<number>`count(*)::int`,
      // The number the deduped list is a window onto. Counting plays there
      // instead would promise more rows than the list can ever show.
      distinctEpisodes: sql<number>`count(distinct (
        ${watchHistory.mediaItemId}, ${watchHistory.seasonNumber}, ${watchHistory.episodeNumber}
      ))::int`,
      last30: sql<number>`count(*) filter (
        where ${watchHistory.watchedAt} >= (now() at time zone 'utc') - interval '30 days'
      )::int`,
      lastWatchedAt: sql<Date | null>`max(${watchHistory.watchedAt})`,
    })
    .from(watchHistory)
    .where(eq(watchHistory.userId, userId));

  return {
    total: totals?.total ?? 0,
    distinctEpisodes: totals?.distinctEpisodes ?? 0,
    playsLast30Days: totals?.last30 ?? 0,
    lastWatchedAt: iso(totals?.lastWatchedAt),
    recent: recent.map((row) => ({
      id: row.id,
      mediaItemId: row.mediaItemId,
      mediaType: row.mediaType,
      title: row.title,
      year: row.year,
      posterUrl: posterUrlFromStored(row.posterPath),
      seasonNumber: row.seasonNumber,
      episodeNumber: row.episodeNumber,
      episodeTitle: row.episodeTitle,
      watchedAt: iso(row.watchedAt)!,
      playCount: row.playCount,
      source: row.source,
      catalog: catalogRef(row.tmdbId, row.mediaType),
    })),
  };
}

/**
 * Assemble the profile.
 *
 * Every read is scoped to `userId` and there is no parameter that widens that.
 * Callers are responsible for proving who the user is; see the route handlers.
 */
export async function getProfile(userId: string, baseUrl: string): Promise<Profile | null> {
  const [user] = await db
    .select({
      id: users.id,
      displayName: users.displayName,
      email: users.email,
      tvSlots: users.watchingNowTvSlots,
      movieSlots: users.watchingNowMovieSlots,
    })
    .from(users)
    .where(eq(users.id, userId))
    .limit(1);

  if (!user) return null;

  const [plex, television, movies, history] = await Promise.all([
    getPlexLink(user.id),
    getWatchNow(user.id, "tv_series", user.tvSlots),
    getWatchNow(user.id, "movie", user.movieSlots),
    getWatchHistory(user.id),
  ]);

  const base = baseUrl.replace(/\/$/, "");

  return {
    service: "ampelos",
    schemaVersion: PROFILE_SCHEMA_VERSION,
    generatedAt: new Date().toISOString(),
    user: { id: user.id, displayName: user.displayName, email: user.email },
    plex,
    watchNow: { television, movies },
    history,
    links: {
      self: `${base}/profile`,
      embed: `${base}/embed/profile`,
      api: `${base}/api/profile`,
    },
  };
}
