/**
 * Tests for the motion scoring arithmetic.
 *
 * A qualification score is not a dashboard ornament: the band it lands in is
 * what a seller is told to do about a deal, and the row is append-only
 * evidence that the judgement was made. So the cases below pin the
 * *decisions* — the empty book, the rounding direction, and above all the band
 * boundaries, each of which a plausible-but-wrong implementation gets wrong by
 * exactly one basis point and reports no error about.
 */
import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';
import { DEMAND_STAGES } from '../src/ontology';
import {
  MOTION_BANDS,
  MOTION_KINDS,
  MOTION_KIND_DESCRIPTIONS,
  MOTION_KIND_LABELS,
  MOTION_KIND_STAGES,
  isArtifactStatus,
  isMotionKind,
  isMotionVisibility,
  motionBand,
  motionScoreBasisPoints,
} from '../src/motion';

describe('motionScoreBasisPoints', () => {
  it('returns 0 for an empty framework rather than NaN', () => {
    // A framework with no dimensions renders somewhere. `NaN` renders as "NaN".
    const score = motionScoreBasisPoints([]);
    assert.equal(score, 0);
    assert.ok(!Number.isNaN(score));
  });

  it('returns 0 rather than throwing when every weight is zero', () => {
    // The naive version divides by the weight total and produces Infinity,
    // which `motionBand` would then clamp to "Build" — a deal nobody
    // scored recommended as the best one in the book.
    const score = motionScoreBasisPoints([
      { id: 'fit', weight: 0, score: 4 },
      { id: 'budget', weight: 0, score: 4 },
    ]);
    assert.equal(score, 0);
  });

  it('scores a full house at exactly the maximum', () => {
    assert.equal(
      motionScoreBasisPoints([
        { id: 'fit', weight: 30, score: 4 },
        { id: 'budget', weight: 70, score: 4 },
      ]),
      10_000,
    );
  });

  it('does not require weights to sum to 100', () => {
    // Two frameworks expressing the same judgement must score the same, or the
    // number means nothing across templates.
    const outOf100 = motionScoreBasisPoints([
      { id: 'fit', weight: 50, score: 4 },
      { id: 'budget', weight: 50, score: 2 },
    ]);
    const outOf6 = motionScoreBasisPoints([
      { id: 'fit', weight: 3, score: 4 },
      { id: 'budget', weight: 3, score: 2 },
    ]);
    assert.equal(outOf100, 7500);
    assert.equal(outOf6, 7500);
  });

  it('rounds half-up rather than truncating', () => {
    // 10000 × 5 / (4 × 8) = 1562.5 exactly. Truncation gives 1562, which is
    // the whole difference between this and a naive `Math.floor` — small, and
    // it moves a score across a band boundary once every few hundred deals.
    const score = motionScoreBasisPoints([
      { id: 'fit', weight: 5, score: 1 },
      { id: 'budget', weight: 3, score: 0 },
    ]);
    assert.equal(score, 1563);
  });

  it('does not let a zero-weight dimension move the total', () => {
    // The case that flatters: a dimension somebody scored 4 and weighted out
    // of the framework must not drag the score up on its way past.
    const withoutIt = motionScoreBasisPoints([{ id: 'fit', weight: 10, score: 2 }]);
    const withIt = motionScoreBasisPoints([
      { id: 'fit', weight: 10, score: 2 },
      { id: 'vanity', weight: 0, score: 4 },
    ]);
    assert.equal(withoutIt, 5000);
    assert.equal(withIt, withoutIt);
  });

  it('always returns an integer inside 0–10000', () => {
    for (const weight of [1, 3, 7, 17, 100]) {
      for (const score of [0, 1, 2, 3, 4]) {
        const basisPoints = motionScoreBasisPoints([
          { id: 'a', weight, score },
          { id: 'b', weight: weight + 1, score: 4 - score },
        ]);
        assert.ok(Number.isInteger(basisPoints), `${weight}/${score} produced a fraction`);
        assert.ok(basisPoints >= 0 && basisPoints <= 10_000, `${basisPoints} is out of range`);
      }
    }
  });
});

