/**
 * Copyright 2020 Inrupt Inc.
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal in
 * the Software without restriction, including without limitation the rights to use,
 * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
 * Software, and to permit persons to whom the Software is furnished to do so,
 * subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
 * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
 * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
 * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
 * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
 * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 */

import { Quad, NamedNode, Quad_Object } from "rdf-js";
import { dataset, DataFactory } from "../rdfjs";
import { ldp } from "../constants";
import { turtleToTriples, triplesToTurtle } from "../formats/turtle";
import {
  isLocalNode,
  isNamedNode,
  resolveIriForLocalNode,
  resolveIriForLocalNodes,
} from "../datatypes";
import {
  UrlString,
  SolidDataset,
  WithChangeLog,
  hasChangelog,
  WithResourceInfo,
  hasResourceInfo,
  LocalNode,
  Url,
  IriString,
  Thing,
  WithServerResourceInfo,
} from "../interfaces";
import { internal_toIriString } from "../interfaces.internal";
import {
  internal_defaultFetchOptions,
  getSourceUrl,
  getResourceInfo,
  isContainer,
  FetchError,
} from "./resource";
import {
  internal_cloneResource,
  internal_isUnsuccessfulResponse,
  internal_parseResourceInfo,
} from "./resource.internal";
import { thingAsMarkdown, getThingAll, getThing } from "../thing/thing";
import {
  internal_getReadableValue,
  internal_toNode,
  internal_withChangeLog,
} from "../thing/thing.internal";
import { getIriAll } from "../thing/get";
import { normalizeServerSideIri } from "./iri.internal";

/**
 * Initialise a new [[SolidDataset]] in memory.
 *
 * @returns An empty [[SolidDataset]].
 */
export function createSolidDataset(): SolidDataset {
  return dataset();
}

/**
 * Fetch a SolidDataset from the given URL. Currently requires the SolidDataset to be available as [Turtle](https://www.w3.org/TR/turtle/).
 *
 * @param url URL to fetch a [[SolidDataset]] from.
 * @param options Optional parameter `options.fetch`: An alternative `fetch` function to make the HTTP request, compatible with the browser-native [fetch API](https://developer.mozilla.org/docs/Web/API/WindowOrWorkerGlobalScope/fetch#parameters).
 * @returns Promise resolving to a [[SolidDataset]] containing the data at the given Resource, or rejecting if fetching it failed.
 */
export async function getSolidDataset(
  url: UrlString | Url,
  options: Partial<
    typeof internal_defaultFetchOptions
  > = internal_defaultFetchOptions
): Promise<SolidDataset & WithServerResourceInfo> {
  url = internal_toIriString(url);
  const config = {
    ...internal_defaultFetchOptions,
    ...options,
  };

  const response = await config.fetch(url, {
    headers: {
      Accept: "text/turtle",
    },
  });
  if (internal_isUnsuccessfulResponse(response)) {
    throw new FetchError(
      `Fetching the Resource at [${url}] failed: [${response.status}] [${response.statusText}].`,
      response
    );
  }
  const data = await response.text();
  const triples = await turtleToTriples(data, url);
  const resource = dataset();
  triples.forEach((triple) => resource.add(triple));

  const resourceInfo = internal_parseResourceInfo(response);

  const resourceWithResourceInfo: SolidDataset &
    WithServerResourceInfo = Object.assign(resource, {
    internal_resourceInfo: resourceInfo,
  });

  return resourceWithResourceInfo;
}

type UpdateableDataset = SolidDataset &
  WithChangeLog &
  WithServerResourceInfo & { internal_resourceInfo: { sourceIri: IriString } };

/**
 * Create a SPARQL UPDATE Patch request from a [[SolidDataset]] with a changelog.
 * @param solidDataset the [[SolidDataset]] that has been locally updated, and that should be persisted.
 * @returns an HTTP PATCH request configuration object, aligned with the [fetch API](https://developer.mozilla.org/docs/Web/API/WindowOrWorkerGlobalScope/fetch#parameters), containing a SPARQL UPDATE.
 * @hidden
 */
