/* Marketing Desk — the arithmetic. Every figure on every screen comes from a function
   here, so a number that appears twice is one function called twice. Nothing in
   this file authors a value; it only reads the fixture. */

const sumBy = (rows, key) => rows.reduce((n, r) => n + (r[key] || 0), 0);
const rate = (part, whole) => (whole ? part / whole : null);
const startOfDay = (ts) => { const d = new Date(ts); d.setHours(0, 0, 0, 0); return d.getTime(); };
const memberName = (members, id) => (members.find((m) => m.id === id) || { name: "somebody who has left" }).name;

/* ── Delivery, by day ──────────────────────────────────────────────────────
   One series, two sources: the journey send log, and each broadcast's own
   numbers dropped on the day it went out. The overview headline sums this, the
   chart plots it, and the broadcasts table reads the same broadcast records —
   so the two screens cannot disagree. */
function deliverySeries(data, days, now) {
  const window = Math.min(days, JOURNEY_DELIVERY.length);
  const rows = JOURNEY_DELIVERY.slice(JOURNEY_DELIVERY.length - window);
  const series = rows.map((row, i) => ({
    at: startOfDay(now - (window - 1 - i) * DAY_MS),
    journey: { delivered: row[0], opened: row[1], clicked: row[2], unsubs: row[3] },
    broadcast: { delivered: 0, opened: 0, clicked: 0, unsubs: 0 },
    broadcasts: [],
  }));
  const byDay = {};
  series.forEach((entry) => { byDay[entry.at] = entry; });
  data.broadcasts.filter(BROADCAST_FLAGS.countsTowardDelivery).forEach((b) => {
    const entry = byDay[startOfDay(b.sentAt)];
    if (!entry) return;
    entry.broadcast.delivered += b.delivered;
    entry.broadcast.opened += b.opened;
    entry.broadcast.clicked += b.clicked;
    entry.broadcast.unsubs += b.unsubs;
    entry.broadcasts.push(b);
  });
  series.forEach((e) => {
    e.delivered = e.journey.delivered + e.broadcast.delivered;
    e.opened = e.journey.opened + e.broadcast.opened;
    e.clicked = e.journey.clicked + e.broadcast.clicked;
    e.unsubs = e.journey.unsubs + e.broadcast.unsubs;
  });
  return series;
}

function deliveryTotals(series) {
  const pick = (key) => ({
    delivered: series.reduce((n, e) => n + e[key].delivered, 0),
    opened: series.reduce((n, e) => n + e[key].opened, 0),
    clicked: series.reduce((n, e) => n + e[key].clicked, 0),
    unsubs: series.reduce((n, e) => n + e[key].unsubs, 0),
  });
  const journey = pick("journey"), broadcast = pick("broadcast");
  return {
    journey: journey,
    broadcast: broadcast,
    delivered: journey.delivered + broadcast.delivered,
    opened: journey.opened + broadcast.opened,
    clicked: journey.clicked + broadcast.clicked,
    unsubs: journey.unsubs + broadcast.unsubs,
  };
}

/* ── Audiences ─────────────────────────────────────────────────────────────── */
const audienceById = (audiences, id) => audiences.find((a) => a.id === id) || null;
const audienceName = (audiences, id) => (audienceById(audiences, id) || { name: "an audience that has been deleted" }).name;
const reachableContacts = (workspace) => workspace.contacts - workspace.unsubscribed;

/* What refers to an audience — derived from the journeys and broadcasts that
   name it, so the audiences screen can drill through to them. */
function audienceUses(audience, data) {
  return {
    journeys: data.journeys.filter((j) => j.audienceId === audience.id),
    broadcasts: data.broadcasts.filter((b) => b.audienceId === audience.id),
  };
}
const audienceIsUsed = (audience, data) => {
  const uses = audienceUses(audience, data);
  return uses.journeys.some((j) => j.status === "live") || uses.broadcasts.some((b) => ["scheduled", "sending", "in_review"].indexOf(b.status) > -1);
};

/* ── Journeys ──────────────────────────────────────────────────────────────
   Everything here is arithmetic over the steps: enrolment is who is sitting in
   the journey right now, conversion is who reached the last step out of who
   entered the first. */
