<?php
/**
 * Charts, drawn on the server.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Analytics;

defined( 'ABSPATH' ) || exit;

/**
 * SVG from PHP (spec §11).
 *
 * ### Why not a charting library
 *
 * The plugin's whole admin JavaScript is around 23 KB. Chart.js is roughly 200
 * KB, and WordPress.org requires it bundled rather than fetched from a CDN — so
 * one widget would multiply the plugin's script weight by nine for every page
 * that loads it. Drawn here instead: no dependency, works with JavaScript off,
 * a couple of kilobytes of markup, and the same approach {@see \TextTango\Admin\Icons}
 * already takes.
 *
 * ### Why the geometry is pure
 *
 * Every way this goes wrong is arithmetic, and arithmetic fails quietly. A
 * series that is all zeros divides by a zero range; a single point has no range
 * at all; an empty series produces a path with no numbers in it, which browsers
 * render as nothing and nobody notices until a customer asks why the chart is
 * blank. So the coordinates are worked out by functions that take numbers and
 * return numbers, and the tests exercise exactly those edges.
 */
final class Chart {

	/**
	 * Coordinate space the shapes are drawn in.
	 *
	 * The SVG scales to its container, so these are proportions rather than
	 * pixels — which is why nothing here needs to know how wide the widget is.
	 */
	public const WIDTH = 300;

	/**
	 * Height of that space.
	 */
	public const HEIGHT = 60;

	/**
	 * Bar geometry for a series of counts.
	 *
	 * Returns one `{x, y, width, height}` per point, in order.
	 *
	 * The scale is taken from the largest value, never from the count of points,
	 * and a largest of zero is treated as "nothing to draw" rather than divided
	 * by. A series of all zeros is a real thing — a month where nothing was
	 * sent — and it has to draw as a flat empty axis rather than as a fatal.
	 *
	 * @param array<int, int> $values  Counts, oldest first.
	 * @param int             $width   Coordinate width.
	 * @param int             $height  Coordinate height.
	 * @param float           $gap     Fraction of each column left as space.
	 * @return array<int, array{x: float, y: float, width: float, height: float}>
	 */
	public static function bars( array $values, int $width = self::WIDTH, int $height = self::HEIGHT, float $gap = 0.25 ): array {
		$count = count( $values );

		if ( $count < 1 ) {
			return array();
		}

		$peak = max( $values );
		$slot = $width / $count;
		$bar  = $slot * ( 1 - $gap );
		$bars = array();

		foreach ( array_values( $values ) as $index => $value ) {
			/*
			 * A zero peak means every value is zero. Scaling by it would be a
			 * division by zero; treating the bars as full height would draw a
			 * solid block and say the opposite of the truth.
			 */
			$tall = $peak > 0 ? ( max( 0, (int) $value ) / $peak ) * $height : 0.0;

			$bars[] = array(
				'x'      => round( ( $index * $slot ) + ( ( $slot - $bar ) / 2 ), 2 ),
				'y'      => round( $height - $tall, 2 ),
				'width'  => round( $bar, 2 ),
				'height' => round( $tall, 2 ),
			);
		}

		return $bars;
	}

	/**
	 * A readable axis for a given peak.
	 *
	 * The single thing that separates a chart you can read from a coloured
	 * shape. A peak of 11 over four gridlines is 0, 2.75, 5.5, 8.25, 11 if the
	 * range is simply divided — numbers nobody reads. Rounded up to a step a
	 * person counts in, it becomes 0, 5, 10, 15.
	 *
	 * Returns the axis maximum and its tick values, lowest first.
	 *
	 * @param int $peak  Largest value in the data.
	 * @param int $lines How many gaps between gridlines.
	 * @return array{max: int, ticks: array<int, int>}
	 */
	public static function scale( int $peak, int $lines = 3 ): array {
		$lines = max( 1, $lines );
		$peak  = max( 0, $peak );

		/*
		 * An empty window still needs an axis. Without this the step is zero,
		 * every tick reads 0, and the area is drawn against a range of nothing —
		 * a division by zero rather than a flat line along the bottom.
		 */
		if ( $peak < 1 ) {
			return array(
				'max'   => $lines,
				'ticks' => range( 0, $lines ),
			);
		}

		$rough = $peak / $lines;
		$power = 10 ** max( 0, (int) floor( log10( max( 1, $rough ) ) ) );

		// 1, 2, 5 and 10 are the steps people count in. Anything else produces an
		// axis that is arithmetically correct and unreadable.
		$step = $power;

		foreach ( array( 1, 2, 5, 10 ) as $multiple ) {
			$step = $multiple * $power;

			if ( $step >= $rough ) {
				break;
			}
		}

		$step = (int) max( 1, $step );
		$max  = (int) ( $step * $lines );

		// A step rounded down would put the peak above the top gridline.
		while ( $max < $peak ) {
			$step += (int) $power;
			$max   = $step * $lines;
		}

		return array(
			'max'   => $max,
			'ticks' => range( 0, $max, $step ),
		);
	}

