import crypto from "node:crypto";
import { sign, verify } from "hono/jwt";
import { logError } from "@/lib/logger";
import { hashPassword, verifyPassword } from "@/lib/password";
import type { TDashboardAccessServices } from "./dashboard-access.services";
import type { TDashboardRepository } from "./dashboard.repository";
import type { ServiceResult } from "./dashboard.services";

/**
 * Public share links.
 *
 * TOKEN LIFECYCLE
 *   generate   32 random bytes → base64url, unpadded. This raw token is the
 *              credential and appears in the shareable URL.
 *   store      ONLY sha256(rawToken) in dashboard_public_links.token_hash.
 *   return     the raw token exactly once, in the create response. It cannot be
 *              recovered afterwards — the hash is one-way and nothing else
 *              retains it. It is never logged and never written to audit.
 *   resolve    hash the incoming token and look it up by the indexed hash
 *              column. A hash lookup leaks nothing by timing that a
 *              constant-time compare would fix.
 */

const TOKEN_BYTES = 32;
const UNLOCK_TOKEN_TTL_SECONDS = 2 * 60 * 60; // 2h
const UNLOCK_TOKEN_PURPOSE = "dashboard_public_unlock";

/** Cookie name is per-link, so unlocking one link never unlocks another. */
export const unlockCookieName = (linkId: string) => `dpl_${linkId}`;
export const UNLOCK_HEADER = "x-dashboard-unlock";

export const sha256Hex = (value: string) =>
  crypto.createHash("sha256").update(value).digest("hex");

const generateRawToken = () =>
  crypto.randomBytes(TOKEN_BYTES).toString("base64url");

/**
 * Why a public request could not be served. The caller collapses EVERY one of
 * these to the same generic response, except PASSWORD_REQUIRED — a password
 * prompt inherently reveals that the link resolved, which the design accepts.
 */
export type PublicResolution =
  | { state: "unavailable" }
  | { state: "password_required"; linkId: string }
  | {
      state: "ok";
      link: { id: string };
      dashboard: { id: string };
      version: { id: string; storage_prefix: string; entry_point: string };
    };

type TDashboardPublicLinkServiceDeps = {
  DashboardRepository: TDashboardRepository;
  DashboardAccessServices: TDashboardAccessServices;
};

