/*
 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/>
 */

/**
 * This content script injects Taler support into pages that request it.
 *
 * Since the content script runs in an isolated context, we inject
 * Taler support by adding a script tag to the DOM.
 */

/**
 * Imports.
 * Since this script runs as a content script, we want to import
 * as little as possible.
 */
import type { MessageFromBackend } from "./platform/api.js";
import type {
  ExtensionOperations,
  MessageFromExtension,
  MessageResponse,
} from "./wxBackend.js";

// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Chrome_incompatibilities#content_script_environment

// ISOLATED mode in chromium browsers
// https://chromium.googlesource.com/chromium/src/+/master/third_party/blink/renderer/bindings/core/v8/V8BindingDesign.md#world
// X-Ray vision in Firefox
// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Sharing_objects_with_page_scripts#xray_vision_in_firefox

// *** IMPORTANT ***

// Content script lifecycle during navigation
// In Firefox: Content scripts remain injected in a web page after the user has navigated away,
// however, window object properties are destroyed.
// In Chrome: Content scripts are destroyed when the user navigates away from a web page.

interface TalerSupportStatus {
  debugEnabled: boolean;
  talerApiEnabled: boolean;
  hijackEnabled: boolean;
  callbackEnabled: boolean;
}

/** Current set of support flags, if requested by the page. */
let talerSupportFlags: TalerSupportStatus | undefined;
/** Currently injected script tag. */
let interactionSupportElement: HTMLScriptElement | undefined = undefined;

/** Index of the next message we send to the webext backend. */
let nextMessageIndex = 0;

const logger = {
  debug: (...msg: any[]) => {},
  info: (...msg: any[]) =>
    console.log(`${new Date().toISOString()} TALER`, ...msg),
  error: (...msg: any[]) =>
    console.error(`${new Date().toISOString()} TALER`, ...msg),
};

function validateTalerUri(uri: string): boolean {
  return (
    !!uri && (uri.startsWith("taler://") || uri.startsWith("taler+http://"))
  );
}

function convertURIToWebExtensionPath(uri: string) {
  const url = new URL(
    chrome.runtime.getURL(
      `static/wallet.html#/taler-uri-simple/${encodeURIComponent(uri)}`,
    ),
  );
  return url.href;
}

function redirectToTalerActionHandler(element: HTMLMetaElement) {
  const name = element.getAttribute("name");
  if (!name) return;
  if (name !== "taler-uri") return;
  const uri = element.getAttribute("content");
  if (!uri) return;

  if (!validateTalerUri(uri)) {
    logger.error(`taler:// URI is invalid: ${uri}`);
    return;
  }

  const walletPage = convertURIToWebExtensionPath(uri);
  window.location.replace(walletPage);
}

const scriptUrl = chrome.runtime.getURL(
  "/dist/taler-wallet-interaction-support.js",
);

function loadScript(): void {
  if (interactionSupportElement) {
    interactionSupportElement.remove();
    interactionSupportElement = undefined;
  }
  if (!talerSupportFlags) {
    return;
  }
  const scriptTag = document.createElement("script");
  scriptTag.setAttribute("async", "false");
  const url = new URL(scriptUrl);
  const setParamBool = (key: string, value: boolean | undefined) => {
    if (value) {
      url.searchParams.set(key, "true");
    }
  };
  setParamBool("callback", talerSupportFlags.callbackEnabled);
  setParamBool("debug", talerSupportFlags.debugEnabled);
  setParamBool("api", talerSupportFlags.talerApiEnabled);
  setParamBool("hijack", talerSupportFlags.hijackEnabled);
  scriptTag.src = url.href;
  const head = document.head;
  try {
    interactionSupportElement = head.insertBefore(
      scriptTag,
      head.children.length ? head.children[0] : null,
    );
  } catch (e) {
    logger.info("inserting link handler failed!");
    logger.error(e);
    return undefined;
  }
}

function maybeHandleMetaTalerUri() {
  const metaTalerUri = document.head.querySelector("meta[name=taler-uri]");
  if (metaTalerUri && metaTalerUri instanceof HTMLMetaElement) {
    const uri = metaTalerUri.getAttribute("content");
    if (!uri) {
      return;
    }
    redirectToTalerActionHandler(metaTalerUri);
  }
}

function maybeHandleMetaTalerSupport() {
  if (talerSupportFlags) {
    // Already loaded.
    return;
  }
  const meta = document.head.querySelector("meta[name=taler-support]");
  if (!meta || !(meta instanceof HTMLMetaElement)) {
    return;
  }
  const content = meta.getAttribute("content");
  if (!content) {
    return;
  }
  const features = content.split(",");
  talerSupportFlags = {
    debugEnabled: meta.getAttribute("debug") === "true",
    hijackEnabled: features.indexOf("uri") >= 0,
    callbackEnabled: features.indexOf("callback") >= 0,
    talerApiEnabled: features.indexOf("api") >= 0,
  };
  loadScript();
}