async function prepareSolidDatasetUpdate(
  solidDataset: UpdateableDataset
): Promise<RequestInit> {
  const deleteStatement =
    solidDataset.internal_changeLog.deletions.length > 0
      ? `DELETE DATA {${(
          await triplesToTurtle(
            solidDataset.internal_changeLog.deletions.map(
              getNamedNodesForLocalNodes
            )
          )
        ).trim()}};`
      : "";
  const insertStatement =
    solidDataset.internal_changeLog.additions.length > 0
      ? `INSERT DATA {${(
          await triplesToTurtle(
            solidDataset.internal_changeLog.additions.map(
              getNamedNodesForLocalNodes
            )
          )
        ).trim()}};`
      : "";

  return {
    method: "PATCH",
    body: `${deleteStatement} ${insertStatement}`,
    headers: {
      "Content-Type": "application/sparql-update",
    },
  };
}

/**
 * Create a Put request to write a locally created [[SolidDataset]] to a Pod.
 * @param solidDataset the [[SolidDataset]] that has been locally updated, and that should be persisted.
 * @returns an HTTP PUT request configuration object, aligned with the [fetch API](https://developer.mozilla.org/docs/Web/API/WindowOrWorkerGlobalScope/fetch#parameters), containing a serialization of the [[SolidDataset]].
 * @hidden
 */
async function prepareSolidDatasetCreation(
  solidDataset: SolidDataset
): Promise<RequestInit> {
  return {
    method: "PUT",
    body: await triplesToTurtle(
      Array.from(solidDataset).map(getNamedNodesForLocalNodes)
    ),
    headers: {
      "Content-Type": "text/turtle",
      "If-None-Match": "*",
      Link: `<${ldp.Resource}>; rel="type"`,
    },
  };
}

/**
 * Given a SolidDataset, store it in a Solid Pod (overwriting the existing data at the given URL).
 *
 * A SolidDataset keeps track of the data changes compared to the data in the Pod; i.e.,
 * the changelog tracks both the old value and new values of the property being modified. This
 * function applies the changes to the current SolidDataset. If the old value specified in the
 * changelog does not correspond to the value currently in the Pod, this function will throw an
 * error.
 * The SolidDataset returned by this function will contain the data sent to the Pod, and a ChangeLog
 * up-to-date with the saved data. Note that if the data on the server was modified in between the
 * first fetch and saving it, the updated data will not be reflected in the returned SolidDataset.
 * To make sure you have the latest data, call [[getSolidDataset]] again after saving the data.
 *
 * The Solid server will create any intermediary Containers that do not exist yet, so they do not
 * need to be created in advance. For example, if the target URL is
 * https://example.pod/container/resource and https://example.pod/container/ does not exist yet,
 * it will exist after this function resolves successfully.
 *
 * @param url URL to save `solidDataset` to.
 * @param solidDataset The [[SolidDataset]] to save.
 * @param options Optional parameter `options.fetch`: An alternative `fetch` function to make the HTTP request, compatible with the browser-native [fetch API](https://developer.mozilla.org/docs/Web/API/WindowOrWorkerGlobalScope/fetch#parameters).
 * @returns A Promise resolving to a [[SolidDataset]] containing the stored data, or rejecting if saving it failed.
 */