const journeyEnrolled = (journey) => sumBy(journey.steps, "at");
const journeyEntered = (journey) => (journey.steps.length ? journey.steps[0].entered : 0);
const journeyFinished = (journey) => (journey.steps.length ? journey.steps[journey.steps.length - 1].completed : 0);
const journeyConversion = (journey) => rate(journeyFinished(journey), journeyEntered(journey));
const journeyFailed = (journey) => sumBy(journey.steps, "failed");
const journeyExited = (journey) => sumBy(journey.steps, "exited");
const journeySendSteps = (journey) => journey.steps.filter((s) => s.kind === "email" || s.kind === "sms");
const stepFailureRate = (step) => rate(step.failed, step.entered);
const stepDropoff = (step) => step.exited + step.failed;

const journeysNeedingAttention = (journeys, now) => journeys.filter((j) => JOURNEY_FLAGS.needsAttention(j, now));
const liveJourneys = (journeys) => journeys.filter((j) => j.status === "live");

/* Why a journey is in the attention list, in words, from the same predicates the
   list filters on. */
function journeyConcern(journey, now) {
  const failing = JOURNEY_FLAGS.failingStep(journey);
  if (failing) return "“" + failing.label + "” is failing to deliver for " + formatPercent(stepFailureRate(failing)) + " of everyone who enters it.";
  if (JOURNEY_FLAGS.isStalled(journey, now)) {
    return journey.lastSendAt
      ? "Live, but nothing has sent since " + formatWhen(journey.lastSendAt, now) + " — the trigger has stopped matching."
      : "Live, but it has never sent anything.";
  }
  return null;
}

/* ── Broadcasts ────────────────────────────────────────────────────────────── */
/* A sent broadcast knows what went out; an unsent one asks its audience. One
   function, so the table cell and the figure never diverge. */
function broadcastRecipients(broadcast, audiences) {
  if (BROADCAST_FLAGS.countsTowardDelivery(broadcast)) return { count: broadcast.sends, projected: false };
  const audience = audienceById(audiences, broadcast.audienceId);
  return { count: audience ? audience.size : 0, projected: true };
}

const broadcastsToReview = (broadcasts, viewer) => broadcasts.filter((b) => BROADCAST_FLAGS.isApprovableBy(b, viewer));
const broadcastsInReview = (broadcasts) => broadcasts.filter((b) => b.status === "in_review");
const sentBroadcasts = (broadcasts, days, now) => broadcasts.filter((b) => BROADCAST_FLAGS.countsTowardDelivery(b) && b.sentAt > now - days * DAY_MS);

const broadcastOpenRate = (broadcast) => (BROADCAST_FLAGS.countsTowardDelivery(broadcast) ? rate(broadcast.opened, broadcast.delivered) : null);
const broadcastClickRate = (broadcast) => (BROADCAST_FLAGS.countsTowardDelivery(broadcast) ? rate(broadcast.clicked, broadcast.delivered) : null);
/* A failed send is a partial one: this is the share that was handed off. */
const broadcastDeliveryRate = (broadcast) => (BROADCAST_FLAGS.countsTowardDelivery(broadcast) ? rate(broadcast.delivered, broadcast.sends) : null);

const broadcastClock = (broadcast) => (broadcast.sentAt ? broadcast.sentAt : broadcast.scheduledAt);

/* ── Content ───────────────────────────────────────────────────────────────
   A template's usage is derived from what points at it. Nothing about usage is
   stored on the template, so archiving one cannot disagree with the journey that
   still sends it. */
const contentById = (content, id) => content.find((c) => c.id === id) || null;
const contentName = (content, id) => (contentById(content, id) || { name: "a template that has been deleted" }).name;

function contentUses(item, data) {
  return {
    journeys: data.journeys.filter((j) => j.steps.some((s) => s.contentId === item.id)),
    broadcasts: data.broadcasts.filter((b) => b.contentId === item.id),
  };
}
const contentUseCount = (item, data) => { const u = contentUses(item, data); return u.journeys.length + u.broadcasts.length; };
const unusedContent = (data) => data.content.filter((c) => CONTENT_FLAGS.isUnused(c, data.journeys, data.broadcasts));
const contentEditedSince = (content, days, now) => content.filter((c) => now - c.updatedAt <= days * DAY_MS);

/* ── Deliverability ────────────────────────────────────────────────────────
   Not time-scoped, and the screen says so: these are every send attempt Marketing Desk
   has recorded — journeys since each was published, broadcasts since each sent. */
const domainById = (domains, id) => domains.find((d) => d.id === id) || null;
const domainName = (domains, id) => (domainById(domains, id) || { domain: "a domain that has been removed" }).domain;
const domainChecks = (domain) => [
  { key: "spf", label: "SPF", value: domain.spf },
  { key: "dkim", label: "DKIM", value: domain.dkim },
  { key: "dmarc", label: "DMARC", value: domain.dmarc },
];
const domainIsAuthenticated = (domain) => domainChecks(domain).every((c) => c.value === "pass");
const authenticatedDomains = (domains) => domains.filter(domainIsAuthenticated);