	/**
	 * Where each segment of a donut starts and how long it is.
	 *
	 * Drawn with `stroke-dasharray` on a circle rather than as arc paths. An arc
	 * path needs trigonometry and a large-arc flag that has to flip once a
	 * segment passes half the ring — miss that and a majority share silently
	 * draws inside out, which is the most common single status there is. Dash
	 * lengths are addition, and addition is checkable.
	 *
	 * @param array<string, float> $shares       Percentages by label, totalling 100.
	 * @param float                $circumference Length once round the ring.
	 * @return array<int, array{label: string, length: float, offset: float}>
	 */
	public static function segments( array $shares, float $circumference ): array {
		$shares = array_filter( $shares, static fn( $share ): bool => $share > 0 );

		if ( array() === $shares ) {
			return array();
		}

		$segments = array();
		$used     = 0.0;
		$last     = array_key_last( $shares );

		foreach ( $shares as $label => $share ) {
			/*
			 * The final segment takes whatever is left rather than its own share.
			 * Each length is rounded for the markup, and three of those roundings
			 * leave the ring a hundredth short of closing — a hairline gap at the
			 * top of the donut, which reads as a rendering fault rather than as
			 * rounding. The same trick `shares()` uses on the percentages.
			 */
			$length = $label === $last
				? $circumference - $used
				: round( ( $share / 100 ) * $circumference, 2 );

			$segments[] = array(
				'label'  => (string) $label,
				'length' => round( $length, 2 ),
				// Negative, because an SVG dash offset runs backwards around the
				// circle. Each segment begins where the last one ended.
				'offset' => round( -$used, 2 ),
			);

			$used += $length;
		}

		return $segments;
	}

	/**
	 * Shares of a whole, as percentages that add up.
	 *
	 * Rounding each share on its own gives 33.3 three times, which is 99.9 and
	 * draws a gap; the largest share absorbs the difference so the total is
	 * always exactly 100 and a stacked bar has no seam in it.
	 *
	 * @param array<string, int> $counts Counts by label.
	 * @return array<string, float>
	 */
	public static function shares( array $counts ): array {
		$counts = array_filter( $counts, static fn( $value ): bool => (int) $value > 0 );
		$total  = array_sum( $counts );

		if ( $total < 1 ) {
			return array();
		}

		$shares = array();

		foreach ( $counts as $label => $count ) {
			$shares[ $label ] = round( ( (int) $count / $total ) * 100, 1 );
		}

		// Give the rounding error to the biggest share, where it is least visible.
		$drift = round( 100 - array_sum( $shares ), 1 );

		if ( 0.0 !== $drift ) {
			$largest            = (string) array_key_first( $shares );
			$shares[ $largest ] = round( $shares[ $largest ] + $drift, 1 );
		}

		return $shares;
	}

