/*
 This file is part of GNU Taler
 (C) 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,
  assertUnreachable,
  encodeCrock,
  hashTokenIssuePub,
  j2s,
  Logger,
  MerchantContractInputType,
  MerchantContractTermsV1,
  MerchantContractTokenDetails,
  MerchantContractTokenKind,
  PaymentTokenAvailabilityDetails,
  TalerProtocolTimestamp,
  TokenAvailabilityHint,
} from "@gnu-taler/taler-util";
import {
  timestampProtocolFromDb,
  TokenRecord,
  WalletDbReadOnlyTransaction,
} from "./db.js";
import { WalletExecutionContext } from "./index.js";

const logger = new Logger("tokenSelection.ts");

export interface SelectPayTokensRequest {
  proposalId: string;
  choiceIndex: number;
  contractTerms: MerchantContractTermsV1;
}

export interface SelectPayTokensAllChoicesRequest {
  proposalId: string;
  contractTerms: MerchantContractTermsV1;
}

export type SelectPayTokensResult =
  | {
    type: "failure",
    details: PaymentTokenAvailabilityDetails,
  }
  | {
    type: "success",
    tokens: TokenRecord[],
    details: PaymentTokenAvailabilityDetails,
  };

export enum TokenMerchantVerificationResult {
  /**
   * Merchant is trusted/expected.
   *
   * Can be used automatically.
   */
  Automatic = "automatic",

  /**
   * Token used against untrusted merchant.
   *
   * User should not be allowed to use it.
   */
  Untrusted = "untrusted-domain",

  /**
   * Token used against unexpected merchant.
   *
   * User should be warned before using.
   */
  Unexpected = "unexpected-domain",
}

/**
 * Verify that merchant URL matches `trusted_domains' or
 * `expected_domains' in the token family.
 *
 * Format: FQD with optional *. (multi-level) wildcard at the beginning.
 * Format: single * alone (catch-all).
 */
export function verifyTokenMerchant(
  merchantBaseUrl: string,
  tokenMerchantBaseUrl: string,
  tokenDetails: MerchantContractTokenDetails,
): TokenMerchantVerificationResult {
  const parsedUrl = new URL(merchantBaseUrl);
  const merchantDomain = parsedUrl.hostname.toLowerCase();

  const parsedTokenUrl = new URL(tokenMerchantBaseUrl);
  const tokenDomain = parsedTokenUrl.hostname.toLowerCase();

  const domains: string[] = [];
  switch (tokenDetails.class) {
    case MerchantContractTokenKind.Discount:
      domains.push(...tokenDetails.expected_domains);
      break;
    case MerchantContractTokenKind.Subscription:
      domains.push(...tokenDetails.trusted_domains);
      break;
  }

  if (domains.find(t => t === "*")) {
    // If catch-all (*) is present, token can be spent anywhere
    return TokenMerchantVerificationResult.Automatic;
  } else if (merchantDomain === tokenDomain) {
    // Tokens are always spendable on their merchant of origin
    return TokenMerchantVerificationResult.Automatic;
  } else if (domains.length === 0) {
    // If not the merchant of origin, but no domains were specified
    // in the token details, it cannot/should not be spent.
    switch (tokenDetails.class) {
      case MerchantContractTokenKind.Discount:
        return TokenMerchantVerificationResult.Unexpected;
      case MerchantContractTokenKind.Subscription:
        return TokenMerchantVerificationResult.Untrusted;
      default:
        assertUnreachable(tokenDetails);
      }
  }

  var warning = true;
  const regex = new RegExp("^(\\*\\.)?([\\w\\d]+\\.)+[\\w\\d]+$");
  for (let domain of domains) {
    domain = domain.toLowerCase();
    if (!regex.test(domain))
      throw new Error("assertion failed");
    if (warning) {
      // If the two domains match exactly, no warning.
      // If the domain has a wildcard, do multi-level matching.
      warning = domain !== merchantDomain && (
        domain.startsWith("*.")
          && !merchantDomain.endsWith(domain.slice(2))
      );
    }
  }

  if (warning) {
    switch (tokenDetails.class) {
      case MerchantContractTokenKind.Discount:
        return TokenMerchantVerificationResult.Unexpected;
      case MerchantContractTokenKind.Subscription:
        return TokenMerchantVerificationResult.Untrusted;
      default:
        assertUnreachable(tokenDetails);
    }
  }

  return TokenMerchantVerificationResult.Automatic;
}

export async function selectPayTokensInTx(
  tx: WalletDbReadOnlyTransaction<
    [
      "tokens",
      "purchases",
    ]
  >,
  req: SelectPayTokensRequest,

): Promise<SelectPayTokensResult> {
  if (logger.shouldLogTrace()) {
    logger.trace(`selecting tokens for ${j2s(req)}`);
  }

  const proposal = await tx.purchases.get(req.proposalId);
  if (!proposal) {
    throw Error(`proposal ${req.proposalId} could not be found`);
  }

  var tokensRequested = 0;
  const inputTokens: {[slug: string]: {
    records: TokenRecord[],
    requested: number,
  }} = {};

  const inputs = req.contractTerms.choices[req.choiceIndex].inputs;
  const tokenIssuePubs: string[] = [];

  for (const slug in req.contractTerms.token_families) {
    const requested = inputs
      .filter(i => i.type === MerchantContractInputType.Token)
      .filter(i => i.token_family_slug === slug)
      .reduce((a, b) => a + (b.count ?? 1), 0);

    if (requested > 0) {
      tokensRequested += requested;
      inputTokens[slug] = {records: [], requested};
      const keys = req.contractTerms.token_families[slug].keys;
      for (const key of keys) {
        const keyHash = encodeCrock(hashTokenIssuePub(key));
        if (!tokenIssuePubs.includes(keyHash)) {
          tokenIssuePubs.push(keyHash);
          const t = await tx.tokens.indexes.byTokenIssuePubHash.getAll(keyHash);
          inputTokens[slug].records.push(...t);
        }
      }

      logger.trace(
        `found total of ${inputTokens[slug].records.length} records for token family ${slug}, `
          + `out of ${requested} requested`
      );
    }
  }

  return selectTokenCandidates(
    inputTokens,
    tokensRequested,
    proposal.merchantBaseUrl,
  );
}

