/* Meridian — every figure on a screen comes from one of these helpers, so a
   number rendered twice is one function called twice. Each takes the array it
   summarises; none of them holds data. */

const PERIODS = [
  { value: "7", label: "Last 7 days" },
  { value: "30", label: "Last 30 days" },
  { value: "90", label: "Last 90 days" },
];

const sumBy = (rows, key) => rows.reduce((total, row) => total + row[key], 0);

/* Scope. Applied before any figure computes. */
function paymentsInPeriod(payments, days, now) {
  return payments.filter((p) => p.createdAt >= now - days * 24 * 3600e3);
}
function disputesInPeriod(disputes, days, now) {
  return disputes.filter((d) => d.openedAt >= now - days * 24 * 3600e3);
}
function expensesInPeriod(expenses, days, now) {
  return expenses.filter((e) => e.occurredAt >= now - days * 24 * 3600e3);
}

/* Calendar spellings, declared once for the product (ui-runtime's distance
   rules govern event times; a chart bucket is a day, not an event). */
const formatDay = (ts) => new Date(ts).toLocaleDateString("en-GB", { day: "numeric", month: "short" });
const formatDayFull = (ts) => new Date(ts).toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "long" });

/* Day buckets — the spine every chart in the product draws on. One entry per
   day in the window, oldest first, each carrying the rows that fell in it so a
   bar can say what it counts. */
function dayBuckets(now, days) {
  const midnight = new Date(now); midnight.setHours(0, 0, 0, 0);
  const out = [];
  for (let i = days - 1; i >= 0; i--) out.push({ at: midnight.getTime() - i * 864e5 });
  return out;
}
function bucketBy(rows, stamp, now, days) {
  const buckets = dayBuckets(now, days).map((b) => ({ at: b.at, rows: [] }));
  const first = buckets[0].at;
  rows.forEach((row) => {
    const ts = stamp(row);
    if (!ts || ts < first) return;
    const index = Math.floor((ts - first) / 864e5);
    if (buckets[index]) buckets[index].rows.push(row);
  });
  return buckets;
}

/* Payments */
const succeededPayments = (rows) => rows.filter((p) => p.status === "succeeded");
const refundedPayments = (rows) => rows.filter((p) => p.status === "refunded");
const failedPayments = (rows) => rows.filter((p) => p.status === "failed");
/* An attempt is a payment whose outcome is known — pending and authorized have
   no outcome yet, so they are outside this set. */
const settledAttempts = (rows) => rows.filter((p) => ["succeeded", "refunded", "failed"].indexOf(p.status) > -1);
const authorisedAttempts = (rows) => rows.filter((p) => ["succeeded", "refunded"].indexOf(p.status) > -1);

const netVolume = (rows) => sumBy(succeededPayments(rows), "amount") - sumBy(refundedPayments(rows), "amount");
const refundTotal = (rows) => sumBy(refundedPayments(rows), "amount");
function authorisationRate(rows) {
  const attempts = settledAttempts(rows);
  if (!attempts.length) return null;
  return authorisedAttempts(rows).length / attempts.length;
}

/* Method mix over the volume it claims to break down. */
function methodMix(rows, methods) {
  const succeeded = succeededPayments(rows);
  const total = sumBy(succeeded, "amount");
  return methods
    .map((m) => {
      const of = succeeded.filter((p) => p.method === m.value);
      return { method: m.value, label: m.label, count: of.length, amount: sumBy(of, "amount"), share: total ? sumBy(of, "amount") / total : 0 };
    })
    .filter((row) => row.count > 0)
    .sort((a, b) => b.amount - a.amount);
}

/* Top customers, over the array the caller scoped. Share decomposes the same
   net volume the table's own total prints. */
function topCustomers(payments, count) {
  const total = netVolume(payments);
  const byName = {};
  succeededPayments(payments).concat(refundedPayments(payments)).forEach((p) => {
    byName[p.customer] = byName[p.customer] || { customer: p.customer, rows: [], amount: 0 };
    byName[p.customer].rows.push(p);
    byName[p.customer].amount += p.status === "refunded" ? -p.amount : p.amount;
  });
  return Object.keys(byName)
    .map((name) => Object.assign(byName[name], { share: total ? byName[name].amount / total : 0 }))
    .sort((a, b) => b.amount - a.amount)
    .slice(0, count);
}