function sendAttempts(data) {
  const journeys = data.journeys.reduce((n, j) => n + sumBy(journeySendSteps(j), "entered"), 0);
  const broadcasts = sumBy(data.broadcasts.filter(BROADCAST_FLAGS.countsTowardDelivery), "sends");
  return { journeys: journeys, broadcasts: broadcasts, total: journeys + broadcasts };
}

function deliveryFailures(data) {
  const journeys = data.journeys.reduce((n, j) => n + journeyFailed(j), 0);
  const broadcasts = data.broadcasts.filter(BROADCAST_FLAGS.isPartial).reduce((n, b) => n + (b.sends - b.delivered), 0);
  return { journeys: journeys, broadcasts: broadcasts, total: journeys + broadcasts };
}

/* The rows behind the failure figure, worst rate first — this is what makes the
   figure checkable, and every row links to the record it came from. */
function failureSources(data) {
  const rows = [];
  data.journeys.forEach((journey) => {
    journeySendSteps(journey).forEach((step) => {
      if (!step.failed) return;
      rows.push({
        key: journey.id + "/" + step.id,
        label: step.label,
        where: journey.name,
        route: "/journeys/" + journey.id,
        attempted: step.entered,
        failed: step.failed,
      });
    });
  });
  data.broadcasts.filter(BROADCAST_FLAGS.isPartial).forEach((broadcast) => {
    rows.push({
      key: broadcast.id,
      label: broadcast.name,
      where: "Broadcast on " + domainName(data.domains, broadcast.domainId),
      route: "/broadcasts/" + broadcast.id,
      attempted: broadcast.sends,
      failed: broadcast.sends - broadcast.delivered,
    });
  });
  return rows.sort((a, b) => rate(b.failed, b.attempted) - rate(a.failed, a.attempted));
}

/* ── The builders' live previews ───────────────────────────────────────────
   Both builders compute from the real thing rather than describing it: the
   segment preview runs the rules' own predicates over the contact sample, and
   the journey summary is arithmetic over the steps as written. */
const activeSegmentRules = (values) => SEGMENT_RULES.filter((rule) => rule.active(values[rule.id]));

function evaluateSegment(values, contacts, now) {
  const rules = activeSegmentRules(values);
  if (!rules.length) return contacts.slice();
  return contacts.filter((contact) => rules.every((rule) => rule.test(contact, values[rule.id], now)));
}

/* Extrapolated from the sample to the reachable list, rounded to ten because a
   number carried out to the digit would claim a precision the sample cannot
   support. */
const estimateFromSample = (matched, sampleSize, reachable) =>
  (sampleSize ? Math.round(((matched / sampleSize) * reachable) / 10) * 10 : 0);

function describeSegment(values) {
  const rules = activeSegmentRules(values);
  if (!rules.length) return "Everybody reachable — no rule set yet";
  return rules.map((rule) => rule.describe(values[rule.id])).join(" · ");
}

/* What a journey as written would do to somebody entering it today. */
function journeyPlanSummary(steps) {
  const emails = steps.filter((s) => s.kind === "email");
  const waitHours = steps.filter((s) => s.kind === "wait").reduce((n, s) => n + Number(s.waitHours || 0), 0);
  return { steps: steps.length, emails: emails.length, waitHours: waitHours, days: Math.round(waitHours / 24) };
}

/* ── People, notes and activity ───────────────────────────────────────────
   Nothing below stores a total. A person's load is the records that name them, a
   thread is the notes on one record, and the inbox is the threads holding a
   mention that is still owed — one predicate, read by the badge, the section
   count and the row alike. */
const memberById = (members, id) => members.find((m) => m.id === id) || { id: id, name: "somebody who has left", role: "", avatar: null, teamRole: "analyst" };
const initialsOf = (name) => name.split(" ").map((w) => w[0]).slice(0, 2).join("");

/* What one person is responsible for, read off the records rather than stored on
   the person, so a profile and a list can never disagree. */
