import type { Context } from "hono";
import { error, success } from "@/lib/response";
import { logError } from "@/lib/logger";
import { EDMRepository } from "@/internal/edm/edm.repository";
import {
  readEDMBundle,
  readEDMBundleEntries,
  EDM_BUNDLE_CONTRACT,
} from "@/internal/edm/edm-import";
import {
  resolveEDMAssets,
  uploadEDMAsset,
} from "@/internal/edm/edm-assets.services";
import { scanImages, extensionOf, IMAGE_EXTENSIONS } from "@/internal/edm/edm-assets";
import { extractSubject } from "@/internal/edm/edm-subject";
import { findUnknownBlockHelpers } from "@/internal/edm/edm-render";
import { batchImportEDMBundle } from "@/internal/edm/edm-batch-import.services";
import {
  EDMService,
  EDM_STORAGE_SETTING_KEY,
  resolveStorageTarget,
} from "@/v1/services/admin/edm/edm.service";
import { ConsoleSettingsRepository } from "@/internal/repository/admin_console/console_settings";
import { EDMStorage } from "@/lib/storage/edm-storage";

const actorId = (c: Context): string | null =>
  (c.get("consoleUser") as { id?: string } | undefined)?.id ??
  (c.get("consoleUserId") as string | undefined) ??
  null;

// ── folders ─────────────────────────────────────────────────────────────────

export const listEDMFoldersHandler = async (c: Context) => {
  try {
    const db = c.get("datastore");
    return success(c, await EDMRepository.ListFolders(db));
  } catch (err: any) {
    logError(err, "[EDM] listEDMFoldersHandler");
    return error(c, err?.message ?? "Failed to list folders", 500);
  }
};

export const createEDMFolderHandler = async (c: Context) => {
  try {
    const db = c.get("datastore");
    const body = await c.req.json() as { name?: string; parent_id?: string | null };

    if (!body.name?.trim()) return error(c, "name is required", 400);

    if (body.parent_id) {
      const parent = await EDMRepository.FindFolderById(db, body.parent_id);
      if (!parent) return error(c, "parent folder not found", 404);
    }

    const folder = await EDMRepository.CreateFolder(db, {
      name: body.name.trim(),
      parentId: body.parent_id ?? null,
      createdBy: actorId(c),
    });

    return success(c, folder, "Folder created", 201);
  } catch (err: any) {
    logError(err, "[EDM] createEDMFolderHandler");
    return error(c, err?.message ?? "Failed to create folder", 500);
  }
};

export const updateEDMFolderHandler = async (c: Context) => {
  try {
    const db = c.get("datastore");
    const folderId = c.req.param("folderId");
    if (!folderId) return error(c, "folderId is required", 400);

    const body = await c.req.json() as { name?: string; parent_id?: string | null };

    if (body.name?.trim()) {
      await EDMRepository.RenameFolder(db, folderId, body.name.trim());
    }

    if (body.parent_id !== undefined) {
      // Throws on a move into the folder's own subtree.
      await EDMRepository.MoveFolder(db, folderId, body.parent_id);
    }

    const folder = await EDMRepository.FindFolderById(db, folderId);
    if (!folder) return error(c, "folder not found", 404);

    return success(c, folder, "Folder updated");
  } catch (err: any) {
    logError(err, "[EDM] updateEDMFolderHandler");
    return error(c, err?.message ?? "Failed to update folder", 400);
  }
};

/**
 * Delete a folder, with an explicit choice about what happens to its contents.
 *
 *   ?mode=orphan            (default) contents move to the top level
 *   ?mode=move&target=<id>  contents move into another folder
 *   ?mode=cascade           contents are DELETED
 *
 * Default is orphan because a folder is an organisational convenience and a
 * template is not: template ids are referenced from promo_code,
 * signup_promo_code, curated_events and member_offers, so tidying up folders
 * must never be able to break live mail by accident.
 *
 * cascade exists because sometimes a whole campaign really is being thrown
 * away, but it deletes templates in the entire subtree and is irreversible —
 * the caller must ask for it by name.
 */