/* Payouts */
/* The approver is passed in wherever this is called, so the badge, the figure,
   the row and the bulk bar cannot drift apart. */
const approvablePayouts = (payouts, approver) => payouts.filter((p) => PAYOUT_FLAGS.isApprovable(p, approver));
const awaitingApprovalPayouts = (payouts) => payouts.filter(PAYOUT_FLAGS.awaitsApproval);
const pendingPayouts = (payouts) => payouts.filter(PAYOUT_FLAGS.isPending);
const pendingPayoutTotal = (payouts) => sumBy(pendingPayouts(payouts), "amount");
const inTransitTotal = (payouts) => sumBy(payouts.filter((p) => p.status === "in_transit"), "amount");

/* Expenses — the money going out on the workspace's own cards. The receipt rule
   is policy, so every predicate here is handed the policy object rather than
   reaching for one. */
const settledExpenses = (rows) => rows.filter(EXPENSE_FLAGS.isSettled);
const pendingExpenses = (rows) => rows.filter((e) => e.status === "pending");
const expensesAwaitingReview = (rows) => rows.filter(EXPENSE_FLAGS.awaitsReview);
const reviewableExpenses = (rows, policy) => rows.filter((e) => EXPENSE_FLAGS.isReviewable(e, policy));
const expensesMissingReceipt = (rows, policy) => rows.filter((e) => EXPENSE_FLAGS.needsReceipt(e, policy));
const queriedExpenses = (rows) => rows.filter((e) => e.status === "queried");
/* Settled spend only: a pending authorisation has not moved any money yet. */
const cardSpendTotal = (rows) => sumBy(settledExpenses(rows), "amount");

function spendByCategory(rows, categories) {
  const settled = settledExpenses(rows);
  const total = sumBy(settled, "amount");
  return categories
    .map((c) => {
      const of = settled.filter((e) => e.category === c.value);
      return { category: c.value, label: c.label, count: of.length, amount: sumBy(of, "amount"), share: total ? sumBy(of, "amount") / total : 0 };
    })
    .filter((row) => row.count > 0)
    .sort((a, b) => b.amount - a.amount);
}

/* Cards. The limit is per rolling 30 days, so the spend it is measured against
   is too — one window, named once, read by the tile, the meter and the flag. */
const CARD_WINDOW_DAYS = 30;
const expensesOnCard = (expenses, cardId) => expenses.filter((e) => e.cardId === cardId);
function cardUtilisation(card, expenses, now, policy) {
  const window = expensesInPeriod(expensesOnCard(expenses, card.id), CARD_WINDOW_DAYS, now);
  const spent = cardSpendTotal(window);
  return {
    rows: window,
    spent: spent,
    limit: card.spendLimit,
    share: card.spendLimit > 0 ? spent / card.spendLimit : 0,
    over: CARD_FLAGS.isOverLimit(card, spent),
    near: CARD_FLAGS.isNearLimit(card, spent, policy),
  };
}
const liveCards = (cards) => cards.filter((c) => c.status !== "cancelled");
const cardsAtRisk = (cards, expenses, now, policy) => liveCards(cards).filter((c) => {
  const use = cardUtilisation(c, expenses, now, policy);
  return use.over || use.near;
});

/* Money in and out of the balance, one bucket a day. In is settled payments;
   out is payouts that actually landed plus card spend that has settled. The
   caller states that scope in words beside the chart. */
function movementByDay(payments, payouts, expenses, now, days) {
  const inBuckets = bucketBy(succeededPayments(payments), (p) => p.createdAt, now, days);
  const payoutBuckets = bucketBy(payouts.filter((p) => p.status === "paid"), (p) => p.paidAt, now, days);
  const spendBuckets = bucketBy(settledExpenses(expenses), (e) => e.occurredAt, now, days);
  return inBuckets.map((bucket, i) => ({
    at: bucket.at,
    payments: bucket.rows,
    payouts: payoutBuckets[i].rows,
    expenses: spendBuckets[i].rows,
    in: sumBy(bucket.rows, "amount"),
    out: sumBy(payoutBuckets[i].rows, "amount") + sumBy(spendBuckets[i].rows, "amount"),
  }));
}