	/**
	 * A bar chart as SVG.
	 *
	 * @param array<int, array{date: string, count: int}> $series One point per day.
	 * @param string                                      $label  Accessible name.
	 */
	public static function sparkline( array $series, string $label ): string {
		$values = array_map( static fn( array $point ): int => (int) $point['count'], $series );
		$bars   = self::bars( $values );

		if ( array() === $bars ) {
			return '';
		}

		$shapes = '';

		foreach ( $bars as $index => $bar ) {
			$point = $series[ $index ] ?? array();

			// A zero-height rect draws nothing, so a baseline is given to every
			// column — otherwise a quiet day is indistinguishable from no day.
			$height = max( 1.5, (float) $bar['height'] );
			$top    = self::HEIGHT - $height;

			$shapes .= sprintf(
				'<rect x="%s" y="%s" width="%s" height="%s" rx="1" fill="currentColor" opacity="%s"><title>%s</title></rect>',
				$bar['x'],
				round( $top, 2 ),
				$bar['width'],
				round( $height, 2 ),
				( (int) ( $point['count'] ?? 0 ) ) > 0 ? '1' : '0.25',
				esc_html(
					sprintf(
						/* translators: 1: date, 2: how many messages. */
						_n( '%1$s — %2$s message', '%1$s — %2$s messages', (int) ( $point['count'] ?? 0 ), 'texttango' ),
						(string) ( $point['date'] ?? '' ),
						number_format_i18n( (int) ( $point['count'] ?? 0 ) )
					)
				)
			);
		}

		return sprintf(
			'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" preserveAspectRatio="none" role="img" aria-label="%s" class="tt-chart">%s</svg>',
			self::WIDTH,
			self::HEIGHT,
			esc_attr( $label ),
			$shapes
		);
	}

	/**
	 * The box the chart is drawn in on a full-width page.
	 *
	 * @var int[]
	 */
	public const PAGE_BOX = array( 1200, 280 );

	/**
	 * The box the chart is drawn in inside the dashboard widget.
	 *
	 * @var int[]
	 */
	public const WIDGET_BOX = array( 340, 190 );