export async function saveSolidDatasetAt<Dataset extends SolidDataset>(
  url: UrlString | Url,
  solidDataset: Dataset,
  options: Partial<
    typeof internal_defaultFetchOptions
  > = internal_defaultFetchOptions
): Promise<Dataset & WithServerResourceInfo & WithChangeLog> {
  url = internal_toIriString(url);
  const config = {
    ...internal_defaultFetchOptions,
    ...options,
  };

  const datasetWithChangelog = internal_withChangeLog(solidDataset);

  const requestInit = isUpdate(datasetWithChangelog, url)
    ? await prepareSolidDatasetUpdate(datasetWithChangelog)
    : await prepareSolidDatasetCreation(datasetWithChangelog);

  const response = await config.fetch(url, requestInit);

  if (internal_isUnsuccessfulResponse(response)) {
    const diagnostics = isUpdate(datasetWithChangelog, url)
      ? "The changes that were sent to the Pod are listed below.\n\n" +
        changeLogAsMarkdown(datasetWithChangelog)
      : "The SolidDataset that was sent to the Pod is listed below.\n\n" +
        solidDatasetAsMarkdown(datasetWithChangelog);
    throw new FetchError(
      `Storing the Resource at [${url}] failed: [${response.status}] [${response.statusText}].\n\n` +
        diagnostics,
      response
    );
  }

  const resourceInfo: WithServerResourceInfo["internal_resourceInfo"] = {
    ...internal_parseResourceInfo(response),
    isRawData: false,
  };
  const storedDataset: Dataset &
    WithChangeLog &
    WithServerResourceInfo = Object.assign(
    internal_cloneResource(datasetWithChangelog),
    {
      internal_changeLog: { additions: [], deletions: [] },
      internal_resourceInfo: resourceInfo,
    }
  );

  const storedDatasetWithResolvedIris = resolveLocalIrisInSolidDataset(
    storedDataset
  );

  return storedDatasetWithResolvedIris;
}

/**
 * Deletes the SolidDataset at a given URL.
 *
 * @param file The (URL of the) SolidDataset to delete
 * @since 0.6.0
 */
export async function deleteSolidDataset(
  solidDataset: Url | UrlString | WithResourceInfo,
  options: Partial<
    typeof internal_defaultFetchOptions
  > = internal_defaultFetchOptions
): Promise<void> {
  const config = {
    ...internal_defaultFetchOptions,
    ...options,
  };
  const url = hasResourceInfo(solidDataset)
    ? internal_toIriString(getSourceUrl(solidDataset))
    : internal_toIriString(solidDataset);
  const response = await config.fetch(url, { method: "DELETE" });

  if (internal_isUnsuccessfulResponse(response)) {
    throw new FetchError(
      `Deleting the SolidDataset at [${url}] failed: [${response.status}] [${response.statusText}].`,
      response
    );
  }
}

/**
 * Create an empty Container at the given URL.
 *
 * Throws an error if creating the Container failed, e.g. because the current user does not have
 * permissions to, or because the Container already exists.
 *
 * Note that a Solid server will automatically create the necessary Containers when storing a
 * Resource; i.e. there is no need to call this function if it is immediately followed by
 * [[saveSolidDatasetAt]] or [[overwriteFile]].
 *
 * @param url URL of the empty Container that is to be created.
 * @param options Optional parameter `options.fetch`: An alternative `fetch` function to make the HTTP request, compatible with the browser-native [fetch API](https://developer.mozilla.org/docs/Web/API/WindowOrWorkerGlobalScope/fetch#parameters).
 * @since 0.2.0
 */
export async function createContainerAt(
  url: UrlString | Url,
  options: Partial<
    typeof internal_defaultFetchOptions
  > = internal_defaultFetchOptions
): Promise<SolidDataset & WithServerResourceInfo> {
  url = internal_toIriString(url);
  url = url.endsWith("/") ? url : url + "/";
  const config = {
    ...internal_defaultFetchOptions,
    ...options,
  };

  const response = await config.fetch(url, {
    method: "PUT",
    headers: {
      Accept: "text/turtle",
      "Content-Type": "text/turtle",
      "If-None-Match": "*",
      // This header should not be required to create a Container,
      // but ESS currently expects it:
      Link: `<${ldp.BasicContainer}>; rel="type"`,
    },
  });

  if (internal_isUnsuccessfulResponse(response)) {
    if (
      response.status === 409 &&
      response.statusText === "Conflict" &&
      (await response.text()).trim() ===
        internal_NSS_CREATE_CONTAINER_SPEC_NONCOMPLIANCE_DETECTION_ERROR_MESSAGE_TO_WORKAROUND_THEIR_ISSUE_1465
    ) {
      return createContainerWithNssWorkaroundAt(url, options);
    }

    throw new FetchError(
      `Creating the empty Container at [${url}] failed: [${response.status}] [${response.statusText}].`,
      response
    );
  }

  const resourceInfo = internal_parseResourceInfo(response);
  const containerDataset: SolidDataset &
    WithChangeLog &
    WithServerResourceInfo = Object.assign(dataset(), {
    internal_changeLog: { additions: [], deletions: [] },
    internal_resourceInfo: resourceInfo,
  });

  return containerDataset;
}

