// Wave 4A — MARKET-BREADTH / CONSENSUS strip (Step 4). Makes the DeskShowcase
// "consensus vs model" claim REAL: the consensus is the MEDIAN book line across
// the prop's per-book rows; the model's position is model_value vs that
// consensus, signed by the graded side. Doctrine: never fabricate a consensus —
// <2 distinct books → null (absent beats invented); a non-numeric line is
// ignored, never coerced to 0 (the Number(null)===0 trap).

const { computeBreadth, collectBreadth, median } = require('../../web/src/lib/marketBreadth');

describe('median', () => {
  it('odd length → middle', () => { expect(median([2.5, 1.5, 3.5])).toBe(2.5); });
  it('even length → mean of the two middles', () => { expect(median([1.5, 2.5])).toBe(2); });
  it('ignores non-finite, empty → null', () => {
    expect(median([NaN, 1.5, 2.5, null])).toBe(2);
    expect(median([])).toBeNull();
    expect(median(null)).toBeNull();
  });
});

describe('computeBreadth — median consensus from ≥2 books', () => {
  const books = [
    { book: 'draftkings', line: 2.5, over_odds: -115 },
    { book: 'fanduel', line: 2.5, over_odds: -110 },
    { book: 'betmgm', line: 1.5, over_odds: -120 },
  ];

  it('consensus is the median book LINE; model above → OVER edge (positive, green)', () => {
    const b = computeBreadth(books, 3.0, 'over');
    expect(b.consensus).toBe(2.5);
    expect(b.bookCount).toBe(3);
    expect(b.model).toBe(3.0);
    expect(b.delta).toBeCloseTo(0.5);
    expect(b.signedEdge).toBeCloseTo(0.5); // model projects ABOVE market → supports OVER
    expect(b.position).toBe('above');
    expect(b.side).toBe('over');
  });

  it('UNDER read: model BELOW consensus is the edge (signed positive)', () => {
    const b = computeBreadth(books, 2.0, 'under');
    expect(b.consensus).toBe(2.5);
    expect(b.delta).toBeCloseTo(-0.5); // raw model - consensus stays signed to the market
    expect(b.signedEdge).toBeCloseTo(0.5); // consensus - model, favors UNDER
    expect(b.position).toBe('below');
    expect(b.side).toBe('under');
  });

  it('model behind the market on an OVER → negative signed edge (amber/red)', () => {
    const b = computeBreadth(books, 2.0, 'over');
    expect(b.signedEdge).toBeCloseTo(-0.5);
    expect(b.position).toBe('below');
  });

  it('<2 DISTINCT books → null (never fabricate a consensus)', () => {
    expect(computeBreadth([{ book: 'dk', line: 2.5 }], 3, 'over')).toBeNull();
    // same book twice is still one opinion → not a consensus
    expect(computeBreadth([{ book: 'dk', line: 2.5 }, { book: 'dk', line: 1.5 }], 3, 'over')).toBeNull();
    expect(computeBreadth([], 3)).toBeNull();
    expect(computeBreadth(null, 3)).toBeNull();
  });

  it('absent model → real consensus present, comparison null (no invented model)', () => {
    const b = computeBreadth(books, null, 'over');
    expect(b.consensus).toBe(2.5);
    expect(b.bookCount).toBe(3);
    expect(b.model).toBeNull();
    expect(b.delta).toBeNull();
    expect(b.signedEdge).toBeNull();
    expect(b.position).toBeNull();
  });

  it('a non-numeric book line is IGNORED, not coerced to 0', () => {
    const mixed = [{ book: 'dk', line: 2.5 }, { book: 'fd', line: null }, { book: 'mgm', line: 2.5 }];
    const b = computeBreadth(mixed, 2.5, 'over');
    expect(b.bookCount).toBe(2);
    expect(b.consensus).toBe(2.5);
    expect(b.position).toBe('inline'); // model == consensus
    expect(b.signedEdge).toBe(0);
  });
});

describe('collectBreadth — ranked list, self-hiding', () => {
  it('drops <2-book props and ranks by |signedEdge| desc', () => {
    const items = [
      { player: 'A', stat: 'hits', side: 'over', line: 1.5, modelValue: 2.0, books: [{ book: 'dk', line: 1.5 }, { book: 'fd', line: 1.5 }] },
      { player: 'B', stat: 'tb', side: 'over', line: 2.5, modelValue: 2.6, books: [{ book: 'dk', line: 2.5 }, { book: 'fd', line: 2.5 }] },
      { player: 'C', stat: 'ks', side: 'over', line: 5.5, modelValue: 6, books: [{ book: 'dk', line: 5.5 }] }, // 1 book → dropped
    ];
    const out = collectBreadth(items, 6);
    expect(out).toHaveLength(2);
    expect(out[0].player).toBe('A'); // |0.5| edge beats |0.1|
    expect(out[1].player).toBe('B');
  });

  it('empty when nothing qualifies (component self-hides)', () => {
    expect(collectBreadth([{ player: 'C', stat: 'ks', side: 'over', books: [{ book: 'dk', line: 5.5 }] }], 6)).toEqual([]);
    expect(collectBreadth(null)).toEqual([]);
    expect(collectBreadth([])).toEqual([]);
  });
});

describe('source: MarketBreadth component self-hides + colors by sign', () => {
  const fs = require('fs');
  const path = require('path');
  const read = (rel) => fs.readFileSync(path.join(__dirname, '..', '..', 'web', 'src', rel), 'utf8');

  it('MarketBreadth.tsx colors the edge via the color contract (edgeColor), not raw green', () => {
    const src = read('components/vyndr/MarketBreadth.tsx');
    expect(src).toContain('edgeColor');
  });

  it('MarketBreadth.tsx self-hides when there is nothing to show', () => {
    const src = read('components/vyndr/MarketBreadth.tsx');
    expect(src).toMatch(/return null/);
  });
});