	/**
	 * The area chart: gridlines, an axis, a filled line, and a marker.
	 *
	 * ### Why the caller chooses the box
	 *
	 * The SVG is `width: 100%; height: auto`, so the viewBox is stretched to
	 * whatever width it is given and everything inside it scales by the same
	 * factor — including `font-size: 11px` on `.tt-chart-axis`, which is in user
	 * units and not CSS pixels. One fixed box across surfaces that differ by four
	 * times in width therefore produces a chart 400px tall with 22px axis labels
	 * on the wide one, and 6px labels on the narrow one. Neither is a styling
	 * problem that CSS can reach.
	 *
	 * So pick a box close to the width the SVG will really be drawn at, which
	 * keeps one user unit at about one CSS pixel and the 11px label at 11px.
	 * {@see self::PAGE_BOX} and {@see self::WIDGET_BOX} are those two.
	 *
	 * `preserveAspectRatio="none"` with a fixed CSS height would fix the height
	 * and nothing else: it stretches the axis numbers and the dates horizontally
	 * along with the geometry.
	 *
	 * @param array<int, array{date: string, count: int}> $series One point per day.
	 * @param string                                      $label  Accessible name.
	 * @param int                                         $width  viewBox width.
	 * @param int                                         $height viewBox height.
	 */
	public static function area( array $series, string $label, int $width = 640, int $height = 200 ): string {
		$series = array_values( $series );
		$points = count( $series );

		if ( $points < 1 ) {
			return '';
		}

		// Floored rather than trusted: a box narrower than its own gutter would
		// give the plot a negative width, and every coordinate below it a sign.
		$width  = max( 120, $width );
		$height = max( 80, $height );
		$footer = 22;
		$counts = array_map( static fn( array $point ): int => (int) $point['count'], $series );
		$scale  = self::scale( max( $counts ) );

		/*
		 * Margins, because the plot used to fill the whole viewBox and everything
		 * drawn *on* an edge was therefore half outside it: the topmost gridline
		 * label sat at baseline 4 and its ascender reached -4, and the end marker
		 * was centred on the right edge with a radius of 4 past it. Both were
		 * visibly sliced in half.
		 */
		$pad_top   = 10;
		$pad_right = 6;

		/*
		 * The gutter follows the widest label rather than sitting at a fixed 34.
		 * Roughly seven units per character at 11px, plus the eight the label is
		 * offset by and a little air: a peak of 15 keeps the 34 it always had, and
		 * a peak of 1,000 gets the 55 it needs instead of overflowing the left.
		 * A fixed gutter is only ever right for the data it was measured against.
		 */
		$widest = 0;

		foreach ( $scale['ticks'] as $tick ) {
			$widest = max( $widest, strlen( (string) number_format_i18n( $tick ) ) );
		}

		$gutter = 20 + ( 7 * $widest );

		// The zero line and the axis maximum, named rather than implied. Every y
		// below is measured between these two.
		$base   = $height - $footer;
		$top    = $pad_top;
		$span   = max( 1, $base - $top );
		$plot_w = max( 1, $width - $gutter - $pad_right );
		$peak   = max( 1, $scale['max'] );

		$grid    = '';
		$columns = max( 1, $points - 1 );

		foreach ( $scale['ticks'] as $tick ) {
			$y = round( $base - ( ( $tick / $peak ) * $span ), 2 );

			$grid .= sprintf(
				'<line x1="%d" y1="%s" x2="%s" y2="%s" class="tt-chart-grid"/>'
				. '<text x="%d" y="%s" class="tt-chart-axis" text-anchor="end">%s</text>',
				$gutter,
				$y,
				$width - $pad_right,
				$y,
				$gutter - 8,
				round( $y + 4, 2 ),
				esc_html( number_format_i18n( $tick ) )
			);
		}

		$coords = array();

		foreach ( $counts as $index => $count ) {
			$coords[] = array(
				'x' => round( $gutter + ( ( $index / $columns ) * ( $plot_w ) ), 2 ),
				'y' => round( $base - ( ( $count / $peak ) * $span ), 2 ),
			);
		}

		$line = '';

		foreach ( $coords as $index => $point ) {
			$line .= ( 0 === $index ? 'M' : 'L' ) . $point['x'] . ' ' . $point['y'] . ' ';
		}

		// Closed down to the zero line, which is where the axis says nothing is —
		// not to the bottom of the box, which is under the dates.
		$last = end( $coords );
		$area = $line . 'L' . $last['x'] . ' ' . $base . ' L' . $coords[0]['x'] . ' ' . $base . ' Z';

		/*
		 * Three dates. Thirty would overlap into a grey smear.
		 *
		 * Indexed off the last point rather than off $columns. The two are the
		 * same number only from two points up: $columns is the count of gaps and
		 * is floored at 1, so a one-day series asked for $coords[1], which does
		 * not exist — a warning, then a date drawn from null.
		 *
		 * Built as a keyed map so the short series collapse rather than stacking
		 * labels on one x: two points get a start and an end, one point gets a
		 * start alone.
		 */
		$last_index = $points - 1;
		$anchors    = array( 0 => 'start' );

		if ( $last_index > 1 ) {
			$anchors[ intdiv( $last_index, 2 ) ] = 'middle';
		}

		if ( $last_index > 0 ) {
			$anchors[ $last_index ] = 'end';
		}

		$dates = '';

		foreach ( $anchors as $index => $anchor ) {
			$dates .= sprintf(
				// Six up rather than four, so the descender in a date like "3 Aug"
				// sits inside the box instead of on its edge.
				'<text x="%s" y="%d" class="tt-chart-axis" text-anchor="%s">%s</text>',
				$coords[ $index ]['x'],
				$height - 6,
				$anchor,
				esc_html( self::day( (string) ( $series[ $index ]['date'] ?? '' ) ) )
			);
		}

		/*
		 * One hit area per point, so the hover label works anywhere in its column
		 * rather than only on the line itself.
		 *
		 * Clamped to the plot, because a column centred on its point hangs half a
		 * column past the chart at each end — 20px at thirty days, 63px at seven,
		 * and the whole width at one. Transparent, so it never looked wrong; it
		 * simply put an invisible rectangle over the y-axis labels and past the
		 * right edge, and the end points answered to hovers outside the chart.
		 */
		$hits = '';
		$half = $plot_w / $columns / 2;
		$edge = $gutter + $plot_w;

		foreach ( $coords as $index => $point ) {
			$from = max( $gutter, round( $point['x'] - $half, 2 ) );

			$hits .= sprintf(
				'<rect x="%s" y="0" width="%s" height="%s" fill="transparent"><title>%s</title></rect>',
				$from,
				round( min( $edge, $point['x'] + $half ) - $from, 2 ),
				$base,
				esc_html(
					sprintf(
						/* translators: 1: date, 2: how many messages. */
						_n( '%1$s — %2$s message', '%1$s — %2$s messages', $counts[ $index ], 'texttango' ),
						(string) ( $series[ $index ]['date'] ?? '' ),
						number_format_i18n( $counts[ $index ] )
					)
				)
			);
		}

		return sprintf(
			'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" role="img" aria-label="%s" class="tt-chart">'
			. '%s<path d="%s" class="tt-chart-area"/><path d="%s" class="tt-chart-line"/>'
			. '<circle cx="%s" cy="%s" r="4" class="tt-chart-point"/>%s%s</svg>',
			$width,
			$height,
			esc_attr( $label ),
			$grid,
			trim( $area ),
			trim( $line ),
			$last['x'],
			$last['y'],
			$dates,
			$hits
		);
	}