const personLoad = (data, memberId, now) => {
  const journeys = data.journeys.filter((j) => j.ownerId === memberId);
  const broadcasts = data.broadcasts.filter((b) => b.ownerId === memberId);
  const submitted = data.broadcasts.filter((b) => b.submittedById === memberId && b.status === "in_review");
  const approved = data.broadcasts.filter((b) => b.approvedById === memberId);
  const notes = (data.comments || []).filter((c) => c.authorId === memberId);
  return {
    journeys, broadcasts, submitted, approved, notes,
    liveJourneys: journeys.filter((j) => j.status === "live"),
    attention: journeysNeedingAttention(journeys, now),
    owed: mentionsOwedTo(data.comments || [], memberId),
    /* Delivered by the journeys and sent broadcasts this person owns — the same
       per-record numbers the record screens draw. (sumBy takes a KEY, so the
       nested sums are written out rather than passed a function.) */
    delivered: journeys.reduce((n, j) => n + sumBy(journeySendSteps(j), "completed"), 0)
      + sumBy(broadcasts.filter((b) => b.sentAt), "delivered"),
  };
};

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 mentionsOwedTo = (comments, memberId) => comments.filter((c) => COMMENT_FLAGS.isAwaitingReply(c, memberId, comments));

/* One thread per record that carries notes, newest activity first. `awaiting`
   is the viewer's own owed mentions inside that thread. */
const commentThreads = (data, viewer) => {
  const comments = data.comments || [];
  const keys = [];
  comments.forEach((c) => {
    const key = c.record.type + ":" + c.record.id;
    if (keys.indexOf(key) < 0) keys.push(key);
  });
  return keys.map((key) => {
    const type = key.split(":")[0], id = key.split(":")[1];
    const thread = commentsOn(comments, type, id);
    const people = [];
    thread.forEach((c) => {
      [c.authorId].concat(c.mentions).forEach((p) => { if (people.indexOf(p) < 0) people.push(p); });
    });
    return {
      key, type, id, comments: thread, people,
      title: recordTitle(data, type, id),
      route: recordRoute(type, id),
      lastAt: thread[thread.length - 1].at,
      awaiting: thread.filter((c) => COMMENT_FLAGS.isAwaitingReply(c, viewer.id, comments)),
    };
  }).sort((a, b) => b.lastAt - a.lastAt);
};

/* What a record is CALLED and WHERE it lives come from the record itself, one
   branch per type, so a note can only sit on something that has both. */
const recordTitle = (data, type, id) => {
  const read = {
    journey: () => (data.journeys.find((r) => r.id === id) || {}).name,
    broadcast: () => (data.broadcasts.find((r) => r.id === id) || {}).name,
    audience: () => (data.audiences.find((r) => r.id === id) || {}).name,
    content: () => (data.content.find((r) => r.id === id) || {}).name,
  }[type];
  return (read && read()) || id;
};
const recordRoute = (type, id) => ({
  journey: "/journeys/" + id, broadcast: "/broadcasts/" + id, audience: "/audiences/" + id, content: "/content",
}[type] || "/");

const activityFor = (activity, memberId) => activity.filter((a) => a.actorId === memberId).slice().sort((a, b) => b.at - a.at);
const activityDays = (activity, now) => {
  const rows = activity.slice().sort((a, b) => b.at - a.at);
  const days = [];
  rows.forEach((entry) => {
    const key = startOfDay(entry.at);
    const day = days.find((d) => d.at === key);
    if (day) day.entries.push(entry); else days.push({ at: key, entries: [entry] });
  });
  return days;
};

/* ── The calendar ─────────────────────────────────────────────────────────
   One entry per thing that is dated: a broadcast's send time, and each live
   journey's last send standing for "this runs continuously". Journeys are marked
   `continuous` so a day cell never claims a journey sent once. */
const calendarEntries = (data) => data.broadcasts
  .filter((b) => b.scheduledAt)
  .map((b) => ({ id: b.id, kind: "broadcast", at: b.scheduledAt, name: b.name, status: b.status, route: "/broadcasts/" + b.id, audienceId: b.audienceId }));

const calendarMonth = (data, monthStart, now) => {
  const first = new Date(monthStart);
  const startWeekday = (first.getDay() + 6) % 7;
  const daysInMonth = new Date(first.getFullYear(), first.getMonth() + 1, 0).getDate();
  const entries = calendarEntries(data);
  const cells = [];
  for (let i = 0; i < startWeekday; i++) cells.push(null);
  for (let d = 1; d <= daysInMonth; d++) {
    const at = new Date(first.getFullYear(), first.getMonth(), d).getTime();
    cells.push({
      at, day: d,
      today: startOfDay(now) === at,
      entries: entries.filter((e) => startOfDay(e.at) === at).sort((a, b) => a.at - b.at),
    });
  }
  while (cells.length % 7) cells.push(null);
  return cells;
};

