/*
 This file is part of GNU Taler
 (C) 2022-2025 Taler Systems S.A.

 GNU Taler is free software; you can redistribute it and/or modify it under the
 terms of the GNU General Public License as published by the Free Software
 Foundation; either version 3, or (at your option) any later version.

 GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
 A PARTICULAR PURPOSE.  See the GNU General Public License for more details.

 You should have received a copy of the GNU General Public License along with
 GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
 */
import {
  AbsoluteTime,
  CustomerAccountSummary,
  Duration,
  HttpStatusCode,
  Paytos,
  TalerError,
  assertUnreachable,
  opFixedSuccess
} from "@gnu-taler/taler-util";
import {
  Attention,
  ButtonBetter,
  ErrorLoading,
  InputToggle,
  Loading,
  Pagination,
  RouteDefinition,
  useExchangeApiContext,
  useLocalNotificationBetter,
  useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
import { useAmlAccounts } from "../hooks/decisions.js";

import { format } from "date-fns";
import { useEffect, useState } from "preact/hooks";
import csvIcon from "../assets/csv-icon.png";
import xlsIcon from "../assets/excel-icon.png";
import { useOfficer } from "../hooks/officer.js";
import { Profile } from "./Profile.js";

export function AccountList({
  routeToAccountById: caseByIdRoute,
}: {
  routeToAccountById: RouteDefinition<{ cid: string }>;
}): VNode {
  const { i18n } = useTranslationContext();
  const [investigated, setInvestigated] = useState<boolean>();
  const [opened, setOpened] = useState<boolean>();
  const [highRisk, setHighRisk] = useState<boolean>();
  const list = useAmlAccounts({ investigated, open: opened, highRisk });
  const [notification, safeFunctionHandler] = useLocalNotificationBetter();

  if (!list) {
    return <Loading />;
  }
  if (list instanceof TalerError) {
    return <ErrorLoading error={list} />;
  }

  if (list.type === "fail") {
    switch (list.case) {
      case HttpStatusCode.Forbidden:
        return (
          <Fragment>
            <Attention type="danger" title={i18n.str`Operation denied`}>
              <i18n.Translate>
                This account signature is invalid, contact administrator or
                create a new one.
              </i18n.Translate>
            </Attention>
            <Profile />
          </Fragment>
        );
      case HttpStatusCode.NotFound:
        return (
          <Fragment>
            <Attention type="danger" title={i18n.str`Operation denied`}>
              <i18n.Translate>
                The designated AML account is not known, contact administrator
                or create a new one.
              </i18n.Translate>
            </Attention>
            <Profile />
          </Fragment>
        );
      case HttpStatusCode.Conflict:
        return (
          <Fragment>
            <Attention type="danger" title={i18n.str`Operation denied`}>
              <i18n.Translate>
                The designated AML account is not enabled, contact administrator
                or create a new one.
              </i18n.Translate>
            </Attention>
            <Profile />
          </Fragment>
        );
      default:
        assertUnreachable(list);
    }
  }

  const records = list.body;

  const description =
    investigated === undefined
      ? opened
        ? highRisk
          ? i18n.str`High risk accounts. Only with custom rules.`
          : i18n.str`Account with custom rules.`
        : highRisk
          ? i18n.str`High risk accounts`
          : i18n.str`All account known.`
      : investigated
        ? opened
          ? highRisk
            ? i18n.str`High risk accounts under investigation. Only with custom rules.`
            : i18n.str`Accounts under investigation. Only with custom rules.`
          : highRisk
            ? i18n.str`High risk accounts under investigation.`
            : i18n.str`Accounts under investigation.`
        : opened
          ? highRisk
            ? i18n.str`High risk accounts without investigation. Only with custom rules.`
            : i18n.str`Accounts without investigation. Only with custom rules.`
          : highRisk
            ? i18n.str`High risk accounts without investigation.`
            : i18n.str`Accounts without investigation.`;

  const [exported, setExported] = useState<{ content: string; file: string }>();

  const fileDescription =
    investigated === undefined
      ? opened
        ? highRisk
          ? `risky_opened`
          : `opened`
        : highRisk
          ? `risky`
          : ``
      : investigated
        ? opened
          ? highRisk
            ? `investigated_risky_opened`
            : `investigated_opened`
          : highRisk
            ? `investigated_risky`
            : `investigated`
        : opened
          ? highRisk
            ? `not-investigated_risky_opened`
            : `not-investigated_opened`
          : highRisk
            ? `not-investigated_risky`
            : `not-investigated`;

  const time = format(new Date(), "yyyyMMdd_HHmmss");
  const downloadCsv = safeFunctionHandler(convertToCsv, [fields, records]);
  const downloadXls = safeFunctionHandler(convertToXls, [fields, records]);

  downloadCsv.onSuccess = (result) => {
    setExported({
      content: result,
      file: `accounts_${time}_${fileDescription}.csv`,
    });
  };
  downloadXls.onSuccess = (result) => {
    setExported({
      content: result,
      file: `accounts_${time}_${fileDescription}.xls`,
    });
  };
  return (
    <div>
      <div class="sm:flex sm:items-center">
        <div class="px-2 sm:flex-auto">
          <h1 class="text-base font-semibold leading-6 text-gray-900">
            <i18n.Translate>Accounts</i18n.Translate>
          </h1>
          <p class="mt-2 text-sm text-gray-700 w-80">
            <i18n.Translate>{description}</i18n.Translate>
          </p>
          <div class="flex space-x-2 mt-4">
            <i18n.Translate>Export as file</i18n.Translate>
            <ButtonBetter onClick={downloadCsv}>
              <img class="size-6 w-6" src={csvIcon} />
            </ButtonBetter>
            <ButtonBetter onClick={downloadXls}>
              <img class="size-6 w-6" src={xlsIcon} />
            </ButtonBetter>
          </div>
          {!exported ? (
            <div class="h-5 mb-5" />
          ) : (
            <a
              href={
                "data:text/plain;charset=utf-8," +
                encodeURIComponent(exported.content)
              }
              name="save file"
              download={exported.file}
            >
              <Attention
                title={i18n.str`Export completed`}
                onClose={() => setExported(undefined)}
              >
                <i18n.Translate>
                  Click here to save the file in your computer.
                </i18n.Translate>
              </Attention>
            </a>
          )}
        </div>

        <JumpByIdForm
          caseByIdRoute={caseByIdRoute}
          filters={{ investigated, highRisk, opened }}
          onTog={(f, v) => {
            switch (f) {
              case "investigated": {
                setInvestigated(v);
                break;
              }
              case "highRisk": {
                setHighRisk(v);
                break;
              }
              case "open": {
                setOpened(v);
                break;
              }
              default: {
                assertUnreachable(f);
              }
            }
          }}
        />
      </div>
      <div class="mt-8 flow-root">
        <div class="overflow-x-auto">
          {!records.length ? (
            <div>
              <i18n.Translate>No results</i18n.Translate>{" "}
            </div>
          ) : (
            <div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
              <table class="min-w-full divide-y divide-gray-300">
                <thead>
                  <tr>
                    <th
                      scope="col"
                      class="px-3 py-3.5 text-right text-sm font-semibold text-gray-900"
                    >
                      <i18n.Translate>#</i18n.Translate>
                    </th>
                    <th
                      scope="col"
                      class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 w-80"
                    >
                      <i18n.Translate>Account Identification</i18n.Translate>
                    </th>
                    <th
                      scope="col"
                      class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 "
                    >
                      <i18n.Translate>Open time</i18n.Translate>
                    </th>
                    <th
                      scope="col"
                      class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 "
                    >
                      <i18n.Translate>Status</i18n.Translate>
                    </th>
                  </tr>
                </thead>
                <tbody class="divide-y divide-gray-200 bg-white">
                  {records.map((r,i) => {
                    const uri = Paytos.asString(r.full_payto);
                    if (i === 1) {
                      r.open_time = AbsoluteTime.toProtocolTimestamp(AbsoluteTime.now())
                      r.close_time = AbsoluteTime.toProtocolTimestamp(AbsoluteTime.addDuration(AbsoluteTime.now(), Duration.fromSpec({minutes:5})))
                    } else if (i === 2) {
                      r.open_time = AbsoluteTime.toProtocolTimestamp(AbsoluteTime.now())
                    }
                    const openTime = r.open_time.t_s !== "never" ? format(r.open_time.t_s*1000, "yyyy/MM/dd HH:mm") : undefined;
                    const closeTime = r.close_time.t_s !== "never" ? format(r.close_time.t_s*1000, "yyyy/MM/dd HH:mm") : undefined;
                    const openDescription = openTime
                      ? closeTime
                        ? <span><i18n.Translate>From {openTime}<br/>To {closeTime}</i18n.Translate></span>
                        : i18n.str`Since ${openTime}`
                      : i18n.str`Not opened`;

                    const paramsDesc = Object.entries(uri.params).map(([name,value]) => {
                      return <div>{name}: {value}</div>
                    })
                    return (
                      <Fragment key={r.h_payto}>
                        <tr class="hover:bg-gray-100 ">
                          <td class="whitespace-nowrap px-3 py-5 text-sm text-gray-500 text-right">
                            {r.rowid}
                          </td>
                          <td class="whitespace-nowrap px-3 py-5 text-sm text-gray-500 ">
                            <div class="text-gray-900">
                              <a
                                href={caseByIdRoute.url({
                                  cid: r.h_payto,
                                })}
                                class="text-indigo-600 hover:text-indigo-900 font-mono"
                              >
                                {uri.displayName}
                              </a>
                              <p class="text-gray-500 text-xs">
                                {paramsDesc}
                              </p>
                            </div>
                          </td>
                          <td class="whitespace-nowrap px-3 text-sm text-gray-900">
                            {openDescription}
                          </td>
                          <td class="whitespace-nowrap px-3 py-5 text-sm text-gray-900 flex">
                            {r.to_investigate ? (
                              <span title={i18n.str`Under investigation`}>
                                <ToInvestigateIcon
                                  stroke="#be2206ff"
                                  stroke-width="2"
                                />
                              </span>
                            ) : undefined}
                            {r.high_risk ? (
                              <span title={i18n.str`High risk`}>
                                <HighRiskIcon
                                  stroke="#b2be06ff"
                                  stroke-width="2"
                                />
                              </span>
                            ) : undefined}
                            {r.open_time.t_s !== "never" ? (
                            <span title={i18n.str`With custom rules`}>
                              <OpenIcon stroke="#1806beff" stroke-width="2" />
                            </span>
                            ) : undefined}
                          </td>
                        </tr>
                        {r.comments ? (
                          <tr>
                            <td
                              class="whitespace-nowrap px-3 py-1 text-sm text-gray-500 "
                              colSpan={3}
                            >
                              {r.comments}
                            </td>
                          </tr>
                        ) : undefined}
                      </Fragment>
                    );
                  })}
                </tbody>
              </table>
              <Pagination onFirstPage={list.loadFirst} onNext={list.loadNext} />
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

export const ToInvestigateIcon = (
  props?: h.JSX.SVGAttributes<SVGSVGElement>,
) => (
  <svg
    xmlns="http://www.w3.org/2000/svg"
    fill="none"
    viewBox="0 0 24 24"
    stroke-width="1.5"
    stroke="currentColor"
    class="size-6"
    {...props}
  >
    <path
      stroke-linecap="round"
      stroke-linejoin="round"
      d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
    />
  </svg>
);
export const HighRiskIcon = (props?: h.JSX.SVGAttributes<SVGSVGElement>) => (
  <svg
    xmlns="http://www.w3.org/2000/svg"
    fill="none"
    viewBox="0 0 24 24"
    stroke-width="1.5"
    stroke="currentColor"
    class="size-6 w-6"
    {...props}
  >
    <path
      stroke-linecap="round"
      stroke-linejoin="round"
      d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"
    />
  </svg>
);
export const OpenIcon = (props?: h.JSX.SVGAttributes<SVGSVGElement>) => (
  <svg
    xmlns="http://www.w3.org/2000/svg"
    fill="none"
    viewBox="0 0 24 24"
    stroke-width="1.5"
    stroke="currentColor"
    class="size-6"
    {...props}
  >
    <path
      stroke-linecap="round"
      stroke-linejoin="round"
      d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 0 0-1.883 2.542l.857 6a2.25 2.25 0 0 0 2.227 1.932H19.05a2.25 2.25 0 0 0 2.227-1.932l.857-6a2.25 2.25 0 0 0-1.883-2.542m-16.5 0V6A2.25 2.25 0 0 1 6 3.75h3.879a1.5 1.5 0 0 1 1.06.44l2.122 2.12a1.5 1.5 0 0 0 1.06.44H18A2.25 2.25 0 0 1 20.25 9v.776"
    />
  </svg>
);

export const TransfersIcon = (props?: h.JSX.SVGAttributes<SVGSVGElement>) => (
  <svg
    xmlns="http://www.w3.org/2000/svg"
    fill="none"
    viewBox="0 0 24 24"
    stroke-width="1.5"
    stroke="currentColor"
    class="size-6"
    {...props}
  >
    <path
      stroke-linecap="round"
      stroke-linejoin="round"
      d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5"
    />
  </svg>
);

export const PeopleIcon = (props?: h.JSX.SVGAttributes<SVGSVGElement>) => (
  <svg
    xmlns="http://www.w3.org/2000/svg"
    fill="none"
    viewBox="0 0 24 24"
    stroke-width="1.5"
    stroke="currentColor"
    class="w-6 h-6"
    {...props}
  >
    <path
      stroke-linecap="round"
      stroke-linejoin="round"
      d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
    />
  </svg>
);

export const HomeIcon = (props?: h.JSX.SVGAttributes<SVGSVGElement>) => (
  <svg
    xmlns="http://www.w3.org/2000/svg"
    fill="none"
    viewBox="0 0 24 24"
    stroke-width="1.5"
    stroke="currentColor"
    class="w-6 h-6"
    {...props}
  >
    <path
      stroke-linecap="round"
      stroke-linejoin="round"
      d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"
    />
  </svg>
);
export const FormIcon = (props?: h.JSX.SVGAttributes<SVGSVGElement>) => (
  <svg
    xmlns="http://www.w3.org/2000/svg"
    viewBox="0 0 24 24"
    fill="currentColor"
    class="w-6 h-6"
    {...props}
  >
    <path
      fillRule="evenodd"
      d="M1.5 5.625c0-1.036.84-1.875 1.875-1.875h17.25c1.035 0 1.875.84 1.875 1.875v12.75c0 1.035-.84 1.875-1.875 1.875H3.375A1.875 1.875 0 0 1 1.5 18.375V5.625ZM21 9.375A.375.375 0 0 0 20.625 9h-7.5a.375.375 0 0 0-.375.375v1.5c0 .207.168.375.375.375h7.5a.375.375 0 0 0 .375-.375v-1.5Zm0 3.75a.375.375 0 0 0-.375-.375h-7.5a.375.375 0 0 0-.375.375v1.5c0 .207.168.375.375.375h7.5a.375.375 0 0 0 .375-.375v-1.5Zm0 3.75a.375.375 0 0 0-.375-.375h-7.5a.375.375 0 0 0-.375.375v1.5c0 .207.168.375.375.375h7.5a.375.375 0 0 0 .375-.375v-1.5ZM10.875 18.75a.375.375 0 0 0 .375-.375v-1.5a.375.375 0 0 0-.375-.375h-7.5a.375.375 0 0 0-.375.375v1.5c0 .207.168.375.375.375h7.5ZM3.375 15h7.5a.375.375 0 0 0 .375-.375v-1.5a.375.375 0 0 0-.375-.375h-7.5a.375.375 0 0 0-.375.375v1.5c0 .207.168.375.375.375Zm0-3.75h7.5a.375.375 0 0 0 .375-.375v-1.5A.375.375 0 0 0 10.875 9h-7.5A.375.375 0 0 0 3 9.375v1.5c0 .207.168.375.375.375Z"
      clipRule="evenodd"
    />
  </svg>
);

export const SearchIcon = (props?: h.JSX.SVGAttributes<SVGSVGElement>) => (
  <svg
    xmlns="http://www.w3.org/2000/svg"
    fill="none"
    viewBox="0 0 24 24"
    stroke-width="1.5"
    stroke="currentColor"
    class="w-6 h-6"
    {...props}
  >
    <path
      stroke-linecap="round"
      stroke-linejoin="round"
      d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
    />
  </svg>
);

let latestTimeout: undefined | ReturnType<typeof setTimeout> = undefined;
type FilterName = "investigated" | "highRisk" | "open";
function JumpByIdForm({
  caseByIdRoute,
  filters = {},
  onTog,
}: {
  caseByIdRoute: RouteDefinition<{ cid: string }>;
  filters: {
    investigated?: boolean;
    highRisk?: boolean;
    opened?: boolean;
  };
  onTog: (name: FilterName, d: boolean | undefined) => void;
}): VNode {
  const { i18n } = useTranslationContext();
  const [account, setAccount] = useState<string>("");
  const officer = useOfficer();
  const session = officer.state === "ready" ? officer.account : undefined;
  const { lib } = useExchangeApiContext();
  const [valid, setValid] = useState(false);
  const [error, setError] = useState<string>();
  useEffect(() => {
    if (!session || !account) return;
    const activeSession = session;
    if (latestTimeout) {
      clearTimeout(latestTimeout);
    }
    setError(undefined);
    setValid(false);
    latestTimeout = setTimeout(async function checkAccouunt() {
      let found = false;
      try {
        const result = await lib.exchange.getAmlAttributesForAccount(
          activeSession,
          account,
          { limit: 1 },
        );
        found = result.type === "ok";
      } catch (e) {
        console.log(e);
      }
      setValid(found);
      if (!found) {
        setError(i18n.str`Invalid account`);
      }
    }, 500);
  }, [account, session]);
  return (
    <form class="mt-5 grid grid-cols-1">
      <div>
        <div class="flex flex-row">
          <div class="w-full sm:max-w-xs">
            <input
              name="account"
              onChange={(e) => {
                setAccount(e.currentTarget.value);
              }}
              class="block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6"
              placeholder={i18n.str`Search by ID`}
            />
          </div>
          <a
            href={!valid ? undefined : caseByIdRoute.url({ cid: account })}
            data-disabled={!valid}
            class="data-[disabled=true]:bg-gray-400 mt-3 inline-flex w-full items-center justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500  focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 sm:ml-3 sm:mt-0 sm:w-auto"
          >
            <svg
              xmlns="http://www.w3.org/2000/svg"
              fill="none"
              viewBox="0 0 24 24"
              stroke-width="1.5"
              stroke="currentColor"
              class="size-6 w-6 h-6"
            >
              <path
                stroke-linecap="round"
                stroke-linejoin="round"
                d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3"
              />
            </svg>
          </a>
        </div>
        {!error ? undefined : <p class="mt-2 text-sm text-red-600">{error}</p>}
      </div>
      <div class="mt-2 cursor-default space-y-2">
        <InputToggle
          threeState
          name="inv"
          label={i18n.str`Under investigation: `}
          handler={{
            name: "inv",
            onChange: (x) => onTog("investigated", x),
            value: filters.investigated,
          }}
        />
        <InputToggle
          name="risk"
          label={i18n.str`High risk:`}
          handler={{
            name: "risk",
            onChange: (x) => onTog("highRisk", x),
            value: filters.highRisk,
          }}
        />
        <InputToggle
          name="open"
          label={i18n.str`Open:`}
          handler={{
            name: "open",
            onChange: (x) => onTog("open", x),
            value: filters.opened,
          }}
        />
      </div>
    </form>
  );
}

// "File number,Customer,Comments,Risky,Acquisition date,Exit date\r\n"
const fields: FieldSet<CustomerAccountSummary> = [
  {
    name: "File number",
    type: "Number",
    convert: (v) => String(v.rowid),
  },
  {
    name: "Customer",
    type: "String",
    convert: (v) => v.full_payto,
  },
  {
    name: "Comments",
    type: "String",
    convert: (v) => v.comments ?? "",
  },
  {
    name: "Increased risk business relationship",
    type: "Boolean",
    convert: (v) => (v.high_risk ? "1" : "0"),
  },
  {
    name: "Acquisition date",
    type: "DateTime",
    convert: (v) => {
      const t = AbsoluteTime.fromProtocolTimestamp(v.open_time);
      if (AbsoluteTime.isNever(t)) return "-";
      return AbsoluteTime.stringify(t);
    },
  },
  {
    name: "Exit date",
    type: "DateTime",
    convert: (v) => {
      const t = AbsoluteTime.fromProtocolTimestamp(v.close_time);
      if (AbsoluteTime.isNever(t)) return "-";
      return AbsoluteTime.stringify(t);
    },
  },
];
type FieldSet<T> = Field<T>[];
type Field<T> = { name: string; type: string; convert: (o: T) => string };

const CSV_HEADER = "";
const CSV_FOOTER = "\r\n";

const XML_HEADER =
  '<?xml version="1.0"?>' +
  '<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"' +
  'xmlns:o="urn:schemas-microsoft-com:office:office"' +
  'xmlns:x="urn:schemas-microsoft-com:office:excel"' +
  'xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">' +
  '<Worksheet ss:Name="Sheet1">' +
  "<Table>";
const XML_FOOTER = "</Table></Worksheet></Workbook>";

async function convertToCsv(
  fields: FieldSet<CustomerAccountSummary>,
  values: CustomerAccountSummary[],
) {
  const columns = fields.map((f) => f.name).join(",");
  const HEADER = `${columns}\r\n`;

  const rows = values.reduce((prev, v) => {
    const cells = fields
      .map((f) => {
        const str = f.convert(v);
        return str.replace(/"/g, '\\"');
      })
      .join(",");
    return `${cells}\r\n`;
  }, "");
  const result = CSV_HEADER + HEADER + rows + CSV_FOOTER;
  return opFixedSuccess(result);
}

async function convertToXls(
  fields: FieldSet<CustomerAccountSummary>,
  values: CustomerAccountSummary[],
) {
  const columns = fields.reduce((prev, f) => {
    return `${prev}<Cell ss:StyleID="Header"><Data ss:Type=\"String\">${f.name}</Data></Cell>`;
  }, "");
  const HEADER = `<Row>${columns}</Row>`;

  const rows = values.reduce((prev, v) => {
    const cells = fields.reduce((prev, f) => {
      const str = f.convert(v);
      const safe = str
        .replace(/"/g, "&quot;")
        .replace(/'/g, "&apos;")
        .replace(/&/g, "&amp;")
        .replace(/</g, "&lt;")
        .replace(/>/g, "&gt;");
      return `${prev}<Cell><Data ss:Type=\"${f.type}\">${safe}</Data></Cell>`;
    }, "");
    return `${prev}<Row>${cells}</Row>`;
  }, "");
  const result = XML_HEADER + HEADER + rows + XML_FOOTER;
  return opFixedSuccess(result);
}
