// Pulse — a dashboard — entry point of a 2-file example.
// The rest of it: dev/cf/app/dashboard/Metrics.java
// All of them, with the documentation: /examples/app-dashboard.md

package dev.cf.app.dashboard;

import dev.cf.Described;
import dev.cf.Tones;
import dev.cf.app.AppBar;
import dev.cf.app.AppTheme;

import dev.crossfly.ui.*;
import dev.crossfly.ui.chart.*;
import dev.crossfly.ui.color.*;
import dev.crossfly.ui.color.ColorUtils;
import dev.crossfly.ui.graphics.*;
import dev.crossfly.ui.layout.*;
import dev.crossfly.ui.style.*;

import static dev.crossfly.ui.Widgets.*;

/**
 * <b>Pulse</b> — the dashboard shape: one screen, read rather than operated.
 *
 * <pre>
 * gradlew lwjgl3:run --args="classname dev.cf.app.dashboard.DashboardApp"
 * </pre>
 *
 * <h2>What makes a dashboard different from a listing</h2>
 *
 * <p>A listing is scanned for one row. A dashboard is read in a fixed order, and the order is the
 * design: <b>headline, then supporting figures, then the shape over time, then the breakdown</b>.
 * A reader who has to hunt for the number the screen exists to show is looking at a chart gallery,
 * not a dashboard.</p>
 *
 * <p>Three decisions carry most of the weight here.</p>
 *
 * <h3>1 · One period, one snapshot, one rebuild</h3>
 *
 * <p>The period chips are the only control. Changing one takes a fresh {@link Metrics} and rebuilds
 * the body from it. Patching the pieces individually is faster and is how a dashboard ends up with
 * a KPI from March next to a chart from April — the failure nobody notices in review and everybody
 * notices in a meeting.</p>
 *
 * <h3>2 · Semantic colour is separate from the accent</h3>
 *
 * <p>Up and down are read at a glance and must not be the brand colour, or "on brand" and "doing
 * well" become the same signal. They come from theme tokens all the same
 * ({@code Styles.primaryColor}, {@code secondary()}), so the whole screen still follows the skin
 * and dark mode.</p>
 *
 * <h3>3 · A number states its own units and its own comparison</h3>
 *
 * <p>"12,480" is not information. "12,480, up 12.4% on last week" is. Every KPI here carries a
 * caption saying what it counts, and the headline carries its delta.</p>
 */
public class DashboardApp extends Window implements Described
{
   private Metrics.Period period = Metrics.Period.WEEK;
   private Metrics data;

   private Component body;
   private Chip[] chips;

   @Override
   public String title()
   {
      return "Pulse — a dashboard";
   }

   @Override
   public String note()
   {
      return "One screen, read rather than operated: a headline number, a KPI grid and charts,"
         + " all re-fed by the single control that changes the period.";
   }

   /** Hands the palette back to whatever was in force before this app opened. */
   @Override
   public void onRemove()
   {
      super.onRemove();
      AppTheme.leave();
   }

   @Override
   public void onAdd(boolean firstTime) throws Exception
   {
      if (!firstTime)
         return;
      // The brand FIRST: fullscreenColor below reads a token, and reading it before the palette
      // is declared bakes in the skin's neutral grey for the life of the window.
      AppTheme.enter();
      // The PAGE, not the surface. Cards, buttons and fields are all drawn on the surface, so a
      // page painted the same colour makes every one of their edges disappear. Read through
      // onRestyle so a light/dark flip re-derives it instead of keeping the old mode's value.
      fullscreenColor = AppTheme.pageColor();
      onRestyle(() -> fullscreenColor = AppTheme.pageColor());
      setLayout(new VerticalLayout(Position.START, Position.START));

      add(0f, new AppBar("Pulse", isRoot() ? null : this::hide));
      add(0f, periodRow());

      body = new Component();
      body.setLayout(new VerticalLayout(Position.START, Position.START));
      body.setScrollable(ScrollDirection.VERTICAL);
      body.getStyle()
         .ingap(Gap.ofFont(1f, .5f, 1f, 1f));
      body.testTag("dash.body");
      add(LAY_FILL, body);

      rebuild();
   }

