import { db } from "@/db/client";
import { sql } from "drizzle-orm";
import Link from "next/link";
import { ManualSearch } from "../manual-search";

export const dynamic = "force-dynamic";

type PageProps = {
  searchParams?: Promise<Record<string, string | string[] | undefined>>;
};

type CalendarRow = {
  kind: "episode" | "movie";
  media_item_id: string;
  title: string;
  season_number: number | null;
  episode_number: number | null;
  episode_title: string | null;
  date: string;
  has_file: boolean;
  grab_status: string | null;
  wanted: boolean;
};

// The agenda view's window. Backwards matters as much as forwards: the useful
// question is rarely "what airs next month", it is "what aired last week that I
// still do not have".
const DAYS_BEHIND = 14;
const DAYS_AHEAD = 28;

// Weeks start on Monday, so a weekend reads as the block it is rather than
// being split across two rows.
const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];

// Beyond this a cell would grow tall enough to break the grid's rhythm, so the
// rest are counted and reachable in the agenda.
const MAX_PER_CELL = 4;

function singleParam(value: string | string[] | undefined) {
  return Array.isArray(value) ? value[0] : value;
}

// ---------------------------------------------------------------------------
// Dates
//
// All of this is done in UTC on 'YYYY-MM-DD' strings. The database hands back
// days, not instants, and dragging them through a local-timezone Date is how
// an episode ends up on the wrong side of midnight.

const DAY_MS = 86_400_000;

function toYmd(value: number) {
  return new Date(value).toISOString().slice(0, 10);
}

function fromYmd(value: string) {
  return Date.parse(value + "T00:00:00Z");
}

/** Monday-based index: Monday 0 … Sunday 6. */
function weekdayIndex(value: number) {
  return (new Date(value).getUTCDay() + 6) % 7;
}

function addDays(value: number, days: number) {
  return value + days * DAY_MS;
}

function monthKey(value: string) {
  return value.slice(0, 7);
}

/** Shift a 'YYYY-MM' key by whole months, without touching day-of-month. */
function shiftMonth(key: string, delta: number) {
  const [year, month] = key.split("-").map(Number);
  const shifted = new Date(Date.UTC(year, month - 1 + delta, 1));
  return shifted.toISOString().slice(0, 7);
}

function parseMonthParam(value: string | undefined, fallback: string) {
  return value && /^\d{4}-(0[1-9]|1[0-2])$/.test(value) ? value : fallback;
}

/**
 * The full grid for a month: whole weeks, so it starts on the Monday on or
 * before the 1st and ends on the Sunday on or after the last day.
 */
function monthGridRange(key: string) {
  const [year, month] = key.split("-").map(Number);
  const first = Date.UTC(year, month - 1, 1);
  const last = Date.UTC(year, month, 0);
  return {
    from: toYmd(addDays(first, -weekdayIndex(first))),
    to: toYmd(addDays(last, 6 - weekdayIndex(last))),
  };
}

// ---------------------------------------------------------------------------
// Data

/**
 * Everything dated in a range, and what the library has of it.
 *
 * Presence, grab state and wantedness are scalar subqueries rather than joins.
 * A title can easily have several live files, several desired_states rows (a
 * series-wide rule plus an episode override) and more than one grab, and any of
 * those as a join would silently duplicate calendar entries.
 */