/**
 * Unfortunately Node Solid Server does not confirm to the Solid spec when it comes to Container
 * creation. When we make the (valid, according to the Solid protocol) request to create a
 * Container, NSS responds with the following exact error message. Thus, when we encounter exactly
 * this message, we use an NSS-specific workaround ([[createContainerWithNssWorkaroundAt]]). Both
 * this constant and that workaround should be removed once the NSS issue has been fixed and
 * no versions of NSS with the issue are in common use/supported anymore.
 *
 * @see https://github.com/solid/node-solid-server/issues/1465
 * @internal
 */
export const internal_NSS_CREATE_CONTAINER_SPEC_NONCOMPLIANCE_DETECTION_ERROR_MESSAGE_TO_WORKAROUND_THEIR_ISSUE_1465 =
  "Can't write file: PUT not supported on containers, use POST instead";

/**
 * Unfortunately Node Solid Server does not confirm to the Solid spec when it comes to Container
 * creation. As a workaround, we create a dummy file _inside_ the desired Container (which should
 * create the desired Container on the fly), and then delete it again.
 *
 * @see https://github.com/solid/node-solid-server/issues/1465
 */
const createContainerWithNssWorkaroundAt: typeof createContainerAt = async (
  url,
  options
) => {
  url = internal_toIriString(url);
  const config = {
    ...internal_defaultFetchOptions,
    ...options,
  };

  let existingContainer;
  try {
    existingContainer = await getResourceInfo(url, options);
  } catch (e) {
    // To create the Container, we'd want it to not exist yet. In other words, we'd expect to get
    // a 404 error here in the happy path - so do nothing if that's the case.
    if (!(e instanceof FetchError) || e.statusCode !== 404) {
      // (But if we get an error other than a 404, just throw that error like we usually would.)
      throw e;
    }
  }
  if (typeof existingContainer !== "undefined") {
    throw new Error(
      `The Container at [${url}] already exists, and therefore cannot be created again.`
    );
  }

  const dummyUrl = url + ".dummy";

  const createResponse = await config.fetch(dummyUrl, {
    method: "PUT",
    headers: {
      Accept: "text/turtle",
      "Content-Type": "text/turtle",
    },
  });

  if (internal_isUnsuccessfulResponse(createResponse)) {
    throw new FetchError(
      `Creating the empty Container at [${url}] failed: [${createResponse.status}] [${createResponse.statusText}].`,
      createResponse
    );
  }

  await config.fetch(dummyUrl, { method: "DELETE" });

  const containerInfoResponse = await config.fetch(url, { method: "HEAD" });

  const resourceInfo = internal_parseResourceInfo(containerInfoResponse);
  const containerDataset: SolidDataset &
    WithChangeLog &
    WithServerResourceInfo = Object.assign(dataset(), {
    internal_changeLog: { additions: [], deletions: [] },
    internal_resourceInfo: resourceInfo,
  });

  return containerDataset;
};

function isSourceIriEqualTo(
  dataset: SolidDataset & WithResourceInfo,
  iri: IriString
): boolean {
  return (
    normalizeServerSideIri(dataset.internal_resourceInfo.sourceIri) ===
    normalizeServerSideIri(iri)
  );
}