/* Net volume a day — the same arithmetic netVolume does, bucketed, so the bars
   add back to the headline over the same window. */
function volumeByDay(payments, now, days) {
  return bucketBy(settledAttempts(payments), (p) => p.createdAt, now, days).map((bucket) => ({
    at: bucket.at,
    rows: bucket.rows,
    amount: netVolume(bucket.rows),
    failed: failedPayments(bucket.rows),
  }));
}

/* Disputes */
const openDisputes = (disputes) => disputes.filter(DISPUTE_FLAGS.isOpen);
const disputesNeedingEvidence = (disputes) => disputes.filter(DISPUTE_FLAGS.needsEvidence);
/* The money held back against open disputes — read by the balance card and by
   the disputes card alike. */
const disputeExposure = (disputes) => sumBy(openDisputes(disputes), "amount");

/* Balance. Available is stored; everything else here is computed from the
   payouts and disputes that claim the money. */
function balanceBreakdown(balance, payouts, disputes) {
  const committed = pendingPayoutTotal(payouts);
  const reserved = disputeExposure(disputes);
  return { available: balance.availableCents, committed: committed, reserved: reserved, total: balance.availableCents + committed + reserved };
}

/* The sidebar badge predicates, resolved by name from nav.js. */
const NAV_BADGES = {
  payoutsApprovable: {
    /* "Payouts you can approve now" — held ones and ones above your own
       approval limit are both excluded, and the card that lists them says so in
       words, naming who can release them instead. */
    count: (data) => approvablePayouts(data.payouts, data.approver).length,
    label: "payouts you can approve",
  },
  disputesNeedingEvidence: {
    count: (data) => disputesNeedingEvidence(data.disputes).length,
    label: "disputes needing evidence",
  },
  expensesToReview: {
    /* "Expenses you can approve now" — the ones still waiting on a receipt are
       excluded, and the screen that lists them says so in words. */
    count: (data) => reviewableExpenses(data.expenses, data.policy).length,
    label: "expenses you can approve",
  },
  notesAwaitingReply: {
    /* Notes that mentioned the signed-in user and have had no answer from them
       on that record since — the same predicate the note badge, the thread
       footer and the activity screen read. */
    count: (data) => mentionsAwaiting(data.comments, MERIDIAN_SIGNED_IN).length,
    label: "notes waiting on your reply",
  },
};

/* Notes. Nothing here is a figure about money; the counts and the queue are
   still arrays a reader can name. `peopleOn` is derived from the record's own
   person fields plus its thread — never a stored watcher list. */
const commentsOn = (comments, type, id) => comments.filter((c) => c.record.type === type && c.record.id === id).slice().sort((a, b) => a.at - b.at);
const commentCount = (comments, type, id) => commentsOn(comments, type, id).length;
const peopleOn = (comments, record, members) => {
  const ids = [];
  const add = (id) => { if (id && ids.indexOf(id) === -1 && members.some((m) => m.id === id)) ids.push(id); };
  if (record.type === "card" && record.holderId) add(record.holderId);
  commentsOn(comments, record.type, record.id).forEach((c) => { add(c.authorId); c.mentions.forEach(add); });
  return ids.map((id) => members.find((m) => m.id === id));
};
const mentionsAwaiting = (comments, userId) =>
  comments.filter((c) => COMMENT_FLAGS.isAwaitingReply(c, userId, comments)).slice().sort((a, b) => b.at - a.at);

/* One processing-fee formula in the product: FEE_SCHEDULE over one amount.
   Read by the billing screen's processing line and by the new-payment preview,
   so a fee quoted before the charge and a fee billed after it are one function
   called twice. */