function calendarQuery(from: string, to: string) {
  return sql`
    with in_window as (
      select
        'episode'::text                        as kind,
        mi.id                                  as media_item_id,
        mi.title,
        s.season_number,
        e.episode_number,
        e.title                                as episode_title,
        -- Formatted in SQL, not in JS. node-postgres hands back a Date object
        -- for a date column, and its string form ("Wed Jul 30 2026 ...") sorts
        -- and compares as nonsense against an ISO day -- which quietly labelled
        -- every past episode as still upcoming.
        to_char(e.air_date, 'YYYY-MM-DD')      as date,
        exists (
          select 1 from storage_files sf
            join storage_tiers t on t.id = sf.tier_id
           where sf.episode_id = e.id and t.tier = 'live' and sf.missing_at is null
        )                                      as has_file,
        (
          select g.status from grabs g
           where g.media_item_id = mi.id
             and coalesce(g.season_number, s.season_number) = s.season_number
             and coalesce(g.episode_number, e.episode_number) = e.episode_number
             and g.status in ('queued','downloading','completed','importing')
           order by g.created_at desc
           limit 1
        )                                      as grab_status,
        exists (
          select 1 from desired_states ds
           where ds.media_item_id = mi.id
             and (ds.season_number is null or ds.season_number = s.season_number)
             and (ds.episode_number is null or ds.episode_number = e.episode_number)
             and ds.wanted
             -- An episode that has not aired is ALWAYS unmonitored: the
             -- classifier sets monitored = false so the fetcher does not chase
             -- a release that cannot exist yet. Requiring monitored here read
             -- that as "we do not care about it" and emptied the calendar of
             -- everything after today -- 123 upcoming episodes, every one of
             -- them wanted. Monitored still decides what shows for episodes
             -- that HAVE aired, where false really does mean stopped caring.
             and (ds.monitored or e.air_date > current_date)
        )                                      as wanted
      from episodes e
      join seasons s on s.id = e.season_id
      join series se on se.id = s.series_id
      join media_items mi on mi.id = se.id
      where e.air_date between ${from}::date and ${to}::date

      union all

      select
        'movie'::text,
        mi.id,
        mi.title,
        null::int,
        null::int,
        null::text,
        to_char(m.release_date, 'YYYY-MM-DD'),
        exists (
          select 1 from storage_files sf
            join storage_tiers t on t.id = sf.tier_id
           where sf.media_item_id = mi.id and t.tier = 'live' and sf.missing_at is null
        ),
        (
          select g.status from grabs g
           where g.media_item_id = mi.id
             and g.status in ('queued','downloading','completed','importing')
           order by g.created_at desc
           limit 1
        ),
        exists (
          select 1 from desired_states ds
           where ds.media_item_id = mi.id and ds.wanted
             -- Same rule for a film that is not out yet.
             and (ds.monitored or m.release_date > current_date)
        )
      from movies m
      join media_items mi on mi.id = m.id
      where m.release_date between ${from}::date and ${to}::date
    )
    select * from in_window
     order by date asc, title asc, season_number asc, episode_number asc
  `;
}

// ---------------------------------------------------------------------------
// Presentation

// "Missing" is only alarming once the thing has actually aired, which is why
// the state is decided against today rather than from presence alone.
function stateOf(row: CalendarRow, today: string) {
  if (row.has_file) return "have" as const;
  if (row.grab_status) return "grabbing" as const;
  if (row.date > today) return "upcoming" as const;
  return "missing" as const;
}

type EntryState = ReturnType<typeof stateOf>;

const STATE_BADGE: Record<EntryState, string> = {
  have: "border-admin-good text-admin-good",
  grabbing: "border-admin-accent text-admin-accent",
  upcoming: "border-admin-line text-admin-muted",
  missing: "border-admin-warn text-admin-warn",
};

// In the grid there is no room for a badge, so state is carried by a colour bar
// down the leading edge of each chip.
const STATE_BAR: Record<EntryState, string> = {
  have: "border-l-2 border-admin-good",
  grabbing: "border-l-2 border-admin-accent",
  upcoming: "border-l-2 border-admin-line",
  missing: "border-l-2 border-admin-warn",
};

const STATE_LABEL: Record<EntryState, string> = {
  have: "On live",
  grabbing: "Downloading",
  upcoming: "Expected",
  missing: "Missing",
};

function entryCode(row: CalendarRow) {
  return row.kind === "episode"
    ? "S" + String(row.season_number ?? 0).padStart(2, "0") +
      "E" + String(row.episode_number ?? 0).padStart(2, "0")
    : "Film";
}

function entryHref(row: CalendarRow) {
  return row.kind === "episode"
    ? "/admin/inventory/series/" + row.media_item_id
    : "/admin/inventory?q=" + encodeURIComponent(row.title);
}

function dayHeading(date: string, today: string) {
  const label = new Date(date + "T00:00:00Z").toLocaleDateString("en-GB", {
    weekday: "long", day: "numeric", month: "long", timeZone: "UTC",
  });
  return date === today ? label + " — today" : label;
}

