import "server-only";

import { registeredDomain } from "@/lib/domains/name";
import { assessDomain, compareDomains, needsAttention, type DomainAssessment } from "@/lib/domains/status";
import type { DomainInput } from "@/lib/validation/domain";
import type { Domain } from "@/generated/prisma/client";
import { forEachLimited } from "../concurrency";
import { db } from "../db";
import { logger } from "../logger";
import { lookupDomain, type LookupOutcome } from "./rdap-client";

// Registries rate-limit; a handful in flight is plenty for a daily run.
export const DOMAIN_LOOKUP_CONCURRENCY = 3;

export type DomainApp = { id: string; projectCode: string; projectName: string };

/**
 * Which registered domain every monitored app is on. Same set of apps as the
 * health checks: active and with monitoring enabled.
 */
async function appsByDomain(): Promise<{ byDomain: Map<string, DomainApp[]>; withoutDomain: DomainApp[] }> {
  const projects = await db.project.findMany({
    where: { deletedAt: null, monitoringEnabled: true },
    select: { id: true, projectCode: true, projectName: true, productionUrl: true },
    orderBy: { projectCode: "asc" },
  });

  const byDomain = new Map<string, DomainApp[]>();
  const withoutDomain: DomainApp[] = [];
  for (const { productionUrl, ...app } of projects) {
    const domain = registeredDomain(productionUrl);
    if (!domain) withoutDomain.push(app);
    else byDomain.set(domain, [...(byDomain.get(domain) ?? []), app]);
  }
  return { byDomain, withoutDomain };
}

/**
 * The domains being watched right now — used by a monitored app or added by
 * hand, and not hidden — each with a row in `domains`.
 */
export async function domainsInUse(): Promise<string[]> {
  const appDomains = [...(await appsByDomain()).byDomain.keys()];
  if (appDomains.length > 0) await db.domain.createMany({ data: appDomains.map((name) => ({ name })), skipDuplicates: true });
  const watched = await db.domain.findMany({
    where: { hiddenAt: null, OR: [{ name: { in: appDomains } }, { addedManually: true }] },
    select: { name: true },
    orderBy: { name: "asc" },
  });
  return watched.map(({ name }) => name);
}

function saveOutcome(name: string, outcome: LookupOutcome) {
  const checked = { rdapServer: outcome.server, lastLookupResult: outcome.result, lastCheckedAt: new Date() };
  switch (outcome.result) {
    case "FOUND":
      return db.domain.update({
        where: { name },
        data: {
          ...checked,
          lastLookupError: null,
          registeredAt: outcome.record.registeredAt,
          expiresAt: outcome.record.expiresAt,
          lastChangedAt: outcome.record.lastChangedAt,
          registryUpdatedAt: outcome.record.registryUpdatedAt,
          registryStatuses: outcome.record.statuses,
        },
      });
    case "NOT_FOUND":
      return db.domain.update({
        where: { name },
        data: { ...checked, lastLookupError: "The registry has no record of this domain" },
      });
    // Keep the last good dates: one bad lookup must not blank a domain out.
    case "UNSUPPORTED":
    case "FAILED":
      return db.domain.update({ where: { name }, data: { ...checked, lastLookupError: outcome.error.slice(0, 500) } });
  }
}

/** Look one domain up and store the outcome. Returns the outcome; throws only if storing fails. */
export async function checkDomain(name: string): Promise<LookupOutcome["result"]> {
  const outcome = await lookupDomain(name);
  const before = await db.domain.findUnique({ where: { name }, select: { expiresAt: true } });
  await saveOutcome(name, outcome);

  const fields = { domain: name, result: outcome.result, server: outcome.server };
  if (outcome.result === "FOUND") {
    const renewed = before?.expiresAt && outcome.record.expiresAt && outcome.record.expiresAt > before.expiresAt;
    logger.info(renewed ? "domain.lookup.renewed" : "domain.lookup.found", {
      ...fields,
      expiresAt: outcome.record.expiresAt,
      ...(renewed ? { previousExpiresAt: before.expiresAt } : {}),
    });
  } else if (outcome.result === "NOT_FOUND") logger.warn("domain.lookup.not_registered", fields);
  else logger.warn("domain.lookup.failed", { ...fields, error: outcome.error });

  return outcome.result;
}

/**
 * After apps are created, re-targeted, re-enabled or imported: look up any
 * domain in use that has never been checked, so it doesn't wait for tomorrow's run.
 */
export async function checkNewDomainsQuietly(): Promise<void> {
  try {
    const names = await domainsInUse();
    const unchecked = await db.domain.findMany({
      where: { name: { in: names }, lastCheckedAt: null },
      select: { name: true },
    });
    await forEachLimited(unchecked, DOMAIN_LOOKUP_CONCURRENCY, async ({ name }) => {
      await checkDomain(name).catch((error) => logger.error("domain.lookup.errored", { domain: name, error }));
    });
  } catch (error) {
    logger.error("domain.lookup.new_failed", { error });
  }
}

