import { randomUUID } from "node:crypto";
import { logError, logInfo } from "@/lib/logger";
import { slugify } from "@/lib/slug";
import { safeRelativePath } from "./dashboard-bundle.validator";
import type { TDashboardStorage } from "@/lib/storage/dashboard-storage";
import {
  buildCoverPrefix,
  buildDashboardPrefix,
} from "@/lib/storage/dashboard-storage";
import type { TDashboardAccessServices } from "./dashboard-access.services";
import type { TDashboardRepository } from "./dashboard.repository";
import type { TDashboardUploadServices } from "./dashboard-upload.services";

/**
 * Dashboard business logic.
 *
 * Two rules hold throughout:
 *   1. Every dashboard-scoped read re-checks access through
 *      DashboardAccessServices — the route guard is a coarse filter, not the
 *      authority.
 *   2. Every privileged mutation re-asserts isDashboardAdmin here regardless of
 *      what the route already checked (defense in depth).
 */

type TDashboardServiceDeps = {
  DashboardRepository: TDashboardRepository;
  DashboardAccessServices: TDashboardAccessServices;
  DashboardUploadServices: TDashboardUploadServices;
  DashboardStorage: TDashboardStorage;
};

/** Discriminated result so controllers can map to the right HTTP status. */
export type ServiceResult<T> =
  | { ok: true; data: T }
  | { ok: false; status: 403 | 404 | 409 | 422 | 500; message: string; errors?: string[] };

const denied = (message = "You do not have access to this dashboard.") =>
  ({ ok: false as const, status: 403 as const, message });
const notFound = (message = "Dashboard not found.") =>
  ({ ok: false as const, status: 404 as const, message });