function isUpdate(
  solidDataset: SolidDataset,
  url: UrlString
): solidDataset is UpdateableDataset {
  return (
    hasChangelog(solidDataset) &&
    hasResourceInfo(solidDataset) &&
    typeof solidDataset.internal_resourceInfo.sourceIri === "string" &&
    isSourceIriEqualTo(solidDataset, url)
  );
}

type SaveInContainerOptions = Partial<
  typeof internal_defaultFetchOptions & {
    slugSuggestion: string;
  }
>;
/**
 * Given a SolidDataset, store it in a Solid Pod in a new Resource inside a Container.
 *
 * The Container at the given URL should already exist; if it does not, you can initialise it first
 * using [[createContainerAt]], or directly save the SolidDataset at the desired location using
 * [[saveSolidDatasetAt]].
 *
 * This function is primarily useful if the current user does not have access to change existing files in
 * a Container, but is allowed to add new files; in other words, they have Append, but not Write
 * access to a Container. This is useful in situations where someone wants to allow others to,
 * for example, send notifications to their Pod, but not to view or delete existing notifications.
 * You can pass a suggestion for the new Resource's name, but the server may decide to give it
 * another name — for example, if a Resource with that name already exists inside the given
 * Container.
 * If the user does have access to write directly to a given location, [[saveSolidDatasetAt]]
 * will do the job just fine, and does not require the parent Container to exist in advance.
 *
 * @param containerUrl URL of the Container in which to create a new Resource.
 * @param solidDataset The [[SolidDataset]] to save to a new Resource in the given Container.
 * @param options Optional parameter `options.fetch`: An alternative `fetch` function to make the HTTP request, compatible with the browser-native [fetch API](https://developer.mozilla.org/docs/Web/API/WindowOrWorkerGlobalScope/fetch#parameters).
 * @returns A Promise resolving to a [[SolidDataset]] containing the saved data. The Promise rejects if the save failed.
 */
export async function saveSolidDatasetInContainer(
  containerUrl: UrlString | Url,
  solidDataset: SolidDataset,
  options: SaveInContainerOptions = internal_defaultFetchOptions
): Promise<SolidDataset & WithResourceInfo> {
  const config = {
    ...internal_defaultFetchOptions,
    ...options,
  };
  containerUrl = internal_toIriString(containerUrl);

  const rawTurtle = await triplesToTurtle(
    Array.from(solidDataset).map(getNamedNodesForLocalNodes)
  );
  const headers: RequestInit["headers"] = {
    "Content-Type": "text/turtle",
    Link: `<${ldp.Resource}>; rel="type"`,
  };
  if (options.slugSuggestion) {
    headers.slug = options.slugSuggestion;
  }
  const response = await config.fetch(containerUrl, {
    method: "POST",
    body: rawTurtle,
    headers: headers,
  });

  if (internal_isUnsuccessfulResponse(response)) {
    throw new FetchError(
      `Storing the Resource in the Container at [${containerUrl}] failed: [${response.status}] [${response.statusText}].\n\n` +
        "The SolidDataset that was sent to the Pod is listed below.\n\n" +
        solidDatasetAsMarkdown(solidDataset),
      response
    );
  }

  const locationHeader = response.headers.get("Location");
  if (locationHeader === null) {
    throw new Error(
      "Could not determine the location of the newly saved SolidDataset."
    );
  }

  const resourceIri = new URL(locationHeader, response.url).href;

  const resourceInfo: WithResourceInfo = {
    internal_resourceInfo: {
      isRawData: false,
      sourceIri: resourceIri,
    },
  };

  const resourceWithResourceInfo: SolidDataset &
    WithResourceInfo = Object.assign(
    internal_cloneResource(solidDataset),
    resourceInfo
  );

  const resourceWithResolvedIris = resolveLocalIrisInSolidDataset(
    resourceWithResourceInfo
  );

  return resourceWithResolvedIris;
}