async function callBackground<Op extends keyof ExtensionOperations>(
  operation: Op,
  payload: ExtensionOperations[Op]["request"],
): Promise<ExtensionOperations[Op]["response"]> {
  const message: MessageFromExtension<Op> = {
    channel: "extension",
    operation,
    payload,
  };

  const response = await sendMessageToBackground(message);
  if (response.type === "error") {
    throw new Error(`Background operation "${operation}" failed`);
  }
  return response.result as any;
}

async function sendMessageToBackground<Op extends keyof ExtensionOperations>(
  message: MessageFromExtension<Op>,
): Promise<MessageResponse> {
  const messageWithId = { ...message, id: `ld:${nextMessageIndex++ % 1000}` };

  if (!chrome.runtime.id) {
    return Promise.reject(new Error("wallet-core not available"));
  }
  return new Promise<any>((resolve, reject) => {
    logger.debug(
      "send operation to the wallet background",
      message,
      chrome.runtime.id,
    );
    let timedout = false;
    const timerId = setTimeout(() => {
      timedout = true;
      reject(new Error(`wallet-core timeout ${message.operation}`));
    }, 20 * 1000);
    try {
      chrome.runtime.sendMessage(messageWithId, (backgroundResponse) => {
        if (timedout) {
          return false; // already rejected
        }
        clearTimeout(timerId);
        if (chrome.runtime.lastError) {
          reject(chrome.runtime.lastError.message);
        } else {
          resolve(backgroundResponse);
        }
        // return true to keep the channel open
        return true;
      });
    } catch (e) {
      console.log(e);
    }
  });
}

async function start() {
  const documentDocTypeIsHTML =
    window.document.doctype && window.document.doctype.name === "html";
  const suffixIsNotXMLorPDF =
    !window.location.pathname.endsWith(".xml") &&
    !window.location.pathname.endsWith(".pdf");
  const rootElementIsHTML =
    document.documentElement.nodeName &&
    document.documentElement.nodeName.toLowerCase() === "html";

  // safe check, if one of this is true then taler handler is not useful
  // or not expected
  const shouldNotInject =
    !documentDocTypeIsHTML || !suffixIsNotXMLorPDF || !rootElementIsHTML;
  // do not run everywhere, this is just expected to run on site
  // that are aware of taler
  if (shouldNotInject) return;

  const injectSettings = await callBackground(
    "getInjectionSettings",
    undefined,
  );

  await waitHeadReady();

  const checkMeta = () => {
    if (injectSettings.autoOpen) {
      maybeHandleMetaTalerUri();
    }
    if (injectSettings.injectTalerSupport) {
      maybeHandleMetaTalerSupport();
    }
  };

  checkMeta();

  const observerCallback = function (
    mutationsList: MutationRecord[],
    observer: MutationObserver,
  ) {
    for (const mutation of mutationsList) {
      if (mutation.type === "childList") {
        mutation.addedNodes.forEach((node) => {
          if (node instanceof Element && node.tagName === "META") {
            checkMeta();
          }
          if (talerSupportFlags) {
            observer.disconnect();
            return;
          }
        });
      }
    }
  };
  const observer = new MutationObserver(observerCallback);
  observer.observe(document.head, { childList: true, subtree: false });

  // Listen for notifications from the webext
  const notificationPort = chrome.runtime.connect({ name: "notifications" });
  notificationPort.onMessage.addListener((e: MessageFromBackend) => {
    if (
      e.type === "web-extension" &&
      e.notification.type === "settings-change"
    ) {
      const settings = e.notification.currentValue;
      injectSettings.autoOpen = settings.autoOpen;
      checkMeta();
    }
  });
}

/**
 * Tries to find HEAD tag ASAP and report.
 */
async function waitHeadReady(): Promise<void> {
  if (document.head) {
    return;
  }
  return new Promise((resolve, reject) => {
    const obs = new MutationObserver(async function (mutations) {
      mutations.forEach((mut) => {
        if (mut.type !== "childList") {
          return;
        }
        mut.addedNodes.forEach((added) => {
          if (added instanceof HTMLHeadElement) {
            resolve();
            obs.disconnect();
          }
        });
      });
    });

    obs.observe(document, {
      childList: true,
      subtree: true,
      attributes: false,
    });
  });
}

if (!("contentScriptDidRun" in window)) {
  (window as any).contentScriptDidRun = true;
  start();
}