// ---------------------------------------------------------------------------
// Reads for the pages
// ---------------------------------------------------------------------------

export type DomainOverviewRow = DomainAssessment & {
  name: string;
  domain: Domain | null; // null until the first lookup creates the row
  apps: DomainApp[];
};

/**
 * Every watched domain, most urgent first; the hidden ones (Restore-able); and
 * the apps that have no domain to watch.
 */
export async function getDomainOverview(now: Date = new Date()) {
  const { byDomain, withoutDomain } = await appsByDomain();
  // Every listed domain gets a row up front, so Edit / Delete work before its first lookup.
  const appDomains = [...byDomain.keys()];
  if (appDomains.length > 0) await db.domain.createMany({ data: appDomains.map((name) => ({ name })), skipDuplicates: true });
  const stored = await db.domain.findMany({ where: { OR: [{ name: { in: appDomains } }, { addedManually: true }] } });
  const byName = new Map(stored.map((domain) => [domain.name, domain]));
  const names = new Set([...byDomain.keys(), ...stored.map((domain) => domain.name)]);

  const all: DomainOverviewRow[] = [...names].map((name) => {
    const domain = byName.get(name) ?? null;
    return { name, domain, apps: byDomain.get(name) ?? [], ...assessDomain(domain, now) };
  });
  const rows = all.filter((row) => !row.domain?.hiddenAt).sort(compareDomains);
  const hidden = all.filter((row) => row.domain?.hiddenAt).sort((a, b) => a.name.localeCompare(b.name));
  return { rows, hidden, withoutDomain };
}

/** The domain rows the dashboard calls out (Expiring Soon or worse). */
export async function listDomainsNeedingAttention(now: Date = new Date()) {
  return (await getDomainOverview(now)).rows.filter((row) => needsAttention(row.status));
}

/** The domain an app's URL is on, with its assessment; null for IP addresses and the like. */
export async function getDomainForUrl(url: string, now: Date = new Date()) {
  const name = registeredDomain(url);
  if (!name) return null;
  const domain = await db.domain.findUnique({ where: { name } });
  return { name, domain, ...assessDomain(domain, now) };
}

export function getDomain(id: string) {
  return db.domain.findUnique({ where: { id } });
}

// ---------------------------------------------------------------------------
// Add / edit / delete (Domains page)
// ---------------------------------------------------------------------------

export class DomainNotFoundError extends Error {
  constructor() {
    super("Domain not found");
    this.name = "DomainNotFoundError";
  }
}

export class DuplicateDomainError extends Error {
  constructor(name: string) {
    super(`${name} is already listed`);
    this.name = "DuplicateDomainError";
  }
}

async function usedByApps(name: string): Promise<boolean> {
  return (await appsByDomain()).byDomain.has(name);
}

/**
 * Watch a domain no app uses (or one that was hidden). Returns the row and
 * whether it still needs its first lookup.
 */
export async function addDomain(input: DomainInput) {
  const existing = await db.domain.findUnique({ where: { name: input.name } });
  if (existing && !existing.hiddenAt && (existing.addedManually || (await usedByApps(input.name)))) {
    throw new DuplicateDomainError(input.name);
  }

  const data = { addedManually: true, hiddenAt: null, registrar: input.registrar, manualExpiresAt: input.manualExpiresAt };
  try {
    // An existing row (hidden, or left over from an app that moved) keeps its lookup history.
    const domain = existing
      ? await db.domain.update({ where: { id: existing.id }, data })
      : await db.domain.create({ data: { name: input.name, ...data } });
    return { domain, needsLookup: domain.lastCheckedAt === null };
  } catch (error) {
    // Two people adding the same domain at once: the unique name decides.
    if (typeof error === "object" && error !== null && (error as { code?: string }).code === "P2002") {
      throw new DuplicateDomainError(input.name);
    }
    throw error;
  }
}

/** Edit the hand-maintained details. The name is fixed: a different domain is a different row. */
export async function updateDomain(id: string, input: Pick<DomainInput, "registrar" | "manualExpiresAt">) {
  const { count } = await db.domain.updateMany({
    where: { id },
    data: { registrar: input.registrar, manualExpiresAt: input.manualExpiresAt },
  });
  if (count === 0) throw new DomainNotFoundError();
}

/**
 * Delete from the Domains page. A domain an app still uses can't really go
 * away, so it is hidden (and can be restored); any other domain is deleted.
 */
export async function removeDomain(id: string): Promise<"hidden" | "deleted"> {
  const domain = await getDomain(id);
  if (!domain) throw new DomainNotFoundError();

  if (await usedByApps(domain.name)) {
    await db.domain.update({ where: { id }, data: { hiddenAt: new Date() } });
    return "hidden";
  }
  await db.domain.delete({ where: { id } });
  return "deleted";
}

export async function restoreDomain(id: string) {
  const { count } = await db.domain.updateMany({ where: { id }, data: { hiddenAt: null } });
  if (count === 0) throw new DomainNotFoundError();
}
