'use strict';

import type { Db } from '../types/db';

const { getDb } = require('../db/database.cts');
const { getCycleRange, resolveDueDate } = require('./statusService.cts');
const { decorateTransaction } = require('./transactionService.cts');
const { fromCents } = require('../utils/money.mts') as typeof import('../utils/money.mts');

type Row = Record<string, any>;

function suggestionError(
  status: number,
  message: string,
  code: string,
  field: string | null = null,
): Error {
  const err = new Error(message) as any;
  err.status = status;
  err.code = code;
  err.field = field;
  return err;
}

function normalizeId(value: unknown, field: string): number {
  const id = typeof value === 'number' ? value : Number(value);
  if (!Number.isSafeInteger(id) || id <= 0) {
    throw suggestionError(400, `${field} must be a positive integer`, 'VALIDATION_ERROR', field);
  }
  return id;
}

function suggestionId(transactionId: any, billId: any): string {
  return `${transactionId}:${billId}`;
}

function parseSuggestionId(id: unknown): { transactionId: number; billId: number } {
  const match = /^(\d+):(\d+)$/.exec(String(id || '').trim());
  if (!match) {
    throw suggestionError(
      400,
      'Suggestion id must be transactionId:billId',
      'VALIDATION_ERROR',
      'id',
    );
  }
  return {
    transactionId: normalizeId(match[1], 'transaction_id'),
    billId: normalizeId(match[2], 'bill_id'),
  };
}

function textKey(value: unknown): string {
  return String(value || '')
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, ' ')
    .trim();
}

function transactionDate(transaction: Row): string | null {
  const date = transaction.posted_date || String(transaction.transacted_at || '').slice(0, 10);
  return /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null;
}

function dateParts(date: string): { year: number; month: number } {
  const [year, month] = String(date).split('-').map(Number);
  return { year, month };
}

function diffDays(a: string, b: string): number | null {
  const left = new Date(`${a}T00:00:00Z`).getTime();
  const right = new Date(`${b}T00:00:00Z`).getTime();
  if (!Number.isFinite(left) || !Number.isFinite(right)) return null;
  return Math.abs(Math.round((left - right) / 86400000));
}

function amountDollars(transaction: Row): number {
  const cents = Number(transaction.amount);
  return Number.isFinite(cents) ? Math.abs(cents) / 100 : 0;
}

function addAmountScore(score: number, reasons: string[], transaction: Row, bill: Row): number {
  const txAmount = amountDollars(transaction);
  const expected = fromCents(bill.expected_amount) || 0;
  if (txAmount <= 0 || expected <= 0) return score;

  const delta = Math.abs(txAmount - expected);
  const pct = delta / expected;
  if (delta <= 0.01) {
    reasons.push('amount matches');
    return score + 40;
  }
  if (delta <= 1) {
    reasons.push('amount within $1');
    return score + 32;
  }
  if (delta <= 5 || pct <= 0.05) {
    reasons.push('amount close to bill');
    return score + 22;
  }
  if (pct <= 0.15) {
    reasons.push('amount within 15%');
    return score + 12;
  }
  return score;
}

function addDateScore(score: number, reasons: string[], transaction: Row, bill: Row): number {
  const postedDate = transactionDate(transaction);
  if (!postedDate) return score;

  const { year, month } = dateParts(postedDate);
  const dueDate = resolveDueDate(bill, year, month);
  if (!dueDate) return score;

  const distance = diffDays(postedDate, dueDate);
  if (distance === null) return score;
  if (distance <= 1) {
    reasons.push('date within 1 day');
    return score + 25;
  }
  if (distance <= 3) {
    reasons.push(`date within ${distance} days`);
    return score + 20;
  }
  if (distance <= 7) {
    reasons.push('date within 7 days');
    return score + 12;
  }
  return score;
}

// Word-boundary comparison — same logic as billMerchantRuleService.merchantMatches()
function wordBoundaryIncludes(a: string, b: string): boolean {
  if (!a || !b) return false;
  if (a === b) return true;
  try {
    const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    const wb = (s: string) => new RegExp(`(^|\\s)${esc(s)}(\\s|$)`);
    return wb(b).test(a) || wb(a).test(b);
  } catch {
    return false;
  }
}

