"use client";

import {
  Alert,
  Badge,
  Box,
  Button,
  Code,
  Divider,
  FileButton,
  Group,
  List,
  Loader,
  Paper,
  Progress,
  ScrollArea,
  SegmentedControl,
  Select,
  Stack,
  Stepper,
  Text,
  TextInput,
  ThemeIcon,
  Title,
} from "@mantine/core";
import { Dropzone } from "@mantine/dropzone";
import { notifications } from "@mantine/notifications";
import {
  IconAlertTriangle,
  IconCheck,
  IconFileZip,
  IconFolders,
  IconPhotoUp,
  IconUpload,
  IconX,
} from "@tabler/icons-react";
import React, { useCallback, useMemo, useState } from "react";
import {
  importEDMBundle,
  importEDMBundleBatch,
  uploadEDMAsset,
  createEDMTemplate,
} from "@/lib/features/edm/action";
import { folderSelectOptions } from "@/lib/features/edm/folderTree";
import type {
  BatchImportResult,
  EDMAssetEntry,
  EDMFolder,
  EDMImportResult,
  EDMTemplate,
} from "@/lib/features/edm/types";

interface Props {
  /** Called after a successful import so the page can navigate away. */
  onClose: () => void;
  folders: EDMFolder[];
  onCreated: (template: EDMTemplate) => void;
  /** Batch import creates templates and folders server-side, so the list page
   *  has to refetch rather than splice in a single row. */
  onBatchImported: () => void;
}

type Mode = "single" | "batch";

/** Mirrors the server's contract in edm-import.ts. */
const EDM_MAX_UPLOAD_BYTES = 25 * 1024 * 1024;

const IMAGE_MIME = [
  "image/png",
  "image/jpeg",
  "image/gif",
  "image/webp",
  "image/svg+xml",
  "image/x-icon",
];

/**
 * Import an EDM from the zip a designer delivered.
 *
 * Four steps: drop the zip → review what the server found → supply any images
 * it could not resolve → name it and save.
 *
 * Step 3 exists because a relative <img src> with no matching file in the zip
 * is not a warning — it is a hole in every inbox that receives the email. The
 * save button stays disabled until every one of them is resolved.
 */