const methodFee = (method, amountCents) => {
  const rate = FEE_SCHEDULE.method[method] || FEE_SCHEDULE.method.card;
  const variable = Math.round(amountCents * rate.rate);
  const capped = rate.capCents ? Math.min(variable, rate.capCents) : variable;
  return capped + (rate.fixedCents || 0);
};

/* Customers. A payment, payout or dispute carries the customer's NAME, so the
   record is found by it and the figures below are sums over the arrays those
   screens render — nothing about a customer's money is stored on the customer. */
const customerByName = (customers, name) => customers.find((c) => c.name === name) || null;
const customerById = (customers, id) => customers.find((c) => c.id === id) || null;
const paymentsFor = (payments, customer) => payments.filter((p) => p.customer === customer.name);
const payoutsFor = (payouts, customer) => payouts.filter((p) => p.destination === customer.name);
const disputesFor = (disputes, customer) => disputes.filter((d) => d.customer === customer.name);
const customerVolume = (payments, customer) => sumBy(succeededPayments(paymentsFor(payments, customer)), "amount");
const customerPaidOut = (payouts, customer) => sumBy(payoutsFor(payouts, customer).filter((p) => p.status === "paid"), "amount");
const customerReserved = (disputes, customer) => sumBy(openDisputes(disputesFor(disputes, customer)), "amount");
const customerLastPaymentAt = (payments, customer) => {
  const theirs = paymentsFor(payments, customer);
  return theirs.length ? Math.max.apply(null, theirs.map((p) => p.createdAt)) : null;
};
/* Ordered by what they are worth to us, because that is what the screen is for. */
const customersByVolume = (customers, payments) => customers.slice().sort((a, b) => customerVolume(payments, b) - customerVolume(payments, a));

/* People. A person's spend, cards and approvals are all arrays somewhere else,
   filtered by their id — except approvals, which the payout records name by
   the person's own name at the moment of approval. */
const cardsHeldBy = (cards, member) => cards.filter((c) => c.holderId === member.id);
const expensesBy = (expenses, member) => expenses.filter((e) => e.spenderId === member.id);
const spendBy = (expenses, member) => cardSpendTotal(settledExpenses(expensesBy(expenses, member)));
const approvalsBy = (payouts, member) => payouts.filter((p) => p.approvedBy === member.name);
const customersOwnedBy = (customers, member) => customers.filter((c) => c.ownerId === member.id);

/* Activity — derived entirely from the records: every event below is a field on
   a payment, payout, dispute, expense or card, plus the notes. Nothing about
   activity is stored, so the feed cannot drift from the desk. */
