const NS = {
  ATOM: "http://www.w3.org/2005/Atom",
  OPDS: "http://opds-spec.org/2010/catalog",
  THR: "http://purl.org/syndication/thread/1.0",
  DC: "http://purl.org/dc/elements/1.1/",
  DCTERMS: "http://purl.org/dc/terms/",
};

const MIME = {
  ATOM: "application/atom+xml",
  OPDS2: "application/opds+json",
};

export const REL = {
  ACQ: "http://opds-spec.org/acquisition",
  FACET: "http://opds-spec.org/facet",
  GROUP: "http://opds-spec.org/group",
  COVER: ["http://opds-spec.org/image", "http://opds-spec.org/cover"],
  THUMBNAIL: ["http://opds-spec.org/image/thumbnail", "http://opds-spec.org/thumbnail"],
};

export const SYMBOL = {
  SUMMARY: Symbol("summary"),
  CONTENT: Symbol("content"),
};

const FACET_GROUP = Symbol("facetGroup");

const groupByArray = (arr, f) => {
  const map = new Map();
  if (arr)
    for (const el of arr) {
      const keys = f(el);
      for (const key of [keys].flat()) {
        const group = map.get(key);
        if (group) group.push(el);
        else map.set(key, [el]);
      }
    }
  return map;
};

// https://www.rfc-editor.org/rfc/rfc7231#section-3.1.1
const parseMediaType = (str) => {
  if (!str) return null;
  const [mediaType, ...ps] = str.split(/ *; */);
  return {
    mediaType: mediaType.toLowerCase(),
    parameters: Object.fromEntries(
      ps.map((p) => {
        const [name, val] = p.split("=");
        return [name.toLowerCase(), val?.replace(/(^"|"$)/g, "")];
      }),
    ),
  };
};

export const isOPDSCatalog = (str) => {
  const parsed = parseMediaType(str);
  if (!parsed) return false;
  const { mediaType, parameters } = parsed;
  if (mediaType === MIME.OPDS2) return true;
  return mediaType === MIME.ATOM && parameters.profile?.toLowerCase() === "opds-catalog";
};

// ignore the namespace if it doesn't appear in document at all
const useNS = (doc, ns) =>
  doc.lookupNamespaceURI(null) === ns || doc.lookupPrefix(ns) ? ns : null;

const filterNS = (ns) =>
  ns
    ? (name) => (el) => el.namespaceURI === ns && el.localName === name
    : (name) => (el) => el.localName === name;

const getContent = (el) => {
  if (!el) return;
  const type = el.getAttribute("type") ?? "text";
  const value =
    type === "xhtml"
      ? el.innerHTML
      : type === "html"
        ? el.textContent.replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&amp;", "&")
        : el.textContent;
  return { value, type };
};

const getTextContent = (el) => {
  const content = getContent(el);
  if (content?.type === "text") return content?.value;
};

const getSummary = (a, b) => getTextContent(a) ?? getTextContent(b);

const getPrice = (link) => {
  const price = link.getElementsByTagNameNS(NS.OPDS, "price")[0];
  return price
    ? {
        currency: price.getAttribute("currencycode"),
        value: price.textContent,
      }
    : null;
};

const getIndirectAcquisition = (el) => {
  const ia = el.getElementsByTagNameNS(NS.OPDS, "indirectAcquisition")[0];
  if (!ia) return [];
  return [{ type: ia.getAttribute("type") }, ...getIndirectAcquisition(ia)];
};

const getLink = (link) => {
  const obj = {
    rel: link.getAttribute("rel")?.split(/ +/),
    href: link.getAttribute("href"),
    type: link.getAttribute("type"),
    title: link.getAttribute("title"),
    properties: {
      price: getPrice(link),
      indirectAcquisition: getIndirectAcquisition(link),
      numberOfItems: link.getAttributeNS(NS.THR, "count"),
    },
    [FACET_GROUP]: link.getAttributeNS(NS.OPDS, "facetGroup"),
  };
  if (link.getAttributeNS(NS.OPDS, "activeFacet") === "true")
    obj.rel = [obj.rel ?? []].flat().concat("self");
  return obj;
};

const getPerson = (person) => {
  const NS = person.namespaceURI;
  const uri = person.getElementsByTagNameNS(NS, "uri")[0]?.textContent;
  return {
    name: person.getElementsByTagNameNS(NS, "name")[0]?.textContent ?? "",
    links: uri ? [{ href: uri }] : [],
  };
};

export const getPublication = (entry) => {
  const filter = filterNS(useNS(entry.ownerDocument, NS.ATOM));
  const children = Array.from(entry.children);
  const filterDCEL = filterNS(NS.DC);
  const filterDCTERMS = filterNS(NS.DCTERMS);
  const filterDC = (x) => {
    const a = filterDCEL(x),
      b = filterDCTERMS(x);
    return (y) => a(y) || b(y);
  };
  const links = children.filter(filter("link")).map(getLink);
  const linksByRel = groupByArray(links, (link) => link.rel);
  return {
    metadata: {
      title: children.find(filter("title"))?.textContent ?? "",
      author: children.filter(filter("author")).map(getPerson),
      contributor: children.filter(filter("contributor")).map(getPerson),
      publisher: children.find(filterDC("publisher"))?.textContent,
      published: (children.find(filterDCTERMS("issued")) ?? children.find(filterDC("date")))
        ?.textContent,
      language: children.find(filterDC("language"))?.textContent,
      identifier: children.find(filterDC("identifier"))?.textContent,
      subject: children.filter(filter("category")).map((category) => ({
        name: category.getAttribute("label"),
        code: category.getAttribute("term"),
        scheme: category.getAttribute("scheme"),
      })),
      rights: children.find(filter("rights"))?.textContent ?? "",
      [SYMBOL.CONTENT]: getContent(
        children.find(filter("content")) ?? children.find(filter("summary")),
      ),
    },
    links,
    images: REL.COVER.concat(REL.THUMBNAIL)
      .map((R) => linksByRel.get(R)?.[0])
      .filter((x) => x),
  };
};