/**
 * Create an empty Container inside the Container at the given URL.
 *
 * Throws an error if creating the Container failed, e.g. because the current user does not have
 * permissions to.
 *
 * The Container in which to create the new Container should itself already exist.
 *
 * This function is primarily useful if the current user does not have access to change existing files in
 * a Container, but is allowed to add new files; in other words, they have Append, but not Write
 * access to a Container. This is useful in situations where someone wants to allow others to,
 * for example, send notifications to their Pod, but not to view or delete existing notifications.
 * You can pass a suggestion for the new Resource's name, but the server may decide to give it
 * another name — for example, if a Resource with that name already exists inside the given
 * Container.
 * If the user does have access to write directly to a given location, [[createContainerAt]]
 * will do the job just fine, and does not require the parent Container to exist in advance.
 *
 * @param containerUrl URL of the Container in which the empty Container is to be created.
 * @param options Optional parameter `options.fetch`: An alternative `fetch` function to make the HTTP request, compatible with the browser-native [fetch API](https://developer.mozilla.org/docs/Web/API/WindowOrWorkerGlobalScope/fetch#parameters).
 * @since 0.2.0
 */
export async function createContainerInContainer(
  containerUrl: UrlString | Url,
  options: SaveInContainerOptions = internal_defaultFetchOptions
): Promise<SolidDataset & WithResourceInfo> {
  containerUrl = internal_toIriString(containerUrl);
  const config = {
    ...internal_defaultFetchOptions,
    ...options,
  };

  const headers: RequestInit["headers"] = {
    "Content-Type": "text/turtle",
    Link: `<${ldp.BasicContainer}>; rel="type"`,
  };
  if (options.slugSuggestion) {
    headers.slug = options.slugSuggestion;
  }
  const response = await config.fetch(containerUrl, {
    method: "POST",
    headers: headers,
  });

  if (internal_isUnsuccessfulResponse(response)) {
    throw new FetchError(
      `Creating an empty Container in the Container at [${containerUrl}] failed: [${response.status}] [${response.statusText}].`,
      response
    );
  }

  const locationHeader = response.headers.get("Location");
  if (locationHeader === null) {
    throw new Error(
      "Could not determine the location of the newly created Container."
    );
  }

  const resourceIri = new URL(locationHeader, response.url).href;

  const resourceInfo: WithResourceInfo = {
    internal_resourceInfo: {
      isRawData: false,
      sourceIri: resourceIri,
    },
  };

  const resourceWithResourceInfo: SolidDataset &
    WithResourceInfo = Object.assign(dataset(), resourceInfo);
  return resourceWithResourceInfo;
}

/**
 * Deletes the Container at a given URL.
 *
 * @param file The (URL of the) Container to delete
 * @since 0.6.0
 */
export async function deleteContainer(
  container: Url | UrlString | WithResourceInfo,
  options: Partial<
    typeof internal_defaultFetchOptions
  > = internal_defaultFetchOptions
): Promise<void> {
  const url = hasResourceInfo(container)
    ? internal_toIriString(getSourceUrl(container))
    : internal_toIriString(container);
  if (!isContainer(container)) {
    throw new Error(
      `You're trying to delete the Container at [${url}], but Container URLs should end in a \`/\`. Are you sure this is a Container?`
    );
  }

  const config = {
    ...internal_defaultFetchOptions,
    ...options,
  };
  const response = await config.fetch(url, { method: "DELETE" });

  if (internal_isUnsuccessfulResponse(response)) {
    throw new FetchError(
      `Deleting the Container at [${url}] failed: [${response.status}] [${response.statusText}].`,
      response
    );
  }
}

/**
 * Given a [[SolidDataset]] representing a Container (see [[isContainer]]), fetch the URLs of all
 * contained resources.
 * If the solidDataset given is not a container, or is missing resourceInfo, throw an error.
 *
 * @param solidDataset The container from which to fetch all contained Resource URLs.
 * @returns A list of URLs, each of which points to a contained Resource of the given SolidDataset.
 * @since 1.3.0
 */