export const deleteEDMFolderHandler = async (c: Context) => {
  try {
    const db = c.get("datastore");
    const folderId = c.req.param("folderId");
    if (!folderId) return error(c, "folderId is required", 400);

    const folder = await EDMRepository.FindFolderById(db, folderId);
    if (!folder) return error(c, "folder not found", 404);

    const mode = (c.req.query("mode") ?? "orphan").toLowerCase();
    const target = c.req.query("target") ?? null;

    if (mode === "move") {
      if (!target) return error(c, "target folder is required when mode=move", 400);
      if (target === folderId) return error(c, "cannot move a folder into itself", 400);

      const destination = await EDMRepository.FindFolderById(db, target);
      if (!destination) return error(c, "target folder not found", 404);

      // Moving into your own descendant would detach the branch from the root.
      const subtree = await EDMRepository.CollectSubtreeFolderIds(db, folderId);
      if (subtree.includes(target)) {
        return error(c, "cannot move contents into a subfolder of the folder being deleted", 400);
      }

      const moved = await EDMRepository.MoveFolderContents(db, folderId, target);
      await EDMRepository.DeleteFolder(db, folderId);
      return success(
        c,
        { mode, moved },
        `Folder deleted — ${moved.templates} template(s) and ${moved.folders} folder(s) moved to "${destination.name}"`,
      );
    }

    if (mode === "cascade") {
      const templateIds = await EDMRepository.CollectSubtreeTemplateIds(db, folderId);

      // Through the service, not the repository: it also removes the stored
      // template objects. Images are deliberately left — mail already in
      // inboxes still fetches those URLs.
      const failed: string[] = [];
      for (const id of templateIds) {
        const res = await EDMService.getInstance().deleteTemplate(db, id);
        if (!res.success) failed.push(id);
      }

      const folderIds = await EDMRepository.CollectSubtreeFolderIds(db, folderId);

      // Images stored under each folder's prefix. Irreversible and it reaches
      // past this console: a URL under these prefixes may already be embedded
      // in mail delivered months ago, which cannot be recalled or repaired.
      let deletedAssets = 0;
      for (const id of folderIds) {
        try {
          const segments = await EDMRepository.FolderNamePath(db, id);
          if (segments.length > 0) {
            deletedAssets += await EDMStorage().deleteAssetsByFolder(segments.join("/"));
          }
        } catch (err) {
          // A storage failure must not leave the delete half-done; folder rows
          // still go and the orphaned objects are reported instead.
          logError(err, `[EDM] Failed deleting assets for folder ${id}`);
        }
      }

      // Deepest-first, so a parent is never removed before its children.
      for (const id of folderIds.reverse()) {
        await EDMRepository.DeleteFolder(db, id);
      }

      return success(
        c,
        { mode, deletedTemplates: templateIds.length - failed.length, deletedAssets, failed },
        `Folder deleted with ${templateIds.length - failed.length} template(s) and ${deletedAssets} image(s)` +
          (failed.length ? `, ${failed.length} could not be deleted` : ""),
      );
    }

    await EDMRepository.DeleteFolder(db, folderId);
    return success(c, { mode: "orphan" }, "Folder deleted — its contents moved to the top level");
  } catch (err: any) {
    logError(err, "[EDM] deleteEDMFolderHandler");
    return error(c, err?.message ?? "Failed to delete folder", 500);
  }
};

/**
 * One folder's contents, the way a file manager shows a directory: its own
 * subfolders, its own templates, and the images those templates use.
 *
 * `folder_id` omitted means the root. The breadcrumb is returned with it so the
 * client can render the trail without holding the whole folder tree.
 */