function monthLabel(key: string) {
  return new Date(key + "-01T00:00:00Z").toLocaleDateString("en-GB", {
    month: "long", year: "numeric", timeZone: "UTC",
  });
}

function hrefFor(params: { view?: string; month?: string; filter?: string }) {
  const search = new URLSearchParams();
  if (params.view && params.view !== "month") search.set("view", params.view);
  if (params.month) search.set("month", params.month);
  if (params.filter === "all") search.set("filter", "all");
  const value = search.toString();
  return value ? "/admin/calendar?" + value : "/admin/calendar";
}

// ---------------------------------------------------------------------------

export default async function AdminCalendarPage({ searchParams }: PageProps) {
  const resolved = (await searchParams) ?? {};
  const monitoredOnly = singleParam(resolved.filter) !== "all";
  const view = singleParam(resolved.view) === "agenda" ? "agenda" : "month";

  // Today comes from the database's clock, not the server's, so the "today"
  // marker agrees with the air dates the same database supplied.
  const { rows: [{ today }] } = await db.execute<{ today: string }>(
    sql`select to_char(current_date, 'YYYY-MM-DD') as today`,
  );

  const month = parseMonthParam(singleParam(resolved.month), monthKey(today));
  const range = view === "month"
    ? monthGridRange(month)
    : {
        from: toYmd(addDays(fromYmd(today), -DAYS_BEHIND)),
        to: toYmd(addDays(fromYmd(today), DAYS_AHEAD)),
      };

  const { rows } = await db.execute<CalendarRow>(calendarQuery(range.from, range.to));

  // "Monitored" keeps anything we want, already hold, or are fetching. A title
  // that is present but no longer monitored still belongs on the calendar --
  // hiding it would make the library look emptier than it is.
  const visible = monitoredOnly
    ? rows.filter((row) => row.wanted || row.has_file || row.grab_status)
    : rows;

  const byDay = new Map<string, CalendarRow[]>();
  for (const row of visible) {
    const list = byDay.get(row.date);
    if (list) list.push(row);
    else byDay.set(row.date, [row]);
  }

  const missingCount = visible.filter((row) => stateOf(row, today) === "missing").length;
  const upcomingCount = visible.filter((row) => stateOf(row, today) === "upcoming").length;

  const filter = monitoredOnly ? "monitored" : "all";

  return (
    <div className="space-y-6">
      <div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
        <div>
          <p className="text-xs font-semibold uppercase tracking-[0.32em] text-admin-accent">Schedule</p>
          <h1 className="mt-2 font-serif text-3xl font-semibold">Release Calendar</h1>
          <p className="mt-2 max-w-3xl text-sm text-admin-muted">
            When episodes and films are due, and what the library actually holds of each.{" "}
            {upcomingCount} still to come,{" "}
            <span className={missingCount ? "text-admin-warn" : undefined}>
              {missingCount} aired and missing
            </span>
            {view === "month" ? " this month" : " in the next " + DAYS_AHEAD + " days"}.
          </p>
        </div>
        <Link href="/admin/downloads" className="admin-nav-button px-3 py-2 text-sm font-medium">
          Downloads
        </Link>
      </div>

      <div className="flex flex-wrap items-center gap-x-6 gap-y-3">
        <div className="flex flex-wrap items-center gap-2">
          <span className="text-xs uppercase tracking-[0.18em] text-admin-muted">View</span>
          {[
            { value: "month", label: "Month", blurb: "a calendar grid, one cell per day" },
            { value: "agenda", label: "Agenda", blurb: "a dated list with search buttons" },
          ].map((option) => (
            <Link
              key={option.value}
              prefetch={false}
              href={hrefFor({ view: option.value, month: option.value === "month" ? month : undefined, filter })}
              aria-current={option.value === view ? "true" : undefined}
              title={"Show " + option.blurb}
              className={
                "admin-nav-button px-3 py-1.5 text-xs font-medium " +
                (option.value === view ? "" : "text-admin-muted")
              }
            >
              {option.label}
            </Link>
          ))}
        </div>

        <div className="flex flex-wrap items-center gap-2">
          <span className="text-xs uppercase tracking-[0.18em] text-admin-muted">Show</span>
          {[
            { value: "monitored", label: "Monitored", blurb: "only what is wanted, held, or downloading" },
            { value: "all", label: "Everything dated", blurb: "every episode and film with a date" },
          ].map((option) => (
            <Link
              key={option.value}
              prefetch={false}
              href={hrefFor({ view, month: view === "month" ? month : undefined, filter: option.value })}
              aria-current={option.value === filter ? "true" : undefined}
              title={"Show " + option.blurb}
              className={
                "admin-nav-button px-3 py-1.5 text-xs font-medium " +
                (option.value === filter ? "" : "text-admin-muted")
              }
            >
              {option.label}
            </Link>
          ))}
        </div>

        {/* The legend is what makes the grid readable at all: in a cell there is
            no room to spell out a state, so the colour bar has to be decodable. */}
        <div className="flex flex-wrap items-center gap-3 text-xs text-admin-muted">
          {(Object.keys(STATE_LABEL) as EntryState[]).map((state) => (
            <span key={state} className="flex items-center gap-1.5">
              <span className={"inline-block h-3 w-0 " + STATE_BAR[state]} aria-hidden />
              {STATE_LABEL[state]}
            </span>
          ))}
        </div>
      </div>

      {view === "month" ? (
        <MonthGrid month={month} today={today} byDay={byDay} filter={filter} />
      ) : (
        <Agenda byDay={byDay} today={today} />
      )}
    </div>
  );
}