	/**
	 * A date as a short label.
	 *
	 * @param string $date `Y-m-d`.
	 */
	private static function day( string $date ): string {
		$time = strtotime( $date . ' UTC' );

		return false === $time ? $date : wp_date( 'j M', $time );
	}

	/**
	 * A ring showing how a total splits.
	 *
	 * @param array<string, int>    $counts Counts by status.
	 * @param array<string, string> $tones  Status => tone class suffix.
	 * @param string                $label  Accessible name.
	 */
	public static function donut( array $counts, array $tones, string $label ): string {
		$shares = self::shares( $counts );

		if ( array() === $shares ) {
			return '';
		}

		$radius        = 40;
		$circumference = 2 * M_PI * $radius;
		$ring          = '';

		foreach ( self::segments( $shares, $circumference ) as $segment ) {
			$ring .= sprintf(
				'<circle cx="60" cy="60" r="%d" class="tt-chart-arc tt-chart-arc-%s"'
				. ' stroke-dasharray="%s %s" stroke-dashoffset="%s" transform="rotate(-90 60 60)"><title>%s</title></circle>',
				$radius,
				esc_attr( $tones[ $segment['label'] ] ?? 'off' ),
				$segment['length'],
				round( $circumference - $segment['length'], 2 ),
				$segment['offset'],
				esc_html(
					sprintf(
						/* translators: 1: status, 2: percentage. */
						__( '%1$s — %2$s%%', 'texttango' ),
						(string) $segment['label'],
						number_format_i18n( $shares[ $segment['label'] ] ?? 0 )
					)
				)
			);
		}

		return sprintf(
			'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" role="img" aria-label="%s" class="tt-donut">%s</svg>',
			esc_attr( $label ),
			$ring
		);
	}

	/**
	 * What a chart is allowed to emit.
	 *
	 * Its own list rather than the icon one. Charts need `title` inside a shape
	 * for the hover label, and `opacity`, `rx` and `preserveAspectRatio` — and
	 * widening the icon allowlist to fit charts would loosen it for icons too,
	 * which are the thing that renders on every screen.
	 *
	 * @return array<string, array<string, bool>>
	 */
	public static function allowed_html(): array {
		return array(
			'svg'    => array(
				'xmlns'               => true,
				'viewbox'             => true,
				'preserveaspectratio' => true,
				'role'                => true,
				'aria-label'          => true,
				'class'               => true,
			),
			'g'      => array( 'class' => true ),
			'rect'   => array(
				'x'       => true,
				'y'       => true,
				'width'   => true,
				'height'  => true,
				'rx'      => true,
				'fill'    => true,
				'opacity' => true,
				'class'   => true,
			),
			'path'   => array(
				'd'     => true,
				'class' => true,
			),
			'line'   => array(
				'x1'    => true,
				'y1'    => true,
				'x2'    => true,
				'y2'    => true,
				'class' => true,
			),
			'circle' => array(
				'cx'                => true,
				'cy'                => true,
				'r'                 => true,
				'class'             => true,
				'stroke-dasharray'  => true,
				'stroke-dashoffset' => true,
				'transform'         => true,
			),
			'text'   => array(
				'x'           => true,
				'y'           => true,
				'class'       => true,
				'text-anchor' => true,
			),
			'title'  => array(),
		);
	}

	/**
	 * A chart, escaped for output.
	 *
	 * @param array<int, array{date: string, count: int}> $series One point per day.
	 * @param string                                      $label  Accessible name.
	 */
	public static function render( array $series, string $label ): string {
		return wp_kses( self::sparkline( $series, $label ), self::allowed_html() );
	}
}