export const browseEDMFolderHandler = async (c: Context) => {
  try {
    const db = c.get("datastore");
    const raw = c.req.query("folder_id");
    const folderId = raw && raw !== "root" ? raw : null;

    let folder = null;
    const breadcrumb: { id: string; name: string }[] = [];

    if (folderId) {
      folder = await EDMRepository.FindFolderById(db, folderId);
      if (!folder) return error(c, "folder not found", 404);

      // Walk to the root. `seen` guards against a parent cycle, which the move
      // endpoint refuses to create but a manual DB edit could.
      const all = await EDMRepository.ListFolders(db);
      const byId = new Map(all.map((f) => [f.id, f]));
      const seen = new Set<string>();
      let cursor: typeof folder | undefined = folder;
      while (cursor && !seen.has(cursor.id)) {
        seen.add(cursor.id);
        breadcrumb.unshift({ id: cursor.id, name: cursor.name });
        cursor = cursor.parent_id ? byId.get(cursor.parent_id) : undefined;
      }
    }

    const folderPath = folderId
      ? (await EDMRepository.FolderNamePath(db, folderId)).join("/")
      : "";

    const [folders, templates, manifestAssets, storedAssets] = await Promise.all([
      EDMRepository.ListChildFolders(db, folderId),
      EDMRepository.ListTemplatesInFolder(db, folderId),
      EDMRepository.ListFolderAssets(db, folderId),
      // Objects actually in the bucket under this folder's prefix. Catches
      // images uploaded before any template references them, which no manifest
      // knows about.
      EDMStorage().listAssetsByFolder(folderPath).catch(() => []),
    ]);

    // Union, keyed by storage path. Manifest entries win because they carry the
    // usedBy list; bucket-only objects come through as unreferenced.
    const byPath = new Map<string, (typeof manifestAssets)[number]>();
    for (const a of storedAssets) {
      byPath.set(a.path, {
        original: a.path.split("/").pop() ?? a.path,
        path: a.path,
        url: a.url,
        sha256: "",
        usedBy: [],
      });
    }
    for (const a of manifestAssets) byPath.set(a.path, a);

    return success(c, {
      folder,
      breadcrumb,
      folders,
      templates,
      assets: [...byPath.values()],
    });
  } catch (err: any) {
    logError(err, "[EDM] browseEDMFolderHandler");
    return error(c, err?.message ?? "Failed to browse folder", 500);
  }
};

// ── preflight ───────────────────────────────────────────────────────────────

/**
 * Report on the images in a block of html without storing anything. Drives the
 * wizard's preflight step, and is safe to call on every keystroke-idle.
 */
export const scanEDMImagesHandler = async (c: Context) => {
  try {
    const body = await c.req.json() as { html?: string };
    if (typeof body.html !== "string") return error(c, "html is required", 400);

    return success(c, {
      ...scanImages(body.html),
      // Block helpers we do not implement. Additive to the image report rather
      // than a nested shape, so existing callers keep working unchanged.
      //
      // Worth surfacing next to broken images because it is the same class of
      // problem: something that looks fine in the editor and is wrong in the
      // inbox. An argument-taking unknown helper makes the send fail outright;
      // an argument-free one renders nothing at all, silently.
      helpers: findUnknownBlockHelpers(body.html),
    });
  } catch (err: any) {
    logError(err, "[EDM] scanEDMImagesHandler");
    return error(c, err?.message ?? "Failed to scan html", 500);
  }
};

// ── assets ──────────────────────────────────────────────────────────────────

/**
 * Upload one image and get back its permanent public URL.
 *
 * Content-addressed and never deleted — see edm-storage.putAsset for why that
 * is a hard rule rather than a default.
 */
export const uploadEDMAssetHandler = async (c: Context) => {
  try {
    const contentType = c.req.header("content-type") ?? "";
    if (!contentType.toLowerCase().includes("multipart/form-data")) {
      return error(c, "Upload must be multipart/form-data.", 415);
    }

    const body = await c.req.parseBody();
    const file = body["image"];
    if (!(file instanceof File)) {
      return error(c, "Expected an image in the 'image' field.", 400);
    }

    const ext = extensionOf(file.name);
    if (!IMAGE_EXTENSIONS.has(ext)) {
      return error(
        c,
        `Image must be one of: ${[...IMAGE_EXTENSIONS].join(", ")}.`,
        415,
      );
    }

    // Optional: places the object under the folder's prefix so a folder's
    // images can later be deleted as a unit.
    const folderIdRaw = body["folder_id"];
    let folderPath: string | undefined;
    if (typeof folderIdRaw === "string" && folderIdRaw) {
      const db = c.get("datastore");
      const segments = await EDMRepository.FolderNamePath(db, folderIdRaw);
      if (segments.length > 0) folderPath = segments.join("/");
    }

    const entry = await uploadEDMAsset(
      Buffer.from(await file.arrayBuffer()),
      file.name,
      file.type,
      folderPath,
    );

    return success(c, entry, "Image uploaded", 201);
  } catch (err: any) {
    logError(err, "[EDM] uploadEDMAssetHandler");
    return error(c, err?.message ?? "Failed to upload image", 500);
  }
};