function addNameScore(score: number, reasons: string[], transaction: Row, bill: Row): number {
  const billName = textKey(bill.name);
  if (!billName) return score;

  const payee = textKey(transaction.payee);
  const description = textKey(transaction.description);
  const memo = textKey(transaction.memo);

  if (payee && wordBoundaryIncludes(payee, billName)) {
    reasons.push('payee contains bill name');
    score += 22;
  }
  if (description && wordBoundaryIncludes(description, billName)) {
    reasons.push('description contains bill name');
    score += 18;
  }
  if (memo && wordBoundaryIncludes(memo, billName)) {
    reasons.push('memo contains bill name');
    score += 8;
  }
  return score;
}

function addPriorMatchScore(
  score: number,
  reasons: string[],
  transaction: Row,
  bill: Row,
  priorMatchKeys: Set<string>,
): number {
  const payee = textKey(transaction.payee);
  const description = textKey(transaction.description);
  if (
    (payee && priorMatchKeys.has(`${bill.id}:payee:${payee}`)) ||
    (description && priorMatchKeys.has(`${bill.id}:description:${description}`))
  ) {
    reasons.push('prior match for this bill');
    return score + 12;
  }
  return score;
}

function hasPaymentInTransactionCycle(db: Db, bill: Row, transaction: Row): boolean {
  const postedDate = transactionDate(transaction);
  if (!postedDate) return false;
  const { year, month } = dateParts(postedDate);
  const range = getCycleRange(year, month, bill);
  if (!range) return false;

  return !!db
    .prepare(
      `
    SELECT 1
    FROM payments
    WHERE bill_id = ?
      AND paid_date BETWEEN ? AND ?
      AND deleted_at IS NULL
    LIMIT 1
  `,
    )
    .get(bill.id, range.start, range.end);
}

function loadCandidateTransactions(
  db: Db,
  userId: number,
  transactionId: number | null = null,
): any[] {
  const params: any[] = [userId];
  const where = [
    't.user_id = ?',
    't.ignored = 0',
    "t.match_status = 'unmatched'",
    't.pending = 0', // don't match/pay against unsettled charges — they can change or vanish
    '(t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1)',
  ];
  if (transactionId) {
    where.push('t.id = ?');
    params.push(transactionId);
  }

  return db
    .prepare(
      `
    SELECT
      t.id, t.user_id, t.data_source_id, t.account_id, t.provider_transaction_id,
      t.source_type, t.transaction_type, t.posted_date, t.transacted_at, t.amount,
      t.currency, t.description, t.payee, t.memo, t.category, t.matched_bill_id,
      t.match_status, t.ignored, t.created_at, t.updated_at,
      ds.type AS data_source_type, ds.provider AS data_source_provider,
      ds.name AS data_source_name, ds.status AS data_source_status,
      fa.name AS account_name, fa.org_name AS account_org_name,
      fa.account_type AS account_type,
      b.name AS matched_bill_name
    FROM transactions t
    LEFT JOIN data_sources ds ON ds.id = t.data_source_id AND ds.user_id = t.user_id
    LEFT JOIN financial_accounts fa ON fa.id = t.account_id AND fa.user_id = t.user_id
    LEFT JOIN bills b ON b.id = t.matched_bill_id AND b.user_id = t.user_id AND b.deleted_at IS NULL
    WHERE ${where.join(' AND ')}
    ORDER BY COALESCE(t.posted_date, substr(t.transacted_at, 1, 10), t.created_at) DESC, t.id DESC
    LIMIT 100
  `,
    )
    .all(...params)
    .map(decorateTransaction);
}

function loadBills(db: Db, userId: number): Row[] {
  return db
    .prepare(
      `
    SELECT b.*, c.name AS category_name
    FROM bills b
    LEFT JOIN categories c ON c.id = b.category_id AND c.deleted_at IS NULL
    WHERE b.user_id = ?
      AND b.deleted_at IS NULL
      AND b.active = 1
    ORDER BY b.name COLLATE NOCASE ASC
  `,
    )
    .all(userId);
}

function loadRejections(db: Db, userId: number): Set<string> {
  const rows = db
    .prepare(
      `
    SELECT transaction_id, bill_id
    FROM match_suggestion_rejections
    WHERE user_id = ?
      AND rejected_at > datetime('now', '-90 days')
  `,
    )
    .all(userId);
  return new Set(rows.map((row: Row) => suggestionId(row.transaction_id, row.bill_id)));
}

function loadPriorMatchKeys(db: Db, userId: number): Set<string> {
  const rows = db
    .prepare(
      `
    SELECT matched_bill_id, payee, description
    FROM transactions
    WHERE user_id = ?
      AND matched_bill_id IS NOT NULL
      AND match_status = 'matched'
      AND ignored = 0
  `,
    )
    .all(userId);
  const keys = new Set<string>();
  for (const row of rows) {
    const payee = textKey(row.payee);
    const description = textKey(row.description);
    if (payee) keys.add(`${row.matched_bill_id}:payee:${payee}`);
    if (description) keys.add(`${row.matched_bill_id}:description:${description}`);
  }
  return keys;
}