export async function selectPayTokens(
  wex: WalletExecutionContext,
  req: SelectPayTokensRequest,
): Promise<SelectPayTokensResult> {
  return await wex.db.runReadOnlyTx(
    {
      storeNames: [
        "tokens",
        "purchases",
      ]
    },
    async (tx) => {
      return selectPayTokensInTx(tx, req);
    }
  );
}

export function selectTokenCandidates(
  inputTokens: {[slug: string]: {
    records: TokenRecord[],
    requested: number,
  }},
  tokensRequested: number,
  merchantBaseUrl: string,
): SelectPayTokensResult {
  const details: PaymentTokenAvailabilityDetails = {
    tokensRequested,
    tokensAvailable: 0,
    tokensUnexpected: 0,
    tokensUntrusted: 0,
    perTokenFamily: {},
  };

  var insufficient = false;
  const tokens: TokenRecord[] = [];

  for (const slug in inputTokens) {
    const {records, requested} = inputTokens[slug];

    details.perTokenFamily[slug] = {
      requested,
      available: 0,
      unexpected: 0,
      untrusted: 0,
    };

    // Selection algorithm:
    // - filter out spent tokens (i.e. no transactionId)
    // - filter out expired/not-yet-valid tokens
    // - filter out tokens with errors
    // - sort ascending by expiration date
    // - choose the first n tokens in the list
    const usable = records
      .filter(tok => !isTokenInUse(tok))
      .filter(tok => isTokenValid(tok))
      .filter(tok => {
        const res = verifyTokenMerchant(
          merchantBaseUrl,
          tok.merchantBaseUrl,
          tok.extraData,
        );
        switch (res) {
          case TokenMerchantVerificationResult.Automatic:
            return true; // usable
          case TokenMerchantVerificationResult.Unexpected:
            details.perTokenFamily[slug].unexpected += 1;
            return true; // usable
          case TokenMerchantVerificationResult.Untrusted:
            details.perTokenFamily[slug].untrusted += 1;
            return false; // non-usable
          default:
            assertUnreachable(res);
        }
      }).sort((a, b) => a.validBefore - b.validBefore);

    details.perTokenFamily[slug].available = usable.length;
    details.tokensAvailable += details.perTokenFamily[slug].available;
    details.tokensUnexpected += details.perTokenFamily[slug].unexpected;
    details.tokensUntrusted += details.perTokenFamily[slug].untrusted;

    // calculate token availability hint
    const perTokenFamily = details.perTokenFamily[slug];
    let hint: TokenAvailabilityHint | undefined;
    if (perTokenFamily.available < perTokenFamily.requested) {
      if (perTokenFamily.untrusted > 0) {
        hint = TokenAvailabilityHint.MerchantUntrusted;
      } else {
        hint = TokenAvailabilityHint.WalletTokensAvailableInsufficient;
      }

      insufficient = true;
      details.perTokenFamily[slug].causeHint = hint;
      continue;
    } else {
      if (perTokenFamily.unexpected > 0) {
        hint = TokenAvailabilityHint.MerchantUnexpected;
      }
    }

    details.perTokenFamily[slug].causeHint = hint;
    tokens.push(...usable.slice(0, requested));
  }

  if (insufficient) {
    return {
      type: "failure",
      details,
    };
  }

  return {
    type: "success",
    tokens,
    details,
  };
}

export function isTokenInUse(tok: TokenRecord): boolean {
  return tok.transactionId !== undefined;
}

export function isTokenValid(tok: TokenRecord): boolean {
  return AbsoluteTime.isBetween(
    AbsoluteTime.now(),
    AbsoluteTime.fromProtocolTimestamp(timestampProtocolFromDb(tok.validAfter)),
    AbsoluteTime.fromProtocolTimestamp(timestampProtocolFromDb(tok.validBefore)),
  );
}

export function isTokenValidBetween(
  tok: TokenRecord,
  start: TalerProtocolTimestamp,
  end: TalerProtocolTimestamp,
): boolean {
  return AbsoluteTime.cmp(
    AbsoluteTime.fromProtocolTimestamp(start ?? TalerProtocolTimestamp.now()),
    AbsoluteTime.fromProtocolTimestamp(tok.tokenIssuePub.signature_validity_start),
  ) <= 0 && AbsoluteTime.cmp(
    AbsoluteTime.fromProtocolTimestamp(end),
    AbsoluteTime.fromProtocolTimestamp(tok.tokenIssuePub.signature_validity_end),
  ) >= 0
}