/**
 * Delete one image.
 *
 * Reports which templates still reference it rather than refusing outright —
 * the caller may be removing an image precisely because it is unused, and only
 * they can judge whether a remaining reference matters. Refusing would also be
 * incomplete: the link is a URL inside html, so a template edited outside this
 * console could reference it without any manifest saying so.
 */
export const deleteEDMAssetHandler = async (c: Context) => {
  try {
    const db = c.get("datastore");
    const assetPath = c.req.query("path");
    if (!assetPath) return error(c, "path is required", 400);

    const users = await EDMRepository.FindTemplatesUsingAsset(db, assetPath);

    await EDMStorage().deleteAsset(assetPath);

    return success(
      c,
      { path: assetPath, wasUsedBy: users },
      users.length > 0
        ? `Image deleted — it was referenced by ${users.length} template(s), which will now show a broken image`
        : "Image deleted",
    );
  } catch (err: any) {
    logError(err, "[EDM] deleteEDMAssetHandler");
    return error(c, err?.message ?? "Failed to delete image", 500);
  }
};

/**
 * Delete every image in one folder's images/ subfolder.
 *
 * Irreversible and it reaches past this console: any of these URLs may already
 * be embedded in mail delivered months ago, which cannot be recalled or
 * repaired. Templates are untouched — only the images go.
 */
export const deleteEDMFolderImagesHandler = async (c: Context) => {
  try {
    const db = c.get("datastore");
    const folderId = c.req.param("folderId");
    if (!folderId) return error(c, "folderId is required", 400);

    const folder = await EDMRepository.FindFolderById(db, folderId);
    if (!folder) return error(c, "folder not found", 404);

    const segments = await EDMRepository.FolderNamePath(db, folderId);
    if (segments.length === 0) return error(c, "could not resolve folder path", 500);

    const deleted = await EDMStorage().deleteAssetsByFolder(segments.join("/"), {
      imagesOnly: true,
    });

    return success(
      c,
      { folderId, deleted },
      `Deleted ${deleted} image(s) from "${folder.name}"`,
    );
  } catch (err: any) {
    logError(err, "[EDM] deleteEDMFolderImagesHandler");
    return error(c, err?.message ?? "Failed to delete folder images", 500);
  }
};

/**
 * Resolve html that is already in hand against images uploaded separately —
 * the wizard's step-4 loop, after the author has supplied what was missing.
 */
export const resolveEDMHtmlHandler = async (c: Context) => {
  try {
    const body = await c.req.json() as { html?: string; extract_data_uris?: boolean };
    if (typeof body.html !== "string") return error(c, "html is required", 400);

    const result = await resolveEDMAssets(body.html, [], {
      extractDataUris: body.extract_data_uris ?? true,
    });

    return success(c, result);
  } catch (err: any) {
    logError(err, "[EDM] resolveEDMHtmlHandler");
    return error(c, err?.message ?? "Failed to resolve html", 500);
  }
};

/**
 * The wizard's main entry point: take the zip a designer delivered
 * (index.html + images/), upload every referenced image, and hand back html
 * whose src attributes are all public https URLs.
 *
 * Stores nothing as a template — the caller reviews the result and then POSTs
 * it to the normal create endpoint. That keeps import non-destructive and
 * repeatable.
 */