export const DashboardPublicLinkServices = ({
  DashboardRepository,
  DashboardAccessServices,
}: TDashboardPublicLinkServiceDeps) => {
  type Actor = { userId: string; email?: string | null };

  const jwtSecret = () => process.env.JWT_SECRET ?? "";

  const audit = (
    dashboardId: string,
    action: string,
    actor: Actor,
    metadata: Record<string, unknown>,
  ) =>
    DashboardRepository.InsertAudit({
      dashboardId,
      action,
      actorId: actor.userId,
      actorEmail: actor.email ?? null,
      metadata,
    });

  // ---------------------------------------------------------------------------
  // Unlock tokens
  // ---------------------------------------------------------------------------

  /**
   * Contains ONLY the link id and a purpose tag. No raw share token, no
   * password, no password hash, no dashboard id — a leaked unlock token grants
   * nothing beyond "password already satisfied for this one link", and only
   * until it expires.
   */
  const IssueUnlockToken = async (linkId: string) => {
    const now = Math.floor(Date.now() / 1000);
    return sign(
      {
        iat: now,
        nbf: now,
        exp: now + UNLOCK_TOKEN_TTL_SECONDS,
        purpose: UNLOCK_TOKEN_PURPOSE,
        link_id: linkId,
      },
      jwtSecret(),
    );
  };

  /** Valid only for the link it was minted for. */
  const VerifyUnlockToken = async (token: string | undefined, linkId: string) => {
    if (!token) return false;
    try {
      const payload = (await verify(token, jwtSecret(), "HS256")) as {
        purpose?: string;
        link_id?: string;
      };
      return payload.purpose === UNLOCK_TOKEN_PURPOSE && payload.link_id === linkId;
    } catch {
      return false;
    }
  };

  // ---------------------------------------------------------------------------
  // Public resolution
  // ---------------------------------------------------------------------------

  /**
   * Resolve a raw share token to something servable.
   *
   * Every non-serviceable outcome — unknown token, revoked, expired, dashboard
   * deleted, dashboard still a draft, no current version — returns the SAME
   * `unavailable` value. A prober cannot tell which applies, so valid tokens
   * cannot be enumerated and a revoked link is indistinguishable from one that
   * never existed.
   */
  const ResolvePublicToken = async (
    rawToken: string,
    unlockToken?: string,
  ): Promise<PublicResolution> => {
    const unavailable: PublicResolution = { state: "unavailable" };
    if (!rawToken) return unavailable;

    const link = await DashboardRepository.FindPublicLinkByTokenHash(
      sha256Hex(rawToken),
    );
    if (!link) return unavailable;
    if (link.is_revoked) return unavailable;
    if (link.expires_at && new Date(link.expires_at).getTime() <= Date.now()) {
      return unavailable;
    }

    const dashboard = await DashboardRepository.FindById(link.dashboard_id);
    if (!dashboard) return unavailable;

    // A public viewer sees the CURRENT PUBLISHED version and nothing else.
    // Drafts are invisible even through a valid link.
    if (dashboard.status !== "published") return unavailable;
    if (!dashboard.current_version_id) return unavailable;

    const version = await DashboardRepository.FindVersionById(
      dashboard.current_version_id,
    );
    if (!version || version.dashboard_id !== dashboard.id) return unavailable;

    if (link.password_hash) {
      const unlocked = await VerifyUnlockToken(unlockToken, link.id);
      if (!unlocked) return { state: "password_required", linkId: link.id };
    }

    return {
      state: "ok",
      link: { id: link.id },
      dashboard: { id: dashboard.id },
      version: {
        id: version.id,
        storage_prefix: version.storage_prefix,
        entry_point: version.entry_point,
      },
    };
  };

  /**
   * Password check for the unlock endpoint.
   *
   * Returns the same `unavailable` shell for an unknown/revoked/expired link
   * AND for a link that has no password at all — the only legitimate way to
   * reach this endpoint is after a PASSWORD_REQUIRED response, so refusing
   * generically here keeps the endpoint from confirming that a passwordless
   * link exists.
   */
  const AttemptUnlock = async (
    rawToken: string,
    password: string,
  ): Promise<{ ok: true; token: string; linkId: string } | { ok: false; reason: "unavailable" | "bad_password" }> => {
    if (!rawToken) return { ok: false, reason: "unavailable" };

    const link = await DashboardRepository.FindPublicLinkByTokenHash(
      sha256Hex(rawToken),
    );
    if (!link) return { ok: false, reason: "unavailable" };
    if (link.is_revoked) return { ok: false, reason: "unavailable" };
    if (link.expires_at && new Date(link.expires_at).getTime() <= Date.now()) {
      return { ok: false, reason: "unavailable" };
    }
    if (!link.password_hash) return { ok: false, reason: "unavailable" };

    const matches = await verifyPassword(password, link.password_hash);
    if (!matches) return { ok: false, reason: "bad_password" };

    return { ok: true, token: await IssueUnlockToken(link.id), linkId: link.id };
  };

  const RecordPublicView = async (entry: {
    dashboardId: string;
    versionId: string;
    publicLinkId: string;
    ipAddress: string | null;
  }) =>
    DashboardRepository.InsertView({
      dashboardId: entry.dashboardId,
      versionId: entry.versionId,
      consoleUserId: null, // public viewers are never a console user
      publicLinkId: entry.publicLinkId,
      ipAddress: entry.ipAddress,
    });

  const IncrementViewCount = (linkId: string) =>
    DashboardRepository.IncrementPublicLinkViewCount(linkId);

  // ---------------------------------------------------------------------------
  // Admin surface
  // ---------------------------------------------------------------------------

  /**
   * The console's public-facing origin, where prompt 7's viewer shell lives.
   * ADMIN_CONSOLE_URL already exists and is exactly that origin, so no new env
   * var is introduced.
   */
  const publicBaseUrl = () =>
    (process.env.ADMIN_CONSOLE_URL ?? "").replace(/\/+$/, "");

  const buildShareUrl = (rawToken: string) =>
    `${publicBaseUrl()}/public/dashboards/${rawToken}`;

  const CreateLink = async (input: {
    dashboardId: string;
    expiresAt?: string | null;
    password?: string | null;
    actor: Actor;
  }): Promise<ServiceResult<unknown>> => {
    if (!(await DashboardAccessServices.isDashboardAdminByUserId(input.actor.userId))) {
      return { ok: false, status: 403, message: "You cannot create share links." };
    }
    const dashboard = await DashboardRepository.FindById(input.dashboardId);
    if (!dashboard) return { ok: false, status: 404, message: "Dashboard not found." };

    let expiresAt: Date | null = null;
    if (input.expiresAt) {
      const parsed = new Date(input.expiresAt);
      if (Number.isNaN(parsed.getTime())) {
        return { ok: false, status: 422, message: "expiresAt is not a valid date." };
      }
      if (parsed.getTime() <= Date.now()) {
        return { ok: false, status: 422, message: "expiresAt must be in the future." };
      }
      expiresAt = parsed;
    }

    const rawToken = generateRawToken();
    const passwordHash = input.password
      ? await hashPassword(input.password)
      : null;

    const link = await DashboardRepository.InsertPublicLink({
      dashboardId: input.dashboardId,
      tokenHash: sha256Hex(rawToken),
      passwordHash,
      expiresAt,
      createdBy: input.actor.userId,
    });

    // Audit records the link, never the token or its hash.
    await audit(input.dashboardId, "public_link.created", input.actor, {
      link_id: link.id,
      has_password: passwordHash !== null,
      expires_at: expiresAt ? expiresAt.toISOString() : null,
    });

    return {
      ok: true,
      data: {
        id: link.id,
        url: buildShareUrl(rawToken),
        // Returned EXACTLY ONCE. Not stored, not logged, unrecoverable after this.
        rawToken,
        expiresAt: expiresAt ? expiresAt.toISOString() : null,
        hasPassword: passwordHash !== null,
        note: "Copy this link now — the token is shown only once and cannot be retrieved again.",
      },
    };
  };

  const ListLinks = async (
    dashboardId: string,
    actor: Actor,
  ): Promise<ServiceResult<unknown[]>> => {
    if (!(await DashboardAccessServices.isDashboardAdminByUserId(actor.userId))) {
      return { ok: false, status: 403, message: "You cannot view share links." };
    }
    const dashboard = await DashboardRepository.FindById(dashboardId);
    if (!dashboard) return { ok: false, status: 404, message: "Dashboard not found." };

    // Metadata only — no token, no hash, no password hash.
    return {
      ok: true,
      data: await DashboardRepository.ListPublicLinksForDashboard(dashboardId),
    };
  };

  const RevokeLink = async (input: {
    dashboardId: string;
    linkId: string;
    actor: Actor;
  }): Promise<ServiceResult<unknown>> => {
    if (!(await DashboardAccessServices.isDashboardAdminByUserId(input.actor.userId))) {
      return { ok: false, status: 403, message: "You cannot revoke share links." };
    }
    const revoked = await DashboardRepository.RevokePublicLink(
      input.dashboardId,
      input.linkId,
    );
    if (!revoked) return { ok: false, status: 404, message: "Share link not found." };

    await audit(input.dashboardId, "public_link.revoked", input.actor, {
      link_id: input.linkId,
    });
    return { ok: true, data: { id: input.linkId, isRevoked: true } };
  };

  return {
    CreateLink,
    ListLinks,
    RevokeLink,
    ResolvePublicToken,
    AttemptUnlock,
    IssueUnlockToken,
    VerifyUnlockToken,
    RecordPublicView,
    IncrementViewCount,
    buildShareUrl,
  };
};

export type TDashboardPublicLinkServices = ReturnType<
  typeof DashboardPublicLinkServices
>;