describe('motionBand', () => {
  /**
   * The boundaries, one assertion per edge. Inclusive at `min`: an
   * implementation using `>` instead of `>=` passes every other test in this
   * file and tells a seller to defer a deal that qualified.
   */
  it('is inclusive at the lower edge of every band', () => {
    assert.equal(motionBand(3000).label, 'Defer');
    assert.equal(motionBand(4600).label, 'Scope down');
    assert.equal(motionBand(6200).label, 'Qualified, conditional');
    assert.equal(motionBand(7800).label, 'Build');
  });

  it('keeps the basis point below each edge in the band underneath', () => {
    assert.equal(motionBand(2999).label, 'Decline');
    assert.equal(motionBand(4599).label, 'Defer');
    assert.equal(motionBand(6199).label, 'Scope down');
    assert.equal(motionBand(7799).label, 'Qualified, conditional');
  });

  it('covers both ends of the scale', () => {
    assert.equal(motionBand(0).label, 'Decline');
    assert.equal(motionBand(10_000).label, 'Build');
  });

  /**
   * The scorecard a customer reads publishes these five bands and an action for
   * each. They are duplicated in `MOTION_BANDS` because the product renders a
   * label the JSON cannot reach — so this pins the two copies together, and
   * fails when somebody re-authors one of them alone.
   */
  it('uses the labels and edges the seeded scorecard publishes', () => {
    assert.deepEqual(
      MOTION_BANDS.map((band) => [band.min, band.max, band.label]),
      [
        [0, 2999, 'Decline'],
        [3000, 4599, 'Defer'],
        [4600, 6199, 'Scope down'],
        [6200, 7799, 'Qualified, conditional'],
        [7800, 10_000, 'Build'],
      ],
    );
  });

  it('leaves no gap and no overlap across the whole range', () => {
    // Written as a sweep because a hand-edited band table is exactly the kind
    // of data where a typo produces a score that belongs to two bands, or none.
    for (let basisPoints = 0; basisPoints <= 10_000; basisPoints += 1) {
      const matches = MOTION_BANDS.filter(
        (band) => basisPoints >= band.min && basisPoints <= band.max,
      );
      assert.equal(matches.length, 1, `${basisPoints} matched ${matches.length} bands`);
    }
  });
});

describe('the kind register', () => {
  it('labels and describes every kind', () => {
    for (const kind of MOTION_KINDS) {
      assert.ok(MOTION_KIND_LABELS[kind], `${kind} has no label`);
      assert.ok(MOTION_KIND_DESCRIPTIONS[kind], `${kind} has no description`);
    }
  });

  it('maps every kind onto stages that actually exist', () => {
    // The stage list is the demand pipeline, not a second vocabulary. A typo
    // here would produce a template nothing can ever be filed against.
    for (const kind of MOTION_KINDS) {
      const stages = MOTION_KIND_STAGES[kind];
      assert.ok(stages.length > 0, `${kind} serves no stage`);
      for (const stage of stages) {
        assert.ok(DEMAND_STAGES.includes(stage), `${kind} claims unknown stage ${stage}`);
      }
    }
  });

  it('never files a kind against a closed stage', () => {
    // A won or lost deal has left the motion; offering to instantiate into it
    // is an invitation to do work against a dead deal.
    for (const kind of MOTION_KINDS) {
      for (const stage of MOTION_KIND_STAGES[kind]) {
        assert.ok(stage !== 'closed_won' && stage !== 'closed_lost', `${kind} serves ${stage}`);
      }
    }
  });

  it('gives the playbook the whole live motion', () => {
    assert.equal(MOTION_KIND_STAGES.playbook.length, 8);
  });
});

describe('type guards', () => {
  it('accepts members and rejects near misses', () => {
    assert.equal(isMotionKind('playbook'), true);
    assert.equal(isMotionKind('Playbook'), false);
    assert.equal(isMotionVisibility('shared'), true);
    assert.equal(isMotionVisibility('public'), false, 'shared is book-wide, not public');
    assert.equal(isArtifactStatus('final'), true);
    assert.equal(isArtifactStatus('published'), false);
  });
});