export const importEDMBundleHandler = async (c: Context) => {
  try {
    const contentType = c.req.header("content-type") ?? "";
    if (!contentType.toLowerCase().includes("multipart/form-data")) {
      return error(c, "Upload must be multipart/form-data.", 415);
    }

    const body = await c.req.parseBody();
    const file = body["bundle"];
    if (!(file instanceof File)) {
      return error(c, "Expected a zip or .html in the 'bundle' field.", 400);
    }

    const buffer = Buffer.from(await file.arrayBuffer());
    if (buffer.length > EDM_BUNDLE_CONTRACT.MAX_TOTAL_UNZIPPED_BYTES) {
      return error(c, "File is too large.", 413);
    }

    // A bare .html is the other half of "single also" — no archive, no images
    // of its own. Anything it references relatively comes back as unresolved
    // and gets supplied through the wizard's fix-up step.
    const isHtml = ["html", "htm"].includes(extensionOf(file.name));

    let bundle: { htmlPath: string; html: string; images: never[] } | null = null;
    let warnings: string[] = [];
    let htmlCount = 1;
    let otherHtmlPaths: string[] = [];

    if (isHtml) {
      if (buffer.length > EDM_BUNDLE_CONTRACT.MAX_HTML_BYTES) {
        return error(c, "HTML file is too large.", 413);
      }
      bundle = { htmlPath: file.name, html: buffer.toString("utf-8"), images: [] };
    } else {
      const parsed = await readEDMBundle(buffer);
      if (!parsed.ok) {
        return c.json(
          { success: false, message: "Invalid EDM bundle", errors: parsed.errors },
          422,
        );
      }
      bundle = parsed.bundle as never;
      warnings = parsed.warnings;

      // How many templates the archive actually holds. Single import picks one
      // on purpose, but the caller needs to know that up front rather than
      // discovering it in a warning after the fact — a designer handing over a
      // whole campaign expects all of them.
      const all = await readEDMBundleEntries(buffer);
      if (all.ok) {
        htmlCount = all.entries.length;
        otherHtmlPaths = all.entries
          .map((e) => e.htmlPath)
          .filter((path) => path !== parsed.bundle.htmlPath);
      }
    }

    const resolved = await resolveEDMAssets(bundle!.html, bundle!.images, {
      baseDir: bundle!.htmlPath.includes("/")
        ? bundle!.htmlPath.slice(0, bundle!.htmlPath.lastIndexOf("/"))
        : "",
    });

    return success(
      c,
      {
        html_path: bundle!.htmlPath,
        html: resolved.html,
        manifest: resolved.manifest,
        unresolved: resolved.unresolved,
        report: resolved.report,
        warnings,
        image_count: bundle!.images.length,
        suggested_subject: extractSubject(bundle!.html),
        html_count: htmlCount,
        other_html_paths: otherHtmlPaths,
      },
      resolved.unresolved.length > 0
        ? `Imported with ${resolved.unresolved.length} unresolved image(s)`
        : "Bundle imported",
    );
  } catch (err: any) {
    logError(err, "[EDM] importEDMBundleHandler");
    return error(c, err?.message ?? "Failed to import bundle", 500);
  }
};

/**
 * Bulk import: one zip holding many EDMs, each in its own directory with its
 * own images folder.
 *
 *   campaign/welcome/index.html   + campaign/welcome/images/…
 *   campaign/reminder/index.html  + campaign/reminder/images/…
 *
 * Mirrors that layout into edm_folders and creates one template per html.
 * Unlike the single import this COMMITS — reviewing twenty templates one at a
 * time in a modal is not a workflow. What keeps it safe is that any entry with
 * an unresolved image is created unpublished, so it cannot be sent until
 * someone fixes it.
 */
