'use strict';

// import required so Node type-strips this .cts (it has no other import).
import type { Db } from '../types/db';

// SimpleFIN consumer client.
//
// This module handles the protocol-level work: claiming tokens, fetching
// accounts/transactions, and normalizing the raw data into DB row shapes.
//
// Security rules enforced here:
//   - Only HTTPS claim URLs are accepted.
//   - Only HTTPS Access URLs are accepted.
//   - Tokens and Access URLs are never logged.
//   - Error messages are sanitized before being returned to callers.

type Row = Record<string, any>;

function sanitizeErrorMessage(msg: unknown): string {
  if (typeof msg !== 'string') return 'Provider error';
  // Strip embedded HTTP Basic Auth credentials (https://user:pass@host)
  return msg.replace(/https?:\/\/[^@\s]+:[^@\s]+@/gi, 'https://[credentials]@');
}

function sanitizeError(err: any): Error {
  const msg = sanitizeErrorMessage(err?.message || String(err || 'Unknown error'));
  const e = new Error(msg) as any;
  e.code = err?.code || 'SIMPLEFIN_ERROR';
  e.status = err?.status;
  return e;
}

function sanitizeRawData(obj: any): string | null {
  if (!obj || typeof obj !== 'object') return null;
  const safe = JSON.parse(JSON.stringify(obj));
  // Remove org sfin-url which may embed auth
  if (safe.org) delete safe.org['sfin-url'];
  try {
    return JSON.stringify(safe).slice(0, 4096);
  } catch {
    return null;
  }
}

async function claimSetupToken(setupToken: unknown): Promise<string> {
  if (!setupToken || typeof setupToken !== 'string') {
    throw new Error('setupToken is required');
  }

  const token = setupToken.trim();

  // Base64-decode to get the claim URL; handle both raw base64 and data-URLs
  let claimUrl = token;
  try {
    const decoded = Buffer.from(token, 'base64').toString('utf8').trim();
    claimUrl = decoded.startsWith('http') ? decoded : token;
  } catch {
    claimUrl = token;
  }

  // Also accept a raw URL pasted directly
  if (!claimUrl.startsWith('http')) {
    throw new Error('Could not decode setup token into a claim URL');
  }

  if (!claimUrl.startsWith('https://')) {
    throw new Error('Setup token must use a secure HTTPS URL');
  }

  let accessUrl = '';
  try {
    const res = await fetch(claimUrl, { method: 'POST', signal: AbortSignal.timeout(30000) });
    if (res.status === 403) {
      throw new Error('This setup token has already been claimed or is invalid');
    }
    if (!res.ok) {
      throw new Error(`Token claim failed (HTTP ${res.status})`);
    }
    accessUrl = (await res.text()).trim();
  } catch (err) {
    throw sanitizeError(err);
  }

  if (!accessUrl.startsWith('https://')) {
    throw new Error('Provider returned an insecure Access URL — only HTTPS is supported');
  }

  return accessUrl;
}

const FETCH_RETRY_ATTEMPTS = 3;
const FETCH_RETRY_DELAYS = [1000, 2000]; // ms between attempts 1→2 and 2→3

async function fetchAccountsAndTransactions(accessUrl: string, sinceEpoch: number): Promise<any> {
  let url: URL;
  try {
    url = new URL(accessUrl);
  } catch {
    throw new Error('Invalid Access URL');
  }

  const basicAuth = Buffer.from(`${url.username}:${url.password}`).toString('base64');
  const baseUrl = `${url.protocol}//${url.host}${url.pathname.replace(/\/?$/, '')}`;
  // pending=1 asks SimpleFIN to include not-yet-settled transactions (excluded by default).
  const endpoint = `${baseUrl}/accounts?start-date=${Math.floor(sinceEpoch)}&pending=1&version=2`;

  let lastErr: any;
  for (let attempt = 0; attempt < FETCH_RETRY_ATTEMPTS; attempt++) {
    if (attempt > 0) await new Promise((r) => setTimeout(r, FETCH_RETRY_DELAYS[attempt - 1]));

    let res: Response;
    try {
      res = await fetch(endpoint, {
        headers: { Authorization: `Basic ${basicAuth}` },
        signal: AbortSignal.timeout(30000),
      });
    } catch (err) {
      // Network error or timeout — retry unless this was the last attempt
      if (attempt < FETCH_RETRY_ATTEMPTS - 1) {
        lastErr = err;
        continue;
      }
      throw sanitizeError(err);
    }

    if (res.status === 403) {
      throw Object.assign(new Error('SimpleFIN access has been revoked — please reconnect'), {
        code: 'SIMPLEFIN_REVOKED',
      });
    }
    if (!res.ok) {
      const err = new Error(`SimpleFIN fetch failed (HTTP ${res.status})`);
      // Retry transient server errors; surface client errors immediately
      if (attempt < FETCH_RETRY_ATTEMPTS - 1 && res.status >= 500) {
        lastErr = err;
        continue;
      }
      throw sanitizeError(err);
    }

    let data: any;
    try {
      data = await res.json();
    } catch (err) {
      throw sanitizeError(err);
    }

    // Surface any connection-level errors from the errlist so callers can log them
    if (Array.isArray(data.errlist) && data.errlist.length > 0) {
      const msgs = data.errlist
        .map((e: any) => sanitizeErrorMessage(e.message || e.msg || e.code || 'Unknown error'))
        .join('; ');
      data._errlistSummary = msgs;
    }
    return data;
  }

  throw sanitizeError(lastErr);
}