function activityFeed(data, now) {
  const events = [];
  const memberByName = (name) => data.members.find((m) => m.name === name) || null;
  const push = (e) => { if (e.at) events.push(e); };

  data.comments.forEach((c) => push({
    id: "ev_" + c.id, at: c.at, kind: "note", actorId: c.authorId, comment: c,
    title: "wrote a note", route: recordRoute(c.record), record: c.record,
  }));
  data.payouts.forEach((p) => {
    push({ id: "ev_" + p.id + "_approved", at: p.approvedAt, kind: "money", actorId: p.approvedBy ? (memberByName(p.approvedBy) || {}).id : null, title: "approved a payout", body: formatMoney(p.amount) + " to " + p.destination + " · " + p.id, route: "/payouts" });
    push({ id: "ev_" + p.id + "_rejected", at: p.rejectedAt, kind: "money", actorId: p.rejectedBy ? (memberByName(p.rejectedBy) || {}).id : null, title: "rejected a payout", body: formatMoney(p.amount) + " to " + p.destination + " · " + (p.rejectReason || p.id), route: "/payouts" });
    push({ id: "ev_" + p.id + "_returned", at: p.returnedAt, kind: "money", actorId: null, title: "a payout came back", body: formatMoney(p.amount) + " to " + p.destination + " · " + (p.returnReason || p.id), route: "/payouts" });
    push({ id: "ev_" + p.id + "_paid", at: p.paidAt, kind: "money", actorId: null, title: "a payout landed", body: formatMoney(p.amount) + " to " + p.destination + " · " + p.id, route: "/payouts" });
  });
  data.disputes.forEach((d) => {
    push({ id: "ev_" + d.id + "_opened", at: d.openedAt, kind: "money", actorId: null, title: "a dispute was opened", body: formatMoney(d.amount) + " · " + d.customer + " · " + d.reason, route: recordRoute({ type: "dispute", id: d.id }) });
    push({ id: "ev_" + d.id + "_evidence", at: d.evidenceSubmittedAt, kind: "money", actorId: null, title: "evidence went to the network", body: d.customer + " · " + d.id, route: recordRoute({ type: "dispute", id: d.id }) });
  });
  data.expenses.forEach((e) => {
    push({ id: "ev_" + e.id + "_reviewed", at: e.reviewedAt, kind: "spend", actorId: e.reviewedBy ? (memberByName(e.reviewedBy) || {}).id : null, title: "approved an expense", body: formatMoney(e.amount) + " at " + e.merchant, route: "/expenses" });
    push({ id: "ev_" + e.id + "_queried", at: e.queriedAt, kind: "spend", actorId: e.queriedBy ? (memberByName(e.queriedBy) || {}).id : null, title: "queried an expense", body: formatMoney(e.amount) + " at " + e.merchant + " · " + (e.queryNote || ""), route: "/expenses" });
    push({ id: "ev_" + e.id + "_denied", at: e.deniedAt, kind: "spend", actorId: e.deniedBy ? (memberByName(e.deniedBy) || {}).id : null, title: "denied an expense", body: formatMoney(e.amount) + " at " + e.merchant + " · " + (e.denyReason || ""), route: "/expenses" });
  });
  data.cards.forEach((c) => {
    push({ id: "ev_" + c.id + "_frozen", at: c.frozenAt, kind: "spend", actorId: c.frozenBy ? (memberByName(c.frozenBy) || {}).id : null, title: "froze a card", body: c.label + " · •••• " + c.last4 + " · " + (c.frozenReason || ""), route: recordRoute({ type: "card", id: c.id }) });
    push({ id: "ev_" + c.id + "_cancelled", at: c.cancelledAt, kind: "spend", actorId: c.cancelledBy ? (memberByName(c.cancelledBy) || {}).id : null, title: "cancelled a card", body: c.label + " · •••• " + c.last4 + " · " + (c.cancelReason || ""), route: recordRoute({ type: "card", id: c.id }) });
  });
  return events.filter((e) => e.at <= now).sort((a, b) => b.at - a.at);
}
const ACTIVITY_SCOPES = [
  { value: "all", label: "Everything" },
  { value: "note", label: "Notes" },
  { value: "money", label: "Money in and out" },
  { value: "spend", label: "Card spend" },
];
const activityInScope = (events, scope) => (scope === "all" ? events : events.filter((e) => e.kind === scope));

Object.assign(window, {
  PERIODS, sumBy, paymentsInPeriod, disputesInPeriod, expensesInPeriod,
  formatDay, formatDayFull, dayBuckets, bucketBy,
  succeededPayments, refundedPayments, failedPayments, settledAttempts, authorisedAttempts,
  netVolume, refundTotal, authorisationRate, methodMix, topCustomers,
  approvablePayouts, awaitingApprovalPayouts, pendingPayouts, pendingPayoutTotal, inTransitTotal,
  settledExpenses, pendingExpenses, expensesAwaitingReview, reviewableExpenses, expensesMissingReceipt,
  queriedExpenses, cardSpendTotal, spendByCategory,
  CARD_WINDOW_DAYS, expensesOnCard, cardUtilisation, liveCards, cardsAtRisk,
  movementByDay, volumeByDay,
  openDisputes, disputesNeedingEvidence, disputeExposure, balanceBreakdown, NAV_BADGES,
  commentsOn, commentCount, peopleOn, mentionsAwaiting,
  customerByName, customerById, paymentsFor, payoutsFor, disputesFor,
  customerVolume, customerPaidOut, customerReserved, customerLastPaymentAt, customersByVolume,
  cardsHeldBy, expensesBy, spendBy, approvalsBy, customersOwnedBy,
  activityFeed, activityInScope, ACTIVITY_SCOPES, methodFee,
});