export function getContainedResourceUrlAll(
  solidDataset: SolidDataset & WithResourceInfo
): UrlString[] {
  const container = getThing(solidDataset, getSourceUrl(solidDataset));
  // See https://www.w3.org/TR/2015/REC-ldp-20150226/#h-ldpc-http_post:
  // > a containment triple MUST be added to the state of the LDPC whose subject is the LDPC URI,
  // > whose predicate is ldp:contains and whose object is the URI for the newly created document
  return container !== null ? getIriAll(container, ldp.contains) : [];
}

/**
 * Gets a human-readable representation of the given SolidDataset to aid debugging.
 *
 * Note that changes to the exact format of the return value are not considered a breaking change;
 * it is intended to aid in debugging, not as a serialisation method that can be reliably parsed.
 *
 * @param solidDataset The [[SolidDataset]] to get a human-readable representation of.
 * @since 0.3.0
 */
export function solidDatasetAsMarkdown(solidDataset: SolidDataset): string {
  let readableSolidDataset: string = "";

  if (hasResourceInfo(solidDataset)) {
    readableSolidDataset += `# SolidDataset: ${solidDataset.internal_resourceInfo.sourceIri}\n`;
  } else {
    readableSolidDataset += `# SolidDataset (no URL yet)\n`;
  }

  const things = getThingAll(solidDataset);
  if (things.length === 0) {
    readableSolidDataset += "\n<empty>\n";
  } else {
    things.forEach((thing) => {
      readableSolidDataset += "\n" + thingAsMarkdown(thing);
      if (hasChangelog(solidDataset)) {
        readableSolidDataset +=
          "\n" + getReadableChangeLogSummary(solidDataset, thing) + "\n";
      }
    });
  }

  return readableSolidDataset;
}

/**
 * Gets a human-readable representation of the local changes to a Resource to aid debugging.
 *
 * Note that changes to the exact format of the return value are not considered a breaking change;
 * it is intended to aid in debugging, not as a serialisation method that can be reliably parsed.
 *
 * @param solidDataset The Resource of which to get a human-readable representation of the changes applied to it locally.
 * @since 0.3.0
 */
export function changeLogAsMarkdown(
  solidDataset: SolidDataset & WithChangeLog
): string {
  if (!hasResourceInfo(solidDataset)) {
    return "This is a newly initialized SolidDataset, so there is no source to compare it to.";
  }
  if (
    !hasChangelog(solidDataset) ||
    (solidDataset.internal_changeLog.additions.length === 0 &&
      solidDataset.internal_changeLog.deletions.length === 0)
  ) {
    return (
      `## Changes compared to ${getSourceUrl(solidDataset)}\n\n` +
      `This SolidDataset has not been modified since it was fetched from ${getSourceUrl(
        solidDataset
      )}.\n`
    );
  }

  let readableChangeLog = `## Changes compared to ${getSourceUrl(
    solidDataset
  )}\n`;

  const changeLogsByThingAndProperty = sortChangeLogByThingAndProperty(
    solidDataset
  );
  Object.keys(changeLogsByThingAndProperty).forEach((thingUrl) => {
    readableChangeLog += `\n### Thing: ${thingUrl}\n`;
    const changeLogByProperty = changeLogsByThingAndProperty[thingUrl];
    Object.keys(changeLogByProperty).forEach((propertyUrl) => {
      readableChangeLog += `\nProperty: ${propertyUrl}\n`;
      const deleted = changeLogByProperty[propertyUrl].deleted;
      const added = changeLogByProperty[propertyUrl].added;
      if (deleted.length > 0) {
        readableChangeLog += "- Removed:\n";
        deleted.forEach(
          (deletedValue) =>
            (readableChangeLog += `  - ${internal_getReadableValue(
              deletedValue
            )}\n`)
        );
      }
      if (added.length > 0) {
        readableChangeLog += "- Added:\n";
        added.forEach(
          (addedValue) =>
            (readableChangeLog += `  - ${internal_getReadableValue(
              addedValue
            )}\n`)
        );
      }
    });
  });

  return readableChangeLog;
}