// ---------------------------------------------------------------------------

function MonthGrid({
  month,
  today,
  byDay,
  filter,
}: {
  month: string;
  today: string;
  byDay: Map<string, CalendarRow[]>;
  filter: string;
}) {
  const { from, to } = monthGridRange(month);
  const start = fromYmd(from);
  const cellCount = Math.round((fromYmd(to) - start) / DAY_MS) + 1;
  const days = Array.from({ length: cellCount }, (_, index) => toYmd(addDays(start, index)));

  return (
    <section className="admin-panel overflow-hidden">
      <div className="flex flex-wrap items-center justify-between gap-2 border-b border-admin-line px-5 py-3">
        <h2 className="font-serif text-xl font-semibold">{monthLabel(month)}</h2>
        <div className="flex items-center gap-2 text-xs">
          <Link prefetch={false} href={hrefFor({ month: shiftMonth(month, -1), filter })} className="admin-nav-button px-2.5 py-1 font-medium">
            ← Previous
          </Link>
          <Link prefetch={false} href={hrefFor({ month: monthKey(today), filter })} className="admin-nav-button px-2.5 py-1 font-medium">
            Today
          </Link>
          <Link prefetch={false} href={hrefFor({ month: shiftMonth(month, 1), filter })} className="admin-nav-button px-2.5 py-1 font-medium">
            Next →
          </Link>
        </div>
      </div>

      {/* Scrolls rather than squeezing: seven readable columns matter more than
          fitting a narrow window, and the page body must not scroll sideways. */}
      <div className="overflow-x-auto">
        <div className="min-w-[56rem]">
          <div className="grid grid-cols-7 border-b border-admin-line bg-admin-subpanel">
            {WEEKDAYS.map((day) => (
              <div key={day} className="px-2 py-2 text-center text-[10px] font-semibold uppercase tracking-[0.18em] text-admin-muted">
                {day}
              </div>
            ))}
          </div>

          <div className="grid grid-cols-7">
            {days.map((date, index) => {
              const entries = byDay.get(date) ?? [];
              const inMonth = monthKey(date) === month;
              const isToday = date === today;
              return (
                <div
                  key={date}
                  className={
                    "min-h-28 border-b border-r border-admin-line p-1.5 " +
                    // The last column has the panel edge; the last row has the
                    // panel bottom. Both would otherwise double up.
                    (index % 7 === 6 ? "border-r-0 " : "") +
                    (inMonth ? "" : "opacity-45 ") +
                    (isToday ? "bg-admin-missing" : "")
                  }
                >
                  <div className="mb-1 flex items-baseline justify-between">
                    <span className={"text-xs " + (isToday ? "font-bold text-admin-accent" : "text-admin-muted")}>
                      {Number(date.slice(8, 10))}
                    </span>
                    {entries.length > MAX_PER_CELL ? (
                      <span className="text-[10px] text-admin-muted">{entries.length}</span>
                    ) : null}
                  </div>

                  <div className="space-y-1">
                    {entries.slice(0, MAX_PER_CELL).map((row) => {
                      const state = stateOf(row, today);
                      return (
                        <Link
                          key={row.kind + row.media_item_id + entryCode(row)}
                          href={entryHref(row)}
                          title={row.title + " " + entryCode(row) + " — " + STATE_LABEL[state] +
                                 (row.episode_title ? "\n" + row.episode_title : "")}
                          className={
                            "block rounded-sm bg-admin-subpanel px-1.5 py-1 leading-tight hover:bg-[#20363a] " +
                            STATE_BAR[state]
                          }
                        >
                          <span className="block truncate text-[11px] text-admin-text">{row.title}</span>
                          <span className="block truncate text-[10px] text-admin-muted">{entryCode(row)}</span>
                        </Link>
                      );
                    })}

                    {entries.length > MAX_PER_CELL ? (
                      <Link
                        href={hrefFor({ view: "agenda", filter })}
                        className="block px-1.5 text-[10px] text-admin-accent hover:underline"
                      >
                        +{entries.length - MAX_PER_CELL} more
                      </Link>
                    ) : null}
                  </div>
                </div>
              );
            })}
          </div>
        </div>
      </div>
    </section>
  );
}