   /** The one control. Chips rather than a dropdown: three options a thumb can reach. */
   private Component periodRow() throws Exception
   {
      Component row = new Component();
      row.setLayout(new HorizontalLayout(Position.START, Position.CENTER));
      row.getStyle()
         .ingap(Gap.ofFont(1f, .5f, 1f, .5f));

      Metrics.Period[] all = Metrics.Period.values();
      chips = new Chip[all.length];
      for (int i = 0; i < all.length; i++)
      {
         final Metrics.Period p = all[i];
         Chip c = new Chip(p.label);
         c.testTag("dash.period." + p.name()
            .toLowerCase());
         c.setSelected(p == period);
         c.onAction(e ->
         {
            period = p;
            for (Chip other : chips)
               other.setSelected(false);
            c.setSelected(true);
            rebuild();
            Screen.repaint();
         });
         c.getStyle()
            .outgap(Gap.ofFont(0, 0, .4f, 0));
         chips[i] = c;
         row.add(LAY_AUTO, c);
      }
      return row;
   }

   /** Takes a snapshot and builds the whole body from it. Never partially. */
   private void rebuild()
   {
      try
      {
         data = Metrics.of(period);
         body.removeAll();
         // removeAll() drops the layout too — put it back before adding anything.
         body.setLayout(new VerticalLayout(Position.START, Position.START));

         body.add(LAY_AUTO, outgap(hero(), 0, 0, 0, 1f));
         body.add(LAY_AUTO, outgap(kpis(), 0, 0, 0, 1.2f));
         body.add(LAY_AUTO, outgap(sectionTitle("Revenue over time"), 0, 0, 0, .5f));
         body.add(LAY_AUTO, outgap(trend(), 0, 0, 0, 1.2f));
         body.add(LAY_AUTO, outgap(sectionTitle("Where it came from"), 0, 0, 0, .5f));
         body.add(LAY_AUTO, channels());

         body.setValidLayout(false);
      }
      catch (Exception e)
      {
         dev.crossfly.util.Debug.handle(e);
      }
   }

   /**
    * The hero: the one number the screen exists to show, its delta, and nothing else.
    *
    * <p>Resisting the fourth item here is most of the work. A hero with four figures in it has no
    * hero, and the reader has to decide which one matters — which is the decision the dashboard was
    * supposed to have made for them.</p>
    */
   private Component hero() throws Exception
   {
      Card c = new Card();
      c.setLayout(new VerticalLayout(Position.START, Position.START));
      c.testTag("dash.hero");

      c.add(LAY_AUTO, label("REVENUE · " + period.label.toUpperCase(), 1f, true,
         Styles.captionColor, Position.START));

      Label big = label(data.revenue(), 3f, true, Styles.textColor, Position.START);
      big.testTag("dash.revenue");
      c.add(LAY_AUTO, outgap(big, 0, .2f, 0, .3f));

      Component deltaRow = new Component();
      deltaRow.setLayout(new HorizontalLayout(Position.START, Position.CENTER));
      PalColor tone = data.deltaIsGood() ? Styles.primaryColor : secondary();
      deltaRow.add(LAY_AUTO, matglyph(data.deltaIsGood() ? 0xE5D8 : 0xE5DB, tone, 1.2f));
      Label d = label(data.delta() + " on the previous period", 1f, true, tone, Position.START);
      d.testTag("dash.delta");
      deltaRow.add(LAY_AUTO, outgap(d, .3f, 0, 0, 0));
      c.add(LAY_AUTO, deltaRow);

      return c;
   }

   /**
    * The supporting figures.
    *
    * <p>A {@link CardGrid} rather than a fixed row: on a phone these stack, on a tablet they sit
    * side by side, and neither case needed a width check or an orientation listener. Reflow is the
    * framework's job.</p>
    */
   private Component kpis() throws Exception
   {
      CardGrid g = new CardGrid().columns(2, 4)
         .cardWidth(11f)
         .gap(.6f);
      g.testTag("dash.kpis");
      g.card(kpi("Orders", "" + data.orders, "closed in the period", 0xE8CC));
      g.card(kpi("Customers", "" + data.customers, "who bought at least once", 0xE7EF));
      g.card(kpi("Average order", Metrics.money(data.orders == 0 ? 0
         : data.revenueCents / data.orders), "revenue ÷ orders", 0xE263));
      g.card(gauge());
      return g;
   }