function sortChangeLogByThingAndProperty(
  solidDataset: WithChangeLog & WithResourceInfo
) {
  const changeLogsByThingAndProperty: Record<
    UrlString,
    Record<UrlString, { added: Quad_Object[]; deleted: Quad_Object[] }>
  > = {};
  solidDataset.internal_changeLog.deletions.forEach((deletion) => {
    const subjectNode = isLocalNode(deletion.subject)
      ? resolveIriForLocalNode(deletion.subject, getSourceUrl(solidDataset))
      : deletion.subject;
    if (!isNamedNode(subjectNode) || !isNamedNode(deletion.predicate)) {
      return;
    }
    const thingUrl = internal_toIriString(subjectNode);
    const propertyUrl = internal_toIriString(deletion.predicate);
    changeLogsByThingAndProperty[thingUrl] ??= {};
    changeLogsByThingAndProperty[thingUrl][propertyUrl] ??= {
      added: [],
      deleted: [],
    };
    changeLogsByThingAndProperty[thingUrl][propertyUrl].deleted.push(
      deletion.object
    );
  });
  solidDataset.internal_changeLog.additions.forEach((addition) => {
    const subjectNode = isLocalNode(addition.subject)
      ? resolveIriForLocalNode(addition.subject, getSourceUrl(solidDataset))
      : addition.subject;
    if (!isNamedNode(subjectNode) || !isNamedNode(addition.predicate)) {
      return;
    }
    const thingUrl = internal_toIriString(subjectNode);
    const propertyUrl = internal_toIriString(addition.predicate);
    changeLogsByThingAndProperty[thingUrl] ??= {};
    changeLogsByThingAndProperty[thingUrl][propertyUrl] ??= {
      added: [],
      deleted: [],
    };
    changeLogsByThingAndProperty[thingUrl][propertyUrl].added.push(
      addition.object
    );
  });

  return changeLogsByThingAndProperty;
}

function getReadableChangeLogSummary(
  solidDataset: WithChangeLog,
  thing: Thing
): string {
  const subject = internal_toNode(thing);
  const nrOfAdditions = solidDataset.internal_changeLog.additions.reduce(
    (count, addition) => (addition.subject.equals(subject) ? count + 1 : count),
    0
  );
  const nrOfDeletions = solidDataset.internal_changeLog.deletions.reduce(
    (count, deletion) => (deletion.subject.equals(subject) ? count + 1 : count),
    0
  );
  const additionString =
    nrOfAdditions === 1
      ? "1 new value added"
      : nrOfAdditions + " new values added";
  const deletionString =
    nrOfDeletions === 1 ? "1 value removed" : nrOfDeletions + " values removed";
  return `(${additionString} / ${deletionString})`;
}

function getNamedNodesForLocalNodes(quad: Quad): Quad {
  const subject = isLocalNode(quad.subject)
    ? getNamedNodeFromLocalNode(quad.subject)
    : quad.subject;
  const object = isLocalNode(quad.object)
    ? getNamedNodeFromLocalNode(quad.object)
    : quad.object;

  return {
    ...quad,
    subject: subject,
    object: object,
  };
}

function getNamedNodeFromLocalNode(localNode: LocalNode): NamedNode {
  return DataFactory.namedNode("#" + localNode.internal_name);
}

function resolveLocalIrisInSolidDataset<
  Dataset extends SolidDataset & WithResourceInfo
>(solidDataset: Dataset): Dataset {
  const resourceIri = getSourceUrl(solidDataset);
  const unresolvedQuads = Array.from(solidDataset);

  unresolvedQuads.forEach((unresolvedQuad) => {
    const resolvedQuad = resolveIriForLocalNodes(unresolvedQuad, resourceIri);
    solidDataset.delete(unresolvedQuad);
    solidDataset.add(resolvedQuad);
  });

  return solidDataset;
}
