'use strict';


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

function sanitizeErrorMessage(msg) {
  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) {
  const msg = sanitizeErrorMessage(err?.message || String(err || 'Unknown error'));
  const e   = new Error(msg);
  e.code    = err?.code || 'SIMPLEFIN_ERROR';
  e.status  = err?.status;
  return e;
}

function sanitizeRawData(obj) {
  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) {
  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;
  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, sinceEpoch) {
  let 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;
  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;
    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;
    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 => sanitizeErrorMessage(e.message || e.msg || e.code || 'Unknown error'))
        .join('; ');
      data._errlistSummary = msgs;
    }
    return data;
  }

  throw sanitizeError(lastErr);
}

function normalizeAccount(rawAccount, dataSourceId, userId) {
  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, localAccountId, dataSourceId, userId, accountId, accountCurrency) {
  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,
};