   private Component kpi(String title, String value, String caption, int glyph) throws Exception
   {
      Card c = new Card();
      c.setLayout(new VerticalLayout(Position.START, Position.START));
      c.testTag("dash.kpi." + title.toLowerCase()
         .replace(' ', '.'));
      c.add(LAY_AUTO, matglyph(glyph, Styles.primaryColor, 1.4f));
      c.add(LAY_AUTO, outgap(label(value, 2f, true, Styles.textColor, Position.START), 0, .3f, 0,
         .1f));
      c.add(LAY_AUTO, label(title, 1f, true, Styles.textColor, Position.START));
      // The caption is what turns a number into a fact. It is the first thing cut for space and the
      // first thing missed in a meeting.
      c.add(LAY_AUTO, label(caption, 1f, false, Styles.captionColor, Position.START));
      return c;
   }

   /** The one KPI that is a ratio, so it gets the shape a ratio deserves. */
   private Component gauge() throws Exception
   {
      Card c = new Card();
      c.setLayout(new VerticalLayout(Position.CENTER, Position.CENTER));
      c.testTag("dash.kpi.conversion");
      RadialProgress r = new RadialProgress().progress(data.conversion)
         .text(data.conversionText())
         .caption("converted");
      c.add(LAY_AUTO, r);
      c.add(LAY_AUTO, outgap(label("Conversion", 1f, true, Styles.textColor, Position.CENTER), 0,
         .4f, 0, 0));
      return c;
   }

   /**
    * Revenue and orders on one chart.
    *
    * <p>Two series that share a period belong on one chart; two charts side by side make the reader
    * do the alignment themselves and they will do it wrong. The categories come from the same
    * snapshot as the values, which is what keeps the axis honest when the period changes.</p>
    */
   private Component trend() throws Exception
   {
      Card c = new Card();
      c.setLayout(new VerticalLayout(Position.START, Position.START));

      ComboChart chart = new ComboChart();
      chart.categories(data.labels);
      // The colour is passed explicitly. autoThemeColors below themes the axes, the grid and the
      // plot background, but NOT the series: a series left at colour 0 falls back to
      // ChartView.DEFAULT_COLORS, whose first entry is a dark grey — so the largest thing on the
      // screen ends up being the one thing that ignores the brand.
      chart.addSeries(new ChartView.Series("Revenue", data.revenueSeries,
         Styles.primaryColor.color()).bar());
      // The second series is the SAME hue, shaded towards the text colour rather than a different
      // colour entirely: the two are measuring the same thing over the same period, so they should
      // look related. Shading towards the text and not towards black is what keeps it working in
      // dark mode, where "darker" is the wrong direction.
      // A ROLE rather than the brand dimmed towards the text colour: two measures on one chart
      // have to be told apart without consulting the legend, and the dimmed brand read as grey.
      chart.addSeries(new ChartView.Series("Orders", data.ordersSeries, Tones.highlight().color())
            .line());
      chart.showLegend = true;
      chart.autoThemeColors(true);
      chart.getStyle()
         .autoHeight(Relative.ofFont(14f));
      chart.testTag("dash.trend");
      c.add(LAY_AUTO, chart);
      return c;
   }

   /** The breakdown. A donut, because the question is "what share", not "how much". */
   private Component channels() throws Exception
   {
      Card c = new Card();
      c.setLayout(new VerticalLayout(Position.START, Position.START));

      DonutChart d = new DonutChart();
      // One hue, four steps, darkest first — a share-of-total chart is asking "how much of it",
      // and four unrelated colours make the reader learn a legend to answer that. The ramp is
      // derived from the brand, so it follows a change of BRAND and it follows dark mode.
      int base = Styles.primaryColor.color(), ground = Styles.backColor.color();
      for (int i = 0; i < Metrics.CHANNEL.length; i++)
         d.slice(Metrics.CHANNEL[i], data.channelValues[i],
            ColorUtils.lerpColor(base, ground, i * .21f));
      d.hole(.62f);
      d.autoThemeColors(true);
      d.getStyle()
         .autoHeight(Relative.ofFont(14f));
      d.testTag("dash.channels");
      c.add(LAY_AUTO, d);
      return c;
   }

   private Component sectionTitle(String t) throws Exception
   {
      return label(t, 1.2f, true, Styles.textColor, Position.START);
   }
}