function scoreSuggestion(
  transaction: Row,
  bill: Row,
  priorMatchKeys: Set<string>,
): { score: number; reasons: string[] } {
  const reasons: string[] = [];
  let score = 0;
  score = addAmountScore(score, reasons, transaction, bill);
  score = addDateScore(score, reasons, transaction, bill);
  score = addNameScore(score, reasons, transaction, bill);
  score = addPriorMatchScore(score, reasons, transaction, bill, priorMatchKeys);
  return { score: Math.min(score, 100), reasons };
}

function listMatchSuggestions(userId: number, options: Row = {}): any[] {
  const db: Db = getDb();
  const rawTransactionId = options.transactionId ?? options.transaction_id;
  const transactionId = rawTransactionId ? normalizeId(rawTransactionId, 'transaction_id') : null;
  const limit = Math.max(1, Math.min(Number.parseInt(options.limit || '50', 10) || 50, 100));

  const transactions = loadCandidateTransactions(db, userId, transactionId);
  const bills = loadBills(db, userId);
  const rejections = loadRejections(db, userId);
  const priorMatchKeys = loadPriorMatchKeys(db, userId);
  const suggestions: any[] = [];

  for (const transaction of transactions) {
    for (const bill of bills) {
      const id = suggestionId(transaction.id, bill.id);
      if (rejections.has(id)) continue;
      if (hasPaymentInTransactionCycle(db, bill, transaction)) continue;

      const scored = scoreSuggestion(transaction, bill, priorMatchKeys);
      if (scored.score < 20) continue;

      suggestions.push({
        id,
        transactionId: transaction.id,
        billId: bill.id,
        score: scored.score,
        reasons: scored.reasons,
        transaction,
        bill: {
          id: bill.id,
          name: bill.name,
          expected_amount: fromCents(bill.expected_amount),
          due_day: bill.due_day,
          category_name: bill.category_name || null,
        },
      });
    }
  }

  return suggestions
    .sort((a, b) => b.score - a.score || a.bill.name.localeCompare(b.bill.name))
    .slice(0, limit);
}

function rejectMatchSuggestion(userId: number, id: unknown) {
  const db: Db = getDb();
  const parsed = parseSuggestionId(id);

  const transaction = db
    .prepare('SELECT id FROM transactions WHERE id = ? AND user_id = ?')
    .get(parsed.transactionId, userId);
  if (!transaction) {
    throw suggestionError(404, 'Transaction not found', 'NOT_FOUND', 'transaction_id');
  }
  const bill = db
    .prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
    .get(parsed.billId, userId);
  if (!bill) {
    throw suggestionError(404, 'Bill not found', 'NOT_FOUND', 'bill_id');
  }

  db.prepare(
    `
    INSERT INTO match_suggestion_rejections (user_id, transaction_id, bill_id, rejected_at)
    VALUES (?, ?, ?, datetime('now'))
    ON CONFLICT(user_id, transaction_id, bill_id) DO UPDATE SET
      rejected_at = excluded.rejected_at
  `,
  ).run(userId, parsed.transactionId, parsed.billId);

  return {
    success: true,
    id: suggestionId(parsed.transactionId, parsed.billId),
    transactionId: parsed.transactionId,
    billId: parsed.billId,
    rejected: true,
  };
}

// Auto-apply high-confidence suggestions (score >= 80) for a user.
// Called by the background sync worker after each successful source sync.
// Score of 80+ requires at minimum exact amount + date proximity + name signal,
// so false-positive risk is low.
function autoMatchForUser(userId: number): number {
  const { matchTransactionToBill } = require('./transactionMatchService.cts');
  const suggestions = listMatchSuggestions(userId, { limit: 50 });
  let matched = 0;
  for (const s of suggestions) {
    if (s.score < 80) break; // sorted descending — safe to stop early
    try {
      matchTransactionToBill(userId, s.transactionId, s.billId);
      matched++;
    } catch {
      // Already matched, ignored, bill deleted, or date missing — skip silently
    }
  }
  return matched;
}

module.exports = {
  autoMatchForUser,
  listMatchSuggestions,
  parseSuggestionId,
  rejectMatchSuggestion,
  suggestionId,
};