const calendarWeek = (data, weekStart, now) => {
  const entries = calendarEntries(data);
  const days = [];
  for (let i = 0; i < 7; i++) {
    const at = weekStart + i * 864e5;
    days.push({ at, today: startOfDay(now) === at, entries: entries.filter((e) => startOfDay(e.at) === at).sort((a, b) => a.at - b.at) });
  }
  return days;
};

/* ── Contacts ─────────────────────────────────────────────────────────────
   The contact sample is the only contact data there is, and it is a sample — so
   every figure over it says so. Which audiences hold a contact is EVALUATED,
   not stored: the same segment rules the builder previews with. */
const contactById = (contacts, id) => contacts.find((c) => c.id === id) || null;
/* A hand-kept list holds whoever is in it, so membership is the list's own
   sample; a segment is EVALUATED with the same rule predicates the audience
   builder previews with, read off the audience's sampleRule. An audience with no
   sampleRule cannot be evaluated over the sample and is left out rather than
   guessed at. */
const contactAudiences = (data, contact, now) => data.audiences.filter((audience) => {
  if (audience.kind === "list") return (audience.sample || []).indexOf(contact.name) > -1;
  if (!audience.sampleRule) return false;
  return evaluateSegment(audience.sampleRule, [contact], now).length === 1;
});
/* What this contact has been sent: the broadcasts that went to an audience
   holding them, plus the live journeys pointed at one. */
const contactSends = (data, contact, now) => {
  const held = contactAudiences(data, contact, now).map((a) => a.id);
  return {
    broadcasts: data.broadcasts.filter((b) => b.sentAt && held.indexOf(b.audienceId) > -1).sort((a, b) => b.sentAt - a.sentAt),
    journeys: data.journeys.filter((j) => j.status === "live" && held.indexOf(j.audienceId) > -1),
  };
};
const contactBuckets = (contacts, now) => LIFECYCLES.map((life) => ({
  value: life.value, label: life.label,
  contacts: contacts.filter((c) => c.lifecycle === life.value),
}));

/* ── Nav badges ────────────────────────────────────────────────────────────
   Each badge is one predicate over one array, and it is the same predicate the
   rows and the section headings use. */
const NAV_BADGES = {
  journeysNeedingAttention: {
    label: "needing attention",
    count: (data) => journeysNeedingAttention(data.journeys, MD_NOW).length,
  },
  broadcastsToReview: {
    label: "waiting on you",
    count: (data) => broadcastsToReview(data.broadcasts, data.members.find((m) => m.id === MD_SIGNED_IN)).length,
  },
  mentionsOwed: {
    label: "mentions waiting on you",
    count: (data) => mentionsOwedTo(data.comments || [], data.members.find((m) => m.id === MD_SIGNED_IN)).length,
  },
  approvalsWaiting: {
    label: "waiting on you",
    count: (data) => broadcastsToReview(data.broadcasts, data.members.find((m) => m.id === MD_SIGNED_IN)).length,
  },
  domainsUnauthenticated: {
    label: "domains not fully authenticated",
    count: (data) => data.domains.filter((d) => !domainIsAuthenticated(d)).length,
  },
};

Object.assign(window, {
  sumBy, rate, startOfDay, memberName, deliverySeries, deliveryTotals,
  audienceById, audienceName, reachableContacts, audienceUses, audienceIsUsed,
  journeyEnrolled, journeyEntered, journeyFinished, journeyConversion, journeyFailed, journeyExited,
  journeySendSteps, stepFailureRate, stepDropoff, journeysNeedingAttention, liveJourneys, journeyConcern,
  broadcastRecipients, broadcastsToReview, broadcastsInReview, sentBroadcasts,
  broadcastOpenRate, broadcastClickRate, broadcastDeliveryRate, broadcastClock,
  contentById, contentName, contentUses, contentUseCount, unusedContent, contentEditedSince,
  domainById, domainName, domainChecks, domainIsAuthenticated, authenticatedDomains,
  sendAttempts, deliveryFailures, failureSources,
  activeSegmentRules, evaluateSegment, estimateFromSample, describeSegment, journeyPlanSummary,
  memberById, initialsOf, personLoad, commentsOn, mentionsOwedTo, commentThreads, recordTitle, recordRoute,
  activityFor, activityDays, calendarEntries, calendarMonth, calendarWeek,
  contactById, contactAudiences, contactSends, contactBuckets,
  NAV_BADGES,
});