export const batchImportEDMHandler = async (c: Context) => {
  try {
    const db = c.get("datastore");

    const contentType = c.req.header("content-type") ?? "";
    if (!contentType.toLowerCase().includes("multipart/form-data")) {
      return error(c, "Upload must be multipart/form-data.", 415);
    }

    const body = await c.req.parseBody();
    const file = body["bundle"];
    if (!(file instanceof File)) {
      return error(c, "Expected a zip in the 'bundle' field.", 400);
    }

    const buffer = Buffer.from(await file.arrayBuffer());
    if (buffer.length > EDM_BUNDLE_CONTRACT.MAX_TOTAL_UNZIPPED_BYTES) {
      return error(c, "Archive is too large.", 413);
    }

    const parentFolderId =
      typeof body["parent_folder_id"] === "string" && body["parent_folder_id"]
        ? (body["parent_folder_id"] as string)
        : null;

    // Opt-in: wrap the whole import in a folder named after the zip, so a
    // second import of an unrelated archive cannot merge into the first.
    const rootFolderName =
      typeof body["root_folder_name"] === "string" && body["root_folder_name"]
        ? (body["root_folder_name"] as string)
        : null;

    if (parentFolderId) {
      const parent = await EDMRepository.FindFolderById(db, parentFolderId);
      if (!parent) return error(c, "parent folder not found", 404);
    }

    const outcome = await batchImportEDMBundle(db, buffer, {
      parentFolderId,
      rootFolderName,
      createdBy: actorId(c),
    });

    if (!outcome.ok) {
      return c.json(
        { success: false, message: "Invalid EDM bundle", errors: outcome.errors },
        422,
      );
    }

    const { result } = outcome;
    return success(
      c,
      result,
      `Imported ${result.created} template(s)` +
        (result.needsAttention
          ? `, ${result.needsAttention} left unpublished pending missing images`
          : "") +
        (result.failed ? `, ${result.failed} failed` : ""),
    );
  } catch (err: any) {
    logError(err, "[EDM] batchImportEDMHandler");
    return error(c, err?.message ?? "Failed to batch import", 500);
  }
};

// ── storage target ──────────────────────────────────────────────────────────

/**
 * Where new EDMs are stored.
 *
 * Read is open to anyone who can see the EDM section: it changes what pressing
 * "create" does, so hiding it from the people doing the creating would be
 * perverse. Writing is super-admin only — one setting, shared by everybody.
 */
export const getEDMStorageTargetHandler = async (c: Context) => {
  try {
    const db = c.get("datastore");
    const { target, source } = await resolveStorageTarget(db);

    return success(c, {
      target,
      // "env" means nobody has chosen yet and the server default is in force.
      source,
      envDefault:
        (process.env.EDM_STORAGE_MODE ?? "").trim().toLowerCase() === "sendgrid"
          ? "sendgrid"
          : "gcs",
      canChange: c.get("isAdminConsoleSuperAdmin") === true,
    });
  } catch (err: any) {
    logError(err, "[EDM] getEDMStorageTarget failed");
    return error(c, err?.message ?? "Failed to read the storage setting", 500);
  }
};

export const putEDMStorageTargetHandler = async (c: Context) => {
  try {
    if (c.get("isAdminConsoleSuperAdmin") !== true) {
      return error(
        c,
        "Only a super admin can change where EDMs are stored.",
        403,
      );
    }

    const body = (await c.req.json().catch(() => null)) as
      | { target?: string }
      | null;
    const target = (body?.target ?? "").trim().toLowerCase();

    if (target !== "gcs" && target !== "sendgrid") {
      return error(c, 'target must be "gcs" or "sendgrid".', 400);
    }

    const repo = new ConsoleSettingsRepository(c.get("datastore"));
    const updatedBy = String(c.get("adminEmail") ?? c.get("consoleUserId") ?? "");
    await repo.put(EDM_STORAGE_SETTING_KEY, { target }, updatedBy || null);

    return success(
      c,
      { target, source: "console" as const },
      target === "sendgrid"
        ? "New EDMs will be created in SendGrid. Templates already in GCS stay there and keep being edited there."
        : "New EDMs will be created in GCS. Templates that only exist in SendGrid stay there and keep being edited there.",
    );
  } catch (err: any) {
    logError(err, "[EDM] putEDMStorageTarget failed");
    return error(c, err?.message ?? "Failed to save the storage setting", 500);
  }
};