export function EDMImportPanel({
  onClose,
  folders,
  onCreated,
  onBatchImported,
}: Props) {
  const [mode, setMode] = useState<Mode>("single");
  const [step, setStep] = useState(0);
  const [busy, setBusy] = useState(false);
  const [progress, setProgress] = useState(0);

  const [result, setResult] = useState<EDMImportResult | null>(null);
  const [bundleErrors, setBundleErrors] = useState<string[]>([]);

  // Batch mode
  const [batchParentId, setBatchParentId] = useState<string | null>(null);
  const [batchRootName, setBatchRootName] = useState("");
  const [batchResult, setBatchResult] = useState<BatchImportResult | null>(null);

  // Missing path → the asset uploaded to stand in for it.
  const [fixes, setFixes] = useState<Record<string, EDMAssetEntry>>({});
  const [uploadingFor, setUploadingFor] = useState<string | null>(null);
  const [bulkBusy, setBulkBusy] = useState(false);

  const [name, setName] = useState("");
  const [subject, setSubject] = useState("");
  const [folderId, setFolderId] = useState<string | null>(null);

  const reset = useCallback(() => {
    setMode("single");
    setStep(0);
    setBusy(false);
    setProgress(0);
    setResult(null);
    setBundleErrors([]);
    setFixes({});
    setUploadingFor(null);
    setName("");
    setSubject("");
    setFolderId(null);
    setBatchParentId(null);
    setBatchRootName("");
    setBatchResult(null);
  }, []);

  const close = () => {
    reset();
    onClose();
  };

  const stillMissing = useMemo(
    () => (result?.unresolved ?? []).filter((u) => !fixes[u]),
    [result, fixes],
  );

  // ── step 1: upload the zip ────────────────────────────────────────────────

  const handleZip = async (file: File | null) => {
    if (!file) return;

    setBusy(true);
    setProgress(0);
    setBundleErrors([]);

    const res = await importEDMBundle(file, setProgress);
    setBusy(false);

    if (!res.success || !res.data) {
      setBundleErrors(res.errors ?? [res.message || "Import failed"]);
      return;
    }

    setResult(res.data);
    // Seed name and subject so the last step is usually just a confirm.
    setName(file.name.replace(/\.(zip|html?)$/i, "").replace(/[-_]+/g, " ").trim());
    setSubject(res.data.suggested_subject ?? "");
    setStep(1);
  };

  const handleBatchZip = async (file: File | null) => {
    if (!file) return;

    setBusy(true);
    setProgress(0);
    setBundleErrors([]);

    const res = await importEDMBundleBatch(
      file,
      {
        parentFolderId: batchParentId,
        rootFolderName:
          batchRootName.trim() ||
          file.name.replace(/\.zip$/i, "").replace(/[-_]+/g, " ").trim(),
      },
      setProgress,
    );
    setBusy(false);

    if (!res.success || !res.data) {
      setBundleErrors(res.errors ?? [res.message || "Batch import failed"]);
      return;
    }

    setBatchResult(res.data);
    onBatchImported();
  };

  // ── step 3: supply a missing image ────────────────────────────────────────

  /**
   * Bulk fix-up: upload many images at once and match them to missing
   * references by filename.
   *
   * A designer's missing files are almost always sitting in one folder with the
   * names the html already uses, so asking for them one at a time is twenty
   * clicks to convey nothing. Matching is on basename, case-insensitively,
   * because "images/hero.png" and a picked "Hero.png" are the same file to
   * everyone except a string comparison.
   *
   * One upload can satisfy several references — two templates both pointing at
   * a `logo.png` in different folders want the same bytes — so every reference
   * whose basename matches is filled, not just the first.
   */
  const handleBulkFix = async (files: File[]) => {
    if (!result || files.length === 0) return;

    const outstanding = result.unresolved.filter((u) => !fixes[u]);
    const wanted = new Map<string, string[]>();
    for (const ref of outstanding) {
      const base = (ref.split(/[?#]/)[0] ?? "").split("/").pop()?.toLowerCase() ?? "";
      if (!base) continue;
      wanted.set(base, [...(wanted.get(base) ?? []), ref]);
    }

    setBulkBusy(true);
    const applied: Record<string, EDMAssetEntry> = {};
    const unmatched: string[] = [];
    let failed = 0;

    try {
      for (const file of files) {
        const targets = wanted.get(file.name.toLowerCase());
        if (!targets?.length) {
          unmatched.push(file.name);
          continue;
        }

        const res = await uploadEDMAsset(file, folderId);
        if (!res.success || !res.data) {
          failed++;
          continue;
        }
        for (const ref of targets) applied[ref] = res.data;
      }

      if (Object.keys(applied).length > 0) {
        setFixes((prev) => ({ ...prev, ...applied }));
      }

      const filled = Object.keys(applied).length;
      const remaining = outstanding.length - filled;

      notifications.show({
        color: filled > 0 ? "green" : "orange",
        message:
          `Matched ${filled} of ${outstanding.length} missing image(s).` +
          (unmatched.length ? ` ${unmatched.length} file(s) matched nothing: ${unmatched.slice(0, 3).join(", ")}${unmatched.length > 3 ? "…" : ""}.` : "") +
          (failed ? ` ${failed} upload(s) failed.` : "") +
          (remaining > 0 ? ` ${remaining} still missing.` : ""),
      });
    } finally {
      setBulkBusy(false);
    }
  };

  const handleFix = async (missingPath: string, file: File | null) => {
    if (!file) return;

    setUploadingFor(missingPath);
    const res = await uploadEDMAsset(file, folderId);
    setUploadingFor(null);

    if (!res.success || !res.data) {
      notifications.show({
        color: "red",
        message: res.message || `Failed uploading ${file.name}`,
      });
      return;
    }

    setFixes((prev) => ({ ...prev, [missingPath]: res.data }));
  };

  /**
   * Patch the uploaded URLs into the html.
   *
   * Plain string replacement of the original reference. The server already
   * rewrote everything it could resolve, so what is left here are literal
   * relative paths that appear verbatim in the source.
   */
  const patchedHtml = useMemo(() => {
    if (!result) return "";
    let html = result.html;
    for (const [missing, asset] of Object.entries(fixes)) {
      html = html.split(missing).join(asset.url);
    }
    return html;
  }, [result, fixes]);

  // ── step 4: create ────────────────────────────────────────────────────────

  const handleCreate = async () => {
    if (!result) return;
    if (!name.trim() || !subject.trim()) {
      notifications.show({ color: "red", message: "Name and subject are required" });
      return;
    }

    setBusy(true);
    const res = await createEDMTemplate({
      name: name.trim(),
      subject: subject.trim(),
      html_content: patchedHtml,
      folder_id: folderId,
      active: 1,
    } as never);
    setBusy(false);

    if (!res.success || !res.data) {
      notifications.show({ color: "red", message: res.message || "Failed to create template" });
      return;
    }

    notifications.show({ color: "green", message: "Template created" });
    onCreated(res.data);
    close();
  };

  const folderOptions = folderSelectOptions(folders);

  return (
    <Box>
      <SegmentedControl
        fullWidth
        mb="md"
        maw={520}
        value={mode}
        onChange={(v) => { reset(); setMode(v as Mode); }}
        data={[
          { value: "single", label: "One template" },
          { value: "batch", label: "Many (folder per template)" },
        ]}
      />

      {mode === "batch" ? (
        <BatchPanel
          folders={folders}
          busy={busy}
          progress={progress}
          parentId={batchParentId}
          setParentId={setBatchParentId}
          rootName={batchRootName}
          setRootName={setBatchRootName}
          errors={bundleErrors}
          result={batchResult}
          onPick={handleBatchZip}
          onDone={close}
        />
      ) : (
      <>
      <Stepper active={step} onStepClick={setStep} size="sm" mb="lg">
        <Stepper.Step label="Upload" description="index.html + images/" allowStepSelect={false} />
        <Stepper.Step label="Review" description="what we found" allowStepSelect={!!result} />
        <Stepper.Step
          label="Fix images"
          description={stillMissing.length ? `${stillMissing.length} missing` : "all resolved"}
          allowStepSelect={!!result}
          color={stillMissing.length ? "red" : undefined}
        />
        <Stepper.Step label="Save" allowStepSelect={!!result && stillMissing.length === 0} />
      </Stepper>

      {/* ── step 1 ── */}
      {step === 0 && (
        <Stack align="center" py="xl">
          <ThemeIcon size={64} radius="xl" variant="light">
            <IconFileZip size={32} />
          </ThemeIcon>
          <Text ta="center" c="dimmed" maw={440}>
            Upload the zip exactly as your designer delivered it. The html can be
            called anything — <Code>index.html</Code>, <Code>welcome.html</Code>,
            whatever they sent. Images can sit in an <Code>images/</Code> folder
            or beside the html; every relative path is rewritten automatically.
          </Text>
          <Text ta="center" size="xs" c="dimmed" maw={440}>
            One template only. If the zip holds several html files this imports a
            single one — a root <Code>index.html</Code> if there is one, otherwise
            the shallowest — and tells you what it skipped. To bring in all of
            them, use <strong>Many</strong> above.
          </Text>

          <Dropzone
            onDrop={(files) => handleZip(files[0] ?? null)}
            onReject={(rejections) =>
              notifications.show({
                color: "red",
                message:
                  rejections[0]?.errors?.[0]?.message ??
                  "That file type is not accepted — drop a .zip or a .html file.",
              })
            }
            loading={busy}
            maxSize={EDM_MAX_UPLOAD_BYTES}
            // Browsers disagree on the MIME type for .zip, and some send
            // application/octet-stream. Accepting the extension too avoids
            // rejecting a perfectly good archive on a technicality.
            accept={[
              "application/zip",
              "application/x-zip-compressed",
              "application/octet-stream",
              "text/html",
              ".zip",
              ".html",
              ".htm",
            ]}
            w="100%"
            maw={520}
          >
            <Stack align="center" gap={4} mih={120} justify="center">
              <Dropzone.Accept>
                <IconUpload size={34} color="var(--mantine-color-blue-6)" />
              </Dropzone.Accept>
              <Dropzone.Reject>
                <IconX size={34} color="var(--mantine-color-red-6)" />
              </Dropzone.Reject>
              <Dropzone.Idle>
                <IconFileZip size={34} opacity={0.5} />
              </Dropzone.Idle>
              <Text size="sm" fw={500}>Drop the zip here, or click to choose</Text>
              <Text size="xs" c="dimmed">.zip or a single .html · up to 25 MB</Text>
            </Stack>
          </Dropzone>

          {busy && <Progress value={progress} w="100%" maw={520} striped animated />}

          {bundleErrors.length > 0 && (
            <Alert color="red" icon={<IconX size={16} />} title="Could not read that zip" w="100%">
              <List size="sm">
                {bundleErrors.map((e, i) => (
                  <List.Item key={i}>{e}</List.Item>
                ))}
              </List>
            </Alert>
          )}
        </Stack>
      )}

      {/* ── step 2 ── */}
      {step === 1 && result && (
        <Stack>
          {result.html_count > 1 && (
            <Alert
              color="orange"
              icon={<IconAlertTriangle size={16} />}
              title={`This zip holds ${result.html_count} templates — only 1 will be imported`}
            >
              <Text size="sm" mb="xs">
                “One template” mode takes a single html and ignores the rest.
                Switch to “Many” and every one is imported, each into its own
                folder, with its own images.
              </Text>
              <Text size="xs" c="dimmed" mb="sm">
                Ignored: {result.other_html_paths.slice(0, 5).join(", ")}
                {result.other_html_paths.length > 5
                  ? ` +${result.other_html_paths.length - 5} more`
                  : ""}
              </Text>
              <Button
                size="xs"
                color="orange"
                leftSection={<IconFolders size={14} />}
                onClick={() => { reset(); setMode("batch"); }}
              >
                Import all {result.html_count} instead
              </Button>
            </Alert>
          )}

          <Group>
            <Badge color="blue" variant="light">{result.html_path}</Badge>
            <Badge color="grape" variant="light">{result.image_count} image files</Badge>
            <Badge color="teal" variant="light">{result.manifest.length} uploaded</Badge>
            {result.report.dataUris > 0 && (
              <Badge color="orange" variant="light">
                {result.report.dataUris} inline extracted
              </Badge>
            )}
          </Group>

          {result.warnings.length > 0 && (
            <Alert color="yellow" icon={<IconAlertTriangle size={16} />} title="Notes">
              <List size="sm">
                {result.warnings.map((w, i) => (
                  <List.Item key={i}>{w}</List.Item>
                ))}
              </List>
            </Alert>
          )}

          {result.report.insecure.length > 0 && (
            <Alert color="red" title="Insecure image URLs">
              <Text size="sm" mb="xs">
                These load over http. Most mail clients block them or show a warning —
                host them over https instead.
              </Text>
              <List size="sm">
                {result.report.insecure.map((r, i) => (
                  <List.Item key={i}>
                    <Code>{r.url}</Code> <Text span c="dimmed" size="xs">({r.kind})</Text>
                  </List.Item>
                ))}
              </List>
            </Alert>
          )}

          {result.unresolved.length > 0 ? (
            <Alert color="red" icon={<IconAlertTriangle size={16} />} title={`${result.unresolved.length} image(s) missing from the zip`}>
              These are referenced by the html but no matching file was in the archive.
              Supply them on the next step.
            </Alert>
          ) : (
            <Alert color="green" icon={<IconCheck size={16} />} title="Every image resolved">
              All references now point at permanent public URLs.
            </Alert>
          )}

          {result.report.external.length > 0 && (
            <Box>
              <Text size="sm" fw={500} mb={4}>
                Left as-is ({result.report.external.length} external https)
              </Text>
              <ScrollArea.Autosize mah={120}>
                <List size="xs" c="dimmed">
                  {result.report.external.map((u, i) => (
                    <List.Item key={i}>{u}</List.Item>
                  ))}
                </List>
              </ScrollArea.Autosize>
            </Box>
          )}

          <Group justify="flex-end">
            <Button variant="default" onClick={() => setStep(0)}>Back</Button>
            <Button onClick={() => setStep(result.unresolved.length > 0 ? 2 : 3)}>
              Continue
            </Button>
          </Group>
        </Stack>
      )}

      {/* ── step 3 ── */}
      {step === 2 && result && (
        <Stack>
          <Paper withBorder p="md" radius="md" bg="var(--mantine-color-gray-0)">
            <Group justify="space-between" wrap="nowrap">
              <Box>
                <Text size="sm" fw={500}>Upload all the missing images at once</Text>
                <Text size="xs" c="dimmed">
                  Select the whole folder of images — each is matched to a reference
                  by filename, ignoring case. Anything that matches nothing is
                  reported and skipped.
                </Text>
              </Box>
              <Dropzone
                onDrop={handleBulkFix}
                loading={bulkBusy}
                accept={IMAGE_MIME}
                p="xs"
                style={{ flexShrink: 0, minWidth: 190 }}
              >
                <Group gap={6} justify="center" wrap="nowrap">
                  <IconPhotoUp size={16} />
                  <Text size="sm">Drop images or click</Text>
                </Group>
              </Dropzone>
            </Group>
          </Paper>

          <Text size="xs" c="dimmed">
            Or upload them individually below. There the file is assigned to that one
            reference, so the replacement can have any filename.
          </Text>

          <Stack gap="xs">
            {result.unresolved.map((missing) => {
              const fixed = fixes[missing];
              return (
                <Paper key={missing} withBorder p="sm">
                  <Group justify="space-between" wrap="nowrap">
                    <Box style={{ minWidth: 0 }}>
                      <Code>{missing}</Code>
                      {fixed && (
                        <Text size="xs" c="teal" truncate>
                          → {fixed.url}
                        </Text>
                      )}
                    </Box>
                    <Group gap="xs" wrap="nowrap">
                      {fixed ? (
                        <ThemeIcon color="teal" variant="light" radius="xl">
                          <IconCheck size={16} />
                        </ThemeIcon>
                      ) : uploadingFor === missing ? (
                        <Loader size="sm" />
                      ) : (
                        <FileButton
                          onChange={(f) => handleFix(missing, f)}
                          accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml"
                        >
                          {(props) => (
                            <Button
                              {...props}
                              size="xs"
                              variant="light"
                              leftSection={<IconPhotoUp size={14} />}
                            >
                              Upload
                            </Button>
                          )}
                        </FileButton>
                      )}
                    </Group>
                  </Group>
                </Paper>
              );
            })}
          </Stack>

          <Group justify="flex-end">
            <Button variant="default" onClick={() => setStep(1)}>Back</Button>
            <Button disabled={stillMissing.length > 0} onClick={() => setStep(3)}>
              {stillMissing.length > 0
                ? `${stillMissing.length} still missing`
                : "Continue"}
            </Button>
          </Group>
        </Stack>
      )}

      {/* ── step 4 ── */}
      {step === 3 && result && (
        <Stack>
          <TextInput
            label="Template name"
            required
            value={name}
            onChange={(e) => setName(e.currentTarget.value)}
          />
          <TextInput
            label="Subject line"
            required
            description="Handlebars is allowed, e.g. Welcome, {{full_name}}"
            value={subject}
            onChange={(e) => setSubject(e.currentTarget.value)}
          />
          <Select
            label="Folder"
            placeholder="No folder (root)"
            data={folderOptions}
            value={folderId}
            onChange={setFolderId}
            clearable
            searchable
          />

          <Divider my="xs" />

          <Group gap="xs">
            <Badge color="teal" variant="light">
              {result.manifest.length + Object.keys(fixes).length} images hosted
            </Badge>
            <Badge color="gray" variant="light">
              {(patchedHtml.length / 1024).toFixed(0)} KB html
            </Badge>
            {patchedHtml.length > 102 * 1024 && (
              <Badge color="orange" variant="light">
                over Gmail&apos;s 102 KB clip threshold
              </Badge>
            )}
          </Group>

          {patchedHtml.length > 102 * 1024 && (
            <Alert color="orange" icon={<IconAlertTriangle size={16} />}>
              Gmail truncates messages past 102 KB behind a “View entire message”
              link — anything below the cut, usually the unsubscribe footer, is
              hidden by default.
            </Alert>
          )}

          <Group justify="flex-end">
            <Button variant="default" onClick={() => setStep(2)}>Back</Button>
            <Button loading={busy} onClick={handleCreate}>
              Create template
            </Button>
          </Group>
        </Stack>
      )}
      </>
      )}
    </Box>
  );
}

/**
 * Batch import is a single shot, not a stepper: the server creates everything
 * and reports back per entry. There is nothing to review beforehand because
 * reviewing twenty templates in a modal is not a workflow — the safety net is
 * that anything with a broken image is created unpublished.
 */
function BatchPanel({
  folders,
  busy,
  progress,
  parentId,
  setParentId,
  rootName,
  setRootName,
  errors,
  result,
  onPick,
  onDone,
}: {
  folders: EDMFolder[];
  busy: boolean;
  progress: number;
  parentId: string | null;
  setParentId: (v: string | null) => void;
  rootName: string;
  setRootName: (v: string) => void;
  errors: string[];
  result: BatchImportResult | null;
  onPick: (f: File | null) => void;
  onDone: () => void;
}) {
  if (result) {
    return (
      <Stack>
        <Group>
          <Badge color="teal" variant="light">{result.created} created</Badge>
          <Badge color="blue" variant="light">{result.foldersCreated} folders</Badge>
          {result.needsAttention > 0 && (
            <Badge color="orange" variant="light">
              {result.needsAttention} unpublished
            </Badge>
          )}
          {result.failed > 0 && (
            <Badge color="red" variant="light">{result.failed} failed</Badge>
          )}
        </Group>

        {result.needsAttention > 0 && (
          <Alert color="orange" icon={<IconAlertTriangle size={16} />}>
            Templates with a missing image were created but left{" "}
            <strong>unpublished</strong> — they cannot be sent until you open each
            one, supply the image, and save. That is deliberate: an unpublished
            version has nothing for the mail worker to render.
          </Alert>
        )}

        <ScrollArea.Autosize mah={340}>
          <Stack gap="xs">
            {result.entries.map((e) => (
              <Paper key={e.htmlPath} withBorder p="sm">
                <Group justify="space-between" wrap="nowrap" align="flex-start">
                  <Box style={{ minWidth: 0 }}>
                    <Group gap="xs">
                      <Text fw={500} size="sm">{e.name}</Text>
                      {e.folderPath.length > 0 && (
                        <Text size="xs" c="dimmed">{e.folderPath.join(" › ")}</Text>
                      )}
                    </Group>
                    <Text size="xs" c="dimmed" ff="monospace">{e.htmlPath}</Text>
                    {e.unresolved && e.unresolved.length > 0 && (
                      <Text size="xs" c="orange">
                        missing: {e.unresolved.join(", ")}
                      </Text>
                    )}
                    {e.message && (
                      <Text size="xs" c="red">{e.message}</Text>
                    )}
                  </Box>
                  {e.status === "failed" ? (
                    <Badge color="red" variant="light">failed</Badge>
                  ) : e.needsAttention ? (
                    <Badge color="orange" variant="light">unpublished</Badge>
                  ) : (
                    <Badge color="teal" variant="light">published</Badge>
                  )}
                </Group>
              </Paper>
            ))}
          </Stack>
        </ScrollArea.Autosize>

        <Group justify="flex-end">
          <Button onClick={onDone}>Done</Button>
        </Group>
      </Stack>
    );
  }

  return (
    <Stack align="center" py="lg">
      <ThemeIcon size={64} radius="xl" variant="light" color="grape">
        <IconFolders size={32} />
      </ThemeIcon>
      <Text ta="center" c="dimmed" maw={460}>
        Upload one zip containing several EDMs, each in its own folder. The
        folder layout is recreated here and one template is made per html file,
        named after the file (or its folder, for an <Code>index.html</Code>).
        Filenames are up to you.
      </Text>

      <Code block style={{ textAlign: "left" }}>
{`campaign/
  welcome/    index.html + images/
  reminder/   index.html + images/
images/       shared, offered to both`}
      </Code>

      <Stack w="100%" maw={420} gap="xs">
        <Select
          label="Import into"
          placeholder="Top level"
          data={folderSelectOptions(folders)}
          value={parentId}
          onChange={setParentId}
          clearable
          searchable
        />
        <TextInput
          label="Wrap in a folder named"
          placeholder="defaults to the zip's filename"
          description="Keeps a second import of an unrelated archive from merging into this one."
          value={rootName}
          onChange={(e) => setRootName(e.currentTarget.value)}
        />
      </Stack>

      <FileButton onChange={onPick} accept=".zip,application/zip">
        {(props) => (
          <Button {...props} leftSection={<IconUpload size={16} />} loading={busy}>
            Choose zip and import
          </Button>
        )}
      </FileButton>

      {busy && <Progress value={progress} w="60%" striped animated />}

      {errors.length > 0 && (
        <Alert color="red" icon={<IconX size={16} />} title="Could not read that zip" w="100%">
          <List size="sm">
            {errors.map((e, i) => (
              <List.Item key={i}>{e}</List.Item>
            ))}
          </List>
        </Alert>
      )}
    </Stack>
  );
}