export const getFeed = (doc) => {
  const ns = useNS(doc, NS.ATOM);
  const filter = filterNS(ns);
  const children = Array.from(doc.documentElement.children);
  const entries = children.filter(filter("entry"));
  const links = children.filter(filter("link")).map(getLink);
  const linksByRel = groupByArray(links, (link) => link.rel);

  const groupedItems = new Map([[null, []]]);
  const groupLinkMap = new Map();
  for (const entry of entries) {
    const children = Array.from(entry.children);
    const links = children.filter(filter("link")).map(getLink);
    const linksByRel = groupByArray(links, (link) => link.rel);
    const isPub = [...linksByRel.keys()].some(
      (rel) => rel?.startsWith(REL.ACQ) || rel === "preview",
    );

    const groupLinks = linksByRel.get(REL.GROUP) ?? linksByRel.get("collection");
    const groupLink = groupLinks?.length
      ? (groupLinks.find((link) => groupedItems.has(link.href)) ?? groupLinks[0])
      : null;
    if (groupLink && !groupLinkMap.has(groupLink.href)) groupLinkMap.set(groupLink.href, groupLink);

    const item = isPub
      ? getPublication(entry)
      : Object.assign(links.find((link) => isOPDSCatalog(link.type)) ?? links[0] ?? {}, {
          title: children.find(filter("title"))?.textContent,
          [SYMBOL.SUMMARY]: getSummary(
            children.find(filter("summary")),
            children.find(filter("content")),
          ),
        });

    const arr = groupedItems.get(groupLink?.href ?? null);
    if (arr) arr.push(item);
    else groupedItems.set(groupLink.href, [item]);
  }
  const [items, ...groups] = Array.from(groupedItems, ([key, items]) => {
    const itemsKey = items[0]?.metadata ? "publications" : "navigation";
    if (key == null) return { [itemsKey]: items };
    const link = groupLinkMap.get(key);
    return {
      metadata: {
        title: link.title,
        numberOfItems: link.properties.numberOfItems,
      },
      links: [{ rel: "self", href: link.href, type: link.type }],
      [itemsKey]: items,
    };
  });
  return {
    metadata: {
      title: children.find(filter("title"))?.textContent,
      subtitle: children.find(filter("subtitle"))?.textContent,
    },
    links,
    ...items,
    groups,
    facets: Array.from(
      groupByArray(linksByRel.get(REL.FACET) ?? [], (link) => link[FACET_GROUP]),
      ([facet, links]) => ({ metadata: { title: facet }, links }),
    ),
  };
};

export const getSearch = async (link) => {
  const { replace, getVariables } = await import("./uri-template.js");
  return {
    metadata: {
      title: link.title,
    },
    search: (map) => replace(link.href, map.get(null)),
    params: Array.from(getVariables(link.href), (name) => ({ name })),
  };
};

export const getOpenSearch = (doc) => {
  const defaultNS = doc.documentElement.namespaceURI;
  const filter = filterNS(defaultNS);
  const children = Array.from(doc.documentElement.children);

  const $$urls = children.filter(filter("Url"));
  const $url = $$urls.find((url) => isOPDSCatalog(url.getAttribute("type"))) ?? $$urls[0];
  if (!$url) throw new Error("document must contain at least one Url element");

  const regex = /{(?:([^}]+?):)?(.+?)(\?)?}/g;
  const defaultMap = new Map([
    ["count", "100"],
    ["startIndex", $url.getAttribute("indexOffset") ?? "0"],
    ["startPage", $url.getAttribute("pageOffset") ?? "0"],
    ["language", "*"],
    ["inputEncoding", "UTF-8"],
    ["outputEncoding", "UTF-8"],
  ]);

  const template = $url.getAttribute("template");
  return {
    metadata: {
      title: (children.find(filter("LongName")) ?? children.find(filter("ShortName")))?.textContent,
      description: children.find(filter("Description"))?.textContent,
    },
    search: (map) =>
      template.replace(regex, (_, prefix, param) => {
        const namespace = prefix ? $url.lookupNamespaceURI(prefix) : null;
        const ns = namespace === defaultNS ? null : namespace;
        const val = map.get(ns)?.get(param);
        return encodeURIComponent(val ? val : !ns ? (defaultMap.get(param) ?? "") : "");
      }),
    params: Array.from(template.matchAll(regex), ([, prefix, param, optional]) => {
      const namespace = prefix ? $url.lookupNamespaceURI(prefix) : null;
      const ns = namespace === defaultNS ? null : namespace;
      return {
        ns,
        name: param,
        required: !optional,
        value: ns && ns !== defaultNS ? "" : (defaultMap.get(param) ?? ""),
      };
    }),
  };
};