function normalizeAccount(rawAccount: any, dataSourceId: number, userId: number): Row {
  const balance =
    rawAccount.balance != null ? Math.round(parseFloat(rawAccount.balance) * 100) : null;
  const availableBalance =
    rawAccount['available-balance'] != null
      ? Math.round(parseFloat(rawAccount['available-balance']) * 100)
      : null;

  return {
    user_id: userId,
    data_source_id: dataSourceId,
    provider_account_id: String(rawAccount.id),
    name: String(rawAccount.name || 'Account').slice(0, 255),
    org_name:
      rawAccount.org?.name || rawAccount.conn_name
        ? String(rawAccount.org?.name || rawAccount.conn_name).slice(0, 255)
        : null,
    account_type: null,
    currency: rawAccount.currency ? String(rawAccount.currency).slice(0, 10) : 'USD',
    balance: Number.isFinite(balance) ? balance : null,
    available_balance: Number.isFinite(availableBalance) ? availableBalance : null,
    raw_data: sanitizeRawData(rawAccount),
  };
}

// accountCurrency: currency string from the parent account (e.g. "USD", "EUR").
// accountId: raw SimpleFIN account id — used in the stable dedup key so the key
//   survives disconnect/reconnect (data_source_id is intentionally omitted).
function normalizeTransaction(
  rawTx: any,
  localAccountId: any,
  dataSourceId: number,
  userId: number,
  accountId: any,
  accountCurrency: any,
): Row {
  const amount = Math.round(parseFloat(rawTx.amount) * 100);
  // Pending transactions report posted = 0 (or omit it) until they settle.
  const isPending = rawTx.pending === true || rawTx.pending === 1;
  // NOTE: deliberately a UTC slice, not localDateString(). SimpleFIN encodes
  // the posted *date* as an epoch at UTC midnight, so the UTC calendar day IS
  // the bank's posting date; converting to server-local time would shift it
  // back a day for any timezone west of UTC.
  const postedDate =
    rawTx.posted && !isPending ? new Date(rawTx.posted * 1000).toISOString().slice(0, 10) : null;
  const transactedAt = rawTx['transacted_at']
    ? new Date(rawTx['transacted_at'] * 1000).toISOString()
    : null;

  // Format: simplefin:{simplefin_account_id}:{tx.id}  (no data_source_id — stable across reconnects)
  const providerTxId = `simplefin:${accountId}:${rawTx.id}`;

  return {
    user_id: userId,
    data_source_id: dataSourceId,
    account_id: localAccountId,
    provider_transaction_id: providerTxId,
    source_type: 'provider_sync',
    posted_date: postedDate,
    transacted_at: transactedAt,
    amount: Number.isFinite(amount) ? amount : 0,
    currency: accountCurrency ? String(accountCurrency).slice(0, 10) : 'USD',
    description: rawTx.description ? String(rawTx.description).slice(0, 500) : null,
    payee: rawTx.payee ? String(rawTx.payee).slice(0, 255) : null,
    memo: rawTx.memo ? String(rawTx.memo).slice(0, 500) : null,
    match_status: 'unmatched',
    ignored: 0,
    pending: isPending ? 1 : 0,
    raw_data: sanitizeRawData(rawTx),
  };
}

module.exports = {
  claimSetupToken,
  fetchAccountsAndTransactions,
  normalizeAccount,
  normalizeTransaction,
  sanitizeError,
  sanitizeErrorMessage,
};