export const DashboardServices = ({
  DashboardRepository,
  DashboardAccessServices,
  DashboardUploadServices,
  DashboardStorage,
}: TDashboardServiceDeps) => {
  type Actor = { userId: string; email?: string | null };

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

  const assertDashboardAdmin = async (actor: Actor) =>
    DashboardAccessServices.isDashboardAdminByUserId(actor.userId);

  /** slugify + numeric suffix until free. Bounded so a pathological name can't spin. */
  const generateUniqueSlug = async (name: string): Promise<string> => {
    const base = slugify(name) || "dashboard";
    for (let attempt = 0; attempt < 50; attempt++) {
      const candidate = attempt === 0 ? base : `${base}-${attempt + 1}`;
      const clash = await DashboardRepository.FindBySlug(candidate);
      if (!clash) return candidate;
    }
    return `${base}-${Date.now()}`;
  };

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

  const CreateDashboard = async (input: {
    name: string;
    description?: string | null;
    actor: Actor;
  }): Promise<ServiceResult<unknown>> => {
    if (!(await assertDashboardAdmin(input.actor))) return denied("You cannot create dashboards.");

    const slug = await generateUniqueSlug(input.name);
    const dashboard = await DashboardRepository.CreateDashboard({
      name: input.name,
      slug,
      description: input.description ?? null,
      createdBy: input.actor.userId,
    });
    await audit(dashboard.id, "dashboard.created", input.actor, {
      name: dashboard.name,
      slug: dashboard.slug,
    });
    return { ok: true, data: dashboard };
  };

  const ListDashboardsForUser = async (
    userId: string,
  ): Promise<ServiceResult<unknown[]>> => {
    const scope = await DashboardAccessServices.getViewableDashboardIds(userId);
    // scope === [] is a legitimate answer: fail-closed means "no dashboards".
    const rows = await DashboardRepository.ListDashboards(
      scope === "ALL" ? null : scope,
    );
    return { ok: true, data: rows };
  };

  const GetDashboardDetail = async (
    dashboardId: string,
    userId: string,
  ): Promise<ServiceResult<unknown>> => {
    const dashboard = await DashboardRepository.FindById(dashboardId);
    if (!dashboard) return notFound();

    if (!(await DashboardAccessServices.canUserViewDashboard(userId, dashboardId))) {
      return denied();
    }

    const isAdmin = await DashboardAccessServices.isDashboardAdminByUserId(userId);
    const versions = await DashboardRepository.ListVersionsByDashboard(dashboardId);

    // Access list and public links are management surface — viewers granted a
    // single dashboard have no business seeing who else has it.
    const [accessList, publicLinks] = isAdmin
      ? await Promise.all([
          DashboardRepository.ListUsersForDashboard(dashboardId),
          DashboardRepository.ListPublicLinksForDashboard(dashboardId),
        ])
      : [undefined, undefined];

    return {
      ok: true,
      data: {
        dashboard,
        versions,
        isDashboardAdmin: isAdmin,
        ...(isAdmin ? { accessList, publicLinks } : {}),
      },
    };
  };

  const ListVersions = async (
    dashboardId: string,
    userId: string,
  ): Promise<ServiceResult<unknown[]>> => {
    const dashboard = await DashboardRepository.FindById(dashboardId);
    if (!dashboard) return notFound();
    if (!(await DashboardAccessServices.canUserViewDashboard(userId, dashboardId))) {
      return denied();
    }
    return { ok: true, data: await DashboardRepository.ListVersionsByDashboard(dashboardId) };
  };

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

  const UploadVersion = async (input: {
    dashboardId: string;
    zipBuffer: Buffer;
    actor: Actor;
    publish?: boolean;
  }): Promise<ServiceResult<unknown>> => {
    if (!(await assertDashboardAdmin(input.actor))) return denied("You cannot upload dashboard versions.");

    const dashboard = await DashboardRepository.FindById(input.dashboardId);
    if (!dashboard) return notFound();

    // Mint the version id up front. It keys the storage prefix, so this upload
    // provably owns every byte it writes — no concurrent upload can share the
    // prefix, and cleanup can never touch someone else's files. The version
    // NUMBER is not decided here; it is allocated under a row lock at insert
    // time, after the upload.
    const versionId = randomUUID();

    const stored = await DashboardUploadServices.ProcessUpload({
      dashboardId: input.dashboardId,
      versionId,
      zipBuffer: input.zipBuffer,
      uploadedBy: input.actor.userId,
    });
    if (!stored.ok) {
      return {
        ok: false,
        status: 422,
        message: "The uploaded bundle failed the Bundle Contract.",
        errors: stored.errors,
      };
    }

    let inserted;
    try {
      inserted = await DashboardRepository.InsertVersion({
        dashboardId: input.dashboardId,
        versionId,
        storagePrefix: stored.storagePrefix,
        entryPoint: stored.entryPoint,
        manifest: stored.manifest,
        sizeBytes: stored.sizeBytes,
        fileCount: stored.fileCount,
        uploadedBy: input.actor.userId,
        publish: input.publish === true,
      });
    } catch (err) {
      // The row never committed, so the uploaded bytes belong to nothing.
      // Deleting this prefix is safe by construction: the UUID was minted
      // locally and shared with no one.
      logError(err, `Failed inserting version row for ${input.dashboardId} (${versionId})`);
      try {
        await DashboardStorage.deleteByPrefix(stored.storagePrefix);
      } catch (cleanupErr) {
        logError(cleanupErr, `Failed cleaning orphaned prefix ${stored.storagePrefix}`);
      }
      return {
        ok: false,
        status: 500,
        message: `Failed recording the uploaded version: ${(err as Error).message}`,
      };
    }

    const { version, madeCurrent } = inserted;
    await audit(input.dashboardId, "version.uploaded", input.actor, {
      version_id: version.id,
      version_number: version.version_number,
      size_bytes: stored.sizeBytes,
      file_count: stored.fileCount,
      entry_point: stored.entryPoint,
      made_current: madeCurrent,
    });

    return {
      ok: true,
      data: { version, warnings: stored.warnings, htmlFiles: stored.htmlFiles },
    };
  };

  /**
   * Entry-point override.
   *
   * dashboard_versions is immutable by design, so this is deliberately narrow:
   * it is allowed ONLY while the dashboard has never been published
   * (status = 'draft'). Once a dashboard goes live, its versions — including
   * the entry point anyone may have bookmarked — are frozen. The wizard runs
   * entirely in the draft window, so it can offer the override; the management
   * page on a published dashboard cannot.
   *
   * The chosen path is validated against STORAGE, not against a client-supplied
   * list, so a caller can't point the entry at something that isn't in the
   * bundle.
   */
  const SetVersionEntryPoint = async (input: {
    dashboardId: string;
    versionId: string;
    entryPoint: string;
    actor: Actor;
  }): Promise<ServiceResult<unknown>> => {
    if (!(await assertDashboardAdmin(input.actor))) {
      return denied("You cannot change the entry point.");
    }
    const dashboard = await DashboardRepository.FindById(input.dashboardId);
    if (!dashboard) return notFound();

    if (dashboard.status !== "draft") {
      return {
        ok: false,
        status: 422,
        message:
          "The entry point can only be changed before a dashboard is published. Upload a new version instead.",
      };
    }

    const version = await DashboardRepository.FindVersionById(input.versionId);
    if (!version || version.dashboard_id !== input.dashboardId) {
      return { ok: false, status: 404, message: "Version not found for this dashboard." };
    }

    const safe = safeRelativePath(input.entryPoint);
    if (!safe || !safe.toLowerCase().endsWith(".html")) {
      return { ok: false, status: 422, message: "The entry point must be an .html file in the bundle." };
    }
    const exists = await DashboardStorage.objectExists(`${version.storage_prefix}/${safe}`);
    if (!exists) {
      return { ok: false, status: 422, message: `"${safe}" is not present in this bundle.` };
    }

    const updated = await DashboardRepository.SetVersionEntryPoint(input.versionId, safe);
    await audit(input.dashboardId, "version.entry_point_changed", input.actor, {
      version_id: input.versionId,
      from: version.entry_point,
      to: safe,
    });
    return { ok: true, data: updated };
  };

  const PublishDashboard = async (input: {
    dashboardId: string;
    versionId: string;
    actor: Actor;
  }): Promise<ServiceResult<unknown>> => {
    if (!(await assertDashboardAdmin(input.actor))) return denied("You cannot publish dashboards.");

    const dashboard = await DashboardRepository.FindById(input.dashboardId);
    if (!dashboard) return notFound();

    const version = await DashboardRepository.FindVersionById(input.versionId);
    if (!version || version.dashboard_id !== input.dashboardId) {
      return { ok: false, status: 404, message: "Version not found for this dashboard." };
    }

    const updated = await DashboardRepository.SetCurrentVersion(
      input.dashboardId,
      input.versionId,
      "published",
    );
    await audit(input.dashboardId, "dashboard.published", input.actor, {
      version_id: input.versionId,
      version_number: version.version_number,
      previous_version_id: dashboard.current_version_id,
    });
    return { ok: true, data: updated };
  };

  const RollbackDashboard = async (input: {
    dashboardId: string;
    versionId: string;
    actor: Actor;
  }): Promise<ServiceResult<unknown>> => {
    if (!(await assertDashboardAdmin(input.actor))) return denied("You cannot roll back dashboards.");

    const dashboard = await DashboardRepository.FindById(input.dashboardId);
    if (!dashboard) return notFound();

    const target = await DashboardRepository.FindVersionById(input.versionId);
    if (!target || target.dashboard_id !== input.dashboardId) {
      return { ok: false, status: 404, message: "Version not found for this dashboard." };
    }

    const from = dashboard.current_version_id
      ? await DashboardRepository.FindVersionById(dashboard.current_version_id)
      : null;

    // Old versions keep their files: dashboard_versions is immutable and its
    // GCS prefix is never reused, so rolling forward again is just a pointer move.
    const updated = await DashboardRepository.SetCurrentVersion(
      input.dashboardId,
      input.versionId,
    );
    await audit(input.dashboardId, "dashboard.rolled_back", input.actor, {
      from_version_id: from?.id ?? null,
      from_version_number: from?.version_number ?? null,
      to_version_id: target.id,
      to_version_number: target.version_number,
    });
    return { ok: true, data: updated };
  };

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

  const GetAccessList = async (
    dashboardId: string,
    actor: Actor,
  ): Promise<ServiceResult<unknown[]>> => {
    if (!(await assertDashboardAdmin(actor))) return denied("You cannot view the access list.");
    const dashboard = await DashboardRepository.FindById(dashboardId);
    if (!dashboard) return notFound();
    return { ok: true, data: await DashboardRepository.ListUsersForDashboard(dashboardId) };
  };

  const SetAccessList = async (input: {
    dashboardId: string;
    userIds: string[];
    actor: Actor;
  }): Promise<ServiceResult<unknown>> => {
    if (!(await assertDashboardAdmin(input.actor))) return denied("You cannot change the access list.");

    const dashboard = await DashboardRepository.FindById(input.dashboardId);
    if (!dashboard) return notFound();

    const current = await DashboardRepository.ListUsersForDashboard(input.dashboardId);
    const currentIds = new Set(current.map((r) => r.console_user_id));
    const nextIds = new Set(input.userIds);
    const added = input.userIds.filter((id) => !currentIds.has(id));
    const removed = [...currentIds].filter((id) => !nextIds.has(id));

    await DashboardRepository.ReplaceGrants(
      input.dashboardId,
      [...nextIds],
      input.actor.userId,
    );

    // Separate rows so the trail reads as grants and revocations, not one diff.
    if (added.length > 0) {
      await audit(input.dashboardId, "access.granted", input.actor, {
        target_user_ids: added,
      });
    }
    if (removed.length > 0) {
      await audit(input.dashboardId, "access.revoked", input.actor, {
        target_user_ids: removed,
      });
    }

    return {
      ok: true,
      data: {
        accessList: await DashboardRepository.ListUsersForDashboard(input.dashboardId),
        added,
        removed,
      },
    };
  };

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

  const UpdateMeta = async (input: {
    dashboardId: string;
    patch: {
      name?: string;
      description?: string | null;
      status?: "draft" | "published";
      coverImagePath?: string | null;
    };
    actor: Actor;
  }): Promise<ServiceResult<unknown>> => {
    if (!(await assertDashboardAdmin(input.actor))) return denied("You cannot edit this dashboard.");

    const dashboard = await DashboardRepository.FindById(input.dashboardId);
    if (!dashboard) return notFound();

    // Publishing requires something to publish.
    if (input.patch.status === "published" && !dashboard.current_version_id) {
      return {
        ok: false,
        status: 422,
        message: "Cannot publish a dashboard that has no uploaded version.",
      };
    }

    const updated = await DashboardRepository.UpdateMeta(input.dashboardId, {
      ...(input.patch.name !== undefined ? { name: input.patch.name } : {}),
      ...(input.patch.description !== undefined
        ? { description: input.patch.description }
        : {}),
      ...(input.patch.status !== undefined ? { status: input.patch.status } : {}),
      ...(input.patch.coverImagePath !== undefined
        ? { cover_image_path: input.patch.coverImagePath }
        : {}),
    });

    await audit(input.dashboardId, "dashboard.updated", input.actor, {
      before: {
        name: dashboard.name,
        description: dashboard.description,
        status: dashboard.status,
        cover_image_path: dashboard.cover_image_path,
      },
      after: input.patch,
    });
    return { ok: true, data: updated };
  };

  const DeleteDashboard = async (input: {
    dashboardId: string;
    actor: Actor;
  }): Promise<ServiceResult<unknown>> => {
    if (!(await assertDashboardAdmin(input.actor))) return denied("You cannot delete dashboards.");

    const dashboard = await DashboardRepository.FindById(input.dashboardId);
    if (!dashboard) return notFound();

    // Write the audit row FIRST. dashboard_audit_log.dashboard_id is ON DELETE
    // SET NULL, so the row survives the cascade — but only if it already
    // exists. The name and id live in metadata so the trail stays readable
    // after the FK is nulled.
    await audit(input.dashboardId, "dashboard.deleted", input.actor, {
      deleted_dashboard_id: dashboard.id,
      deleted_dashboard_name: dashboard.name,
      deleted_dashboard_slug: dashboard.slug,
    });

    await DashboardRepository.DeleteDashboard(input.dashboardId);

    // Best-effort: one prefix covers every version and the cover image.
    try {
      // Builder, not a literal: the root folder must not be duplicated here.
      await DashboardStorage.deleteByPrefix(buildDashboardPrefix(input.dashboardId));
    } catch (err) {
      logError(err, `Failed removing GCS objects for deleted dashboard ${input.dashboardId}`);
    }

    logInfo(`Dashboard ${input.dashboardId} deleted by ${input.actor.email ?? input.actor.userId}`);
    return { ok: true, data: { id: input.dashboardId } };
  };

  const GetAuditLog = async (
    dashboardId: string,
    actor: Actor,
  ): Promise<ServiceResult<unknown[]>> => {
    if (!(await assertDashboardAdmin(actor))) return denied("You cannot view this audit log.");
    const dashboard = await DashboardRepository.FindById(dashboardId);
    if (!dashboard) return notFound();
    return { ok: true, data: await DashboardRepository.ListAuditForDashboard(dashboardId) };
  };

  // -------------------------------------------------------------------------
  // Cover image
  // -------------------------------------------------------------------------

  /** Only real raster formats — no SVG, which can carry script. */
  const COVER_CONTENT_TYPES: Record<string, string> = {
    png: "image/png",
    jpg: "image/jpeg",
    jpeg: "image/jpeg",
    webp: "image/webp",
  };
  const MAX_COVER_BYTES = 5 * 1024 * 1024;

  const UploadCover = async (input: {
    dashboardId: string;
    fileName: string;
    contentType: string;
    buffer: Buffer;
    actor: Actor;
  }): Promise<ServiceResult<unknown>> => {
    if (!(await assertDashboardAdmin(input.actor))) {
      return denied("You cannot change this dashboard's cover.");
    }
    const dashboard = await DashboardRepository.FindById(input.dashboardId);
    if (!dashboard) return notFound();

    const ext = input.fileName.slice(input.fileName.lastIndexOf(".") + 1).toLowerCase();
    const resolved = COVER_CONTENT_TYPES[ext];
    // Trust the extension whitelist, not the client-supplied content type.
    if (!resolved) {
      return {
        ok: false,
        status: 422,
        message: `Cover must be one of: ${Object.keys(COVER_CONTENT_TYPES).join(", ")}.`,
      };
    }
    if (input.buffer.length > MAX_COVER_BYTES) {
      return {
        ok: false,
        status: 422,
        message: `Cover exceeds the ${MAX_COVER_BYTES / (1024 * 1024)} MB limit.`,
      };
    }

    const prefix = buildCoverPrefix(input.dashboardId);
    // One cover per dashboard: clear the old object so a replacement can't
    // leave the previous file orphaned under the same prefix.
    try {
      await DashboardStorage.deleteByPrefix(prefix);
    } catch (err) {
      logError(err, `Failed clearing old cover for ${input.dashboardId}`);
    }

    const safeName = `cover.${ext}`;
    const fullPath = await DashboardStorage.putObject(
      prefix,
      safeName,
      input.buffer,
      resolved,
    );
    const updated = await DashboardRepository.UpdateMeta(input.dashboardId, {
      cover_image_path: fullPath,
    });

    await audit(input.dashboardId, "dashboard.cover_updated", input.actor, {
      cover_image_path: fullPath,
      size_bytes: input.buffer.length,
      content_type: resolved,
    });
    return { ok: true, data: updated };
  };

  /**
   * Resolve a cover for streaming. Access-scoped through the SAME
   * canUserViewDashboard helper as everything else: the cover of a dashboard
   * you cannot see is a 404, not a 403, so covers can't be used to probe which
   * dashboards exist.
   */
  const ResolveCover = async (
    dashboardId: string,
    userId: string,
  ): Promise<ServiceResult<{ path: string }>> => {
    const dashboard = await DashboardRepository.FindById(dashboardId);
    if (!dashboard) return notFound("Cover not found.");
    if (!(await DashboardAccessServices.canUserViewDashboard(userId, dashboardId))) {
      return notFound("Cover not found.");
    }
    if (!dashboard.cover_image_path) return notFound("Cover not found.");
    return { ok: true, data: { path: dashboard.cover_image_path } };
  };

  // -------------------------------------------------------------------------
  // Serving support
  // -------------------------------------------------------------------------

  /**
   * Resolve which version a serve request should read from, enforcing access.
   * `requestedVersionId` is honoured only for dashboard admins, so a viewer
   * cannot preview an unpublished draft by guessing a version id.
   */
  const ResolveServeTarget = async (input: {
    dashboardId: string;
    userId: string;
    requestedVersionId?: string | null;
  }): Promise<ServiceResult<{ dashboard: any; version: any }>> => {
    const dashboard = await DashboardRepository.FindById(input.dashboardId);
    if (!dashboard) return notFound();

    if (!(await DashboardAccessServices.canUserViewDashboard(input.userId, input.dashboardId))) {
      return denied();
    }

    const isAdmin = await DashboardAccessServices.isDashboardAdminByUserId(input.userId);

    let versionId = dashboard.current_version_id;
    if (input.requestedVersionId) {
      if (!isAdmin) {
        return denied("Only dashboard managers can preview a specific version.");
      }
      versionId = input.requestedVersionId;
    }
    if (!versionId) {
      return { ok: false, status: 404, message: "This dashboard has no published version yet." };
    }

    const version = await DashboardRepository.FindVersionById(versionId);
    if (!version || version.dashboard_id !== input.dashboardId) {
      return { ok: false, status: 404, message: "Version not found for this dashboard." };
    }
    return { ok: true, data: { dashboard, version } };
  };

  const RecordView = async (input: {
    dashboardId: string;
    versionId: string | null;
    consoleUserId: string | null;
    ipAddress: string | null;
  }) =>
    DashboardRepository.InsertView({
      dashboardId: input.dashboardId,
      versionId: input.versionId,
      consoleUserId: input.consoleUserId,
      publicLinkId: null, // prompt 4 fills this in for token-based views
      ipAddress: input.ipAddress,
    });

  return {
    CreateDashboard,
    ListDashboardsForUser,
    GetDashboardDetail,
    ListVersions,
    UploadVersion,
    PublishDashboard,
    RollbackDashboard,
    GetAccessList,
    SetAccessList,
    UpdateMeta,
    DeleteDashboard,
    GetAuditLog,
    ResolveServeTarget,
    RecordView,
    UploadCover,
    ResolveCover,
    SetVersionEntryPoint,
  };
};

export type TDashboardServices = ReturnType<typeof DashboardServices>;