// ---------------------------------------------------------------------------

/**
 * The dated list. This is the view that can act: a grid cell has no room for a
 * search panel, so grabbing something lives here.
 */
function Agenda({ byDay, today }: { byDay: Map<string, CalendarRow[]>; today: string }) {
  const days = [...byDay.entries()];

  if (!days.length) {
    return (
      <section className="admin-panel">
        <p className="p-5 text-sm text-admin-muted">
          Nothing is dated in this window. Try “Everything dated” — titles that are not monitored are hidden.
        </p>
      </section>
    );
  }

  return (
    <section className="admin-panel overflow-hidden">
      <div className="divide-y divide-admin-line">
        {days.map(([date, entries]) => (
          <div key={date} className={date === today ? "bg-admin-subpanel" : ""}>
            <div className="flex items-baseline justify-between border-b border-admin-line px-5 py-2">
              <h2 className={"font-serif text-lg font-semibold " + (date === today ? "text-admin-accent" : "")}>
                {dayHeading(date, today)}
              </h2>
              <span className="text-xs text-admin-muted">{entries.length} scheduled</span>
            </div>

            <div className="divide-y divide-admin-line">
              {entries.map((row) => {
                const state = stateOf(row, today);
                const code = entryCode(row);
                return (
                  <div
                    key={row.kind + row.media_item_id + code}
                    className="flex flex-wrap items-center gap-x-3 gap-y-2 px-5 py-2.5 text-sm"
                  >
                    <span className="w-14 shrink-0 font-mono text-xs text-admin-muted">{code}</span>

                    <Link href={entryHref(row)} className="font-medium text-admin-text hover:text-admin-accent">
                      {row.title}
                    </Link>

                    {row.episode_title ? (
                      <span className="min-w-0 truncate text-admin-muted">{row.episode_title}</span>
                    ) : null}

                    <span className={"ml-auto rounded border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide " + STATE_BADGE[state]}>
                      {STATE_LABEL[state]}
                    </span>

                    {/* Searching for something unaired would ask indexers for a
                        release that does not exist, so the button only appears
                        once there is something to find. */}
                    {state === "missing" ? (
                      <div className="w-full">
                        <ManualSearch
                          mediaItemId={row.media_item_id}
                          seasonNumber={row.season_number}
                          episodeNumber={row.episode_number}
                          label={row.title + " " + code}
                          compact
                        />
                      </div>
                    ) : null}
                  </div>
                );
              })}
            </div>
          </div>
        ))}
      </div>
    </section>
  );
}
