/**
 * Usage dashboard API v2 (GET /api/usage/daily) tests.
 *
 * Coverage:
 *  - admin sees all users + byUser breakdown; non-admin scoped to own rows
 *  - no-auth (authActive=false) sees everyone (scope 'all')
 *  - default groupBy=source keeps gateway/direct totals; series carries segments
 *  - day / week / month bucketing
 *  - groupBy model / route / user / org produces the right series keys
 *  - tzOffset re-buckets UTC hours into the viewer's local calendar day
 *  - inclusive range, default range, from>to → 400, range-too-large → 400
 *  - invalid dates fall back to defaults (not 500)
 *
 * Spec: docs/superpowers/specs/2026-06-11-usage-dashboard-v2-design.md
 */
import { describe, it, expect, beforeEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { Repository } from '../db/repository.js';
import { createUsageRouter } from './usage-api.js';

function makeApp(repo: Repository, opts: { authActive: boolean; user?: { id: string; role?: string } }) {
  const app = express();
  app.use((req, _res, next) => {
    if (opts.user) (req as unknown as { user: unknown }).user = opts.user;
    next();
  });
  app.use('/api/usage', createUsageRouter(repo, { authActive: opts.authActive }));
  return app;
}

/** Seed at hour grain. `day` is expanded to noon UTC so it maps to the same
 *  local day under tzOffset=0 (the default the legacy assertions rely on). */
function seed(
  repo: Repository,
  rows: Array<{ day?: string; hour?: string; userId: string; source: 'gateway' | 'direct'; model?: string; route?: string; tin: number; tout: number; req?: number }>,
) {
  for (const r of rows) {
    repo.incrementLlmUsageHourly({
      hour: r.hour ?? `${r.day}T12`,
      userId: r.userId, source: r.source,
      model: r.model ?? 'm', route: r.route ?? 'r',
      tokensIn: r.tin, tokensOut: r.tout, requests: r.req ?? 1,
    });
  }
}

describe('GET /api/usage/daily', () => {
  let repo: Repository;
  beforeEach(() => {
    repo = new Repository(':memory:');
    seed(repo, [
      { day: '2026-06-10', userId: 'u1', source: 'gateway', tin: 100, tout: 40 },
      { day: '2026-06-10', userId: 'u1', source: 'direct', model: 'x', route: 'h', tin: 10, tout: 5 },
      { day: '2026-06-11', userId: 'u1', source: 'gateway', tin: 7, tout: 3 },
      { day: '2026-06-11', userId: 'u2', source: 'direct', tin: 1000, tout: 500 },
    ]);
  });

  it('non-admin sees only their own rows (scope=self, no byUser)', async () => {
    const app = makeApp(repo, { authActive: true, user: { id: 'u1', role: 'user' } });
    const res = await request(app).get('/api/usage/daily?from=2026-06-01&to=2026-06-30');
    expect(res.status).toBe(200);
    expect(res.body.scope).toBe('self');
    expect(res.body.byUser).toBeUndefined();
    // u1 only: gateway 100+40+7+3=150, direct 10+5=15
    expect(res.body.totals.gateway).toMatchObject({ tokensIn: 107, tokensOut: 43, requests: 2 });
    expect(res.body.totals.direct).toMatchObject({ tokensIn: 10, tokensOut: 5, requests: 1 });
  });

  it('default groupBy is source with gateway→direct keys', async () => {
    const app = makeApp(repo, { authActive: true, user: { id: 'u1', role: 'user' } });
    const res = await request(app).get('/api/usage/daily?from=2026-06-01&to=2026-06-30');
    expect(res.body.groupBy).toBe('source');
    expect(res.body.keys).toEqual(['gateway', 'direct']);
    // series carries per-key segments now
    const b = res.body.series.find((x: { bucket: string }) => x.bucket === '2026-06-10');
    expect(b.segments.gateway).toMatchObject({ tokensIn: 100, tokensOut: 40 });
    expect(b.segments.direct).toMatchObject({ tokensIn: 10, tokensOut: 5 });
  });

  it('admin sees all users with a byUser breakdown', async () => {
    const app = makeApp(repo, { authActive: true, user: { id: 'admin1', role: 'admin' } });
    const res = await request(app).get('/api/usage/daily?from=2026-06-01&to=2026-06-30');
    expect(res.status).toBe(200);
    expect(res.body.scope).toBe('all');
    expect(res.body.totals.direct.tokensIn).toBe(1010); // u1 10 + u2 1000
    const users = (res.body.byUser as Array<{ userId: string }>).map((u) => u.userId).sort();
    expect(users).toEqual(['u1', 'u2']);
    // sorted by total tokens desc → u2 (1500) first
    expect(res.body.byUser[0].userId).toBe('u2');
  });

  it('resolves byUser display names (real users → name, sentinels verbatim)', async () => {
    const u = repo.createUser({ email: 'alice@example.com', name: 'Alice', role: 'user', status: 'active' });
    seed(repo, [
      { day: '2026-06-11', userId: u.id, source: 'direct', tin: 5, tout: 5 },
      { day: '2026-06-11', userId: 'local', source: 'direct', tin: 1, tout: 1 },
    ]);
    const app = makeApp(repo, { authActive: true, user: { id: 'admin1', role: 'admin' } });
    const res = await request(app).get('/api/usage/daily?from=2026-06-01&to=2026-06-30');
    const byId = Object.fromEntries((res.body.byUser as Array<{ userId: string; displayName: string }>).map((r) => [r.userId, r.displayName]));
    expect(byId[u.id]).toBe('Alice');
    expect(byId['local']).toBe('local'); // sentinel returned verbatim for UI localization
  });

  it('no-auth mode (authActive=false) sees everyone', async () => {
    const app = makeApp(repo, { authActive: false }); // no req.user
    const res = await request(app).get('/api/usage/daily?from=2026-06-01&to=2026-06-30');
    expect(res.status).toBe(200);
    expect(res.body.scope).toBe('all');
    expect(res.body.byUser.length).toBe(2);
  });

  it('day granularity yields one bucket per active day', async () => {
    const app = makeApp(repo, { authActive: true, user: { id: 'admin1', role: 'admin' } });
    const res = await request(app).get('/api/usage/daily?from=2026-06-10&to=2026-06-11&granularity=day');
    expect(res.body.series.map((b: { bucket: string }) => b.bucket)).toEqual(['2026-06-10', '2026-06-11']);
  });

  it('month granularity collapses days into a YYYY-MM bucket', async () => {
    const app = makeApp(repo, { authActive: true, user: { id: 'admin1', role: 'admin' } });
    const res = await request(app).get('/api/usage/daily?from=2026-06-01&to=2026-06-30&granularity=month');
    expect(res.body.series).toHaveLength(1);
    expect(res.body.series[0].bucket).toBe('2026-06');
  });

  it('week granularity uses an ISO YYYY-Www bucket', async () => {
    const app = makeApp(repo, { authActive: true, user: { id: 'admin1', role: 'admin' } });
    const res = await request(app).get('/api/usage/daily?from=2026-06-08&to=2026-06-14&granularity=week');
    // 2026-06-10 / -11 fall in ISO week 24 of 2026
    expect(res.body.series).toHaveLength(1);
    expect(res.body.series[0].bucket).toBe('2026-W24');
  });

  it('rejects from > to with 400', async () => {
    const app = makeApp(repo, { authActive: true, user: { id: 'u1', role: 'user' } });
    const res = await request(app).get('/api/usage/daily?from=2026-06-30&to=2026-06-01');
    expect(res.status).toBe(400);
  });

  it('rejects an absurdly large range with 400', async () => {
    const app = makeApp(repo, { authActive: true, user: { id: 'u1', role: 'user' } });
    const res = await request(app).get('/api/usage/daily?from=2000-01-01&to=2026-06-30');
    expect(res.status).toBe(400);
  });

  it('falls back to defaults for invalid dates (no 500)', async () => {
    const app = makeApp(repo, { authActive: true, user: { id: 'u1', role: 'user' } });
    const res = await request(app).get('/api/usage/daily?from=2026-99-99');
    expect(res.status).toBe(200);
    // default window is the last 30 days, ending today
    expect(res.body.to).toMatch(/^\d{4}-\d{2}-\d{2}$/);
  });
});

describe('GET /api/usage/daily — v2 group-by axes', () => {
  let repo: Repository;
  beforeEach(() => {
    repo = new Repository(':memory:');
    seed(repo, [
      { day: '2026-06-11', userId: 'u1', source: 'gateway', model: 'big', route: 'pool-a', tin: 100, tout: 40 },
      { day: '2026-06-11', userId: 'u1', source: 'gateway', model: 'small', route: 'pool-b', tin: 20, tout: 10 },
      { day: '2026-06-11', userId: 'u2', source: 'direct', model: 'big', route: 'pool-a', tin: 5, tout: 5 },
    ]);
  });

  it('groupBy=model splits series by model, ordered by total desc', async () => {
    const app = makeApp(repo, { authActive: false });
    const res = await request(app).get('/api/usage/daily?from=2026-06-11&to=2026-06-11&groupBy=model');
    expect(res.body.groupBy).toBe('model');
    expect(res.body.keys).toEqual(['big', 'small']); // big 150 > small 30
    expect(res.body.totals.big).toMatchObject({ tokensIn: 105, tokensOut: 45 });
    expect(res.body.totals.small).toMatchObject({ tokensIn: 20, tokensOut: 10 });
  });

  it('groupBy=route splits series by backend route', async () => {
    const app = makeApp(repo, { authActive: false });
    const res = await request(app).get('/api/usage/daily?from=2026-06-11&to=2026-06-11&groupBy=route');
    expect(res.body.keys.sort()).toEqual(['pool-a', 'pool-b']);
    expect(res.body.totals['pool-a']).toMatchObject({ tokensIn: 105, tokensOut: 45 });
  });

  it('groupBy=user keys by id and resolves display labels', async () => {
    const alice = repo.createUser({ email: 'a@x', name: 'Alice', role: 'user', status: 'active' });
    seed(repo, [{ day: '2026-06-11', userId: alice.id, source: 'direct', tin: 1, tout: 1 }]);
    const app = makeApp(repo, { authActive: false });
    const res = await request(app).get('/api/usage/daily?from=2026-06-11&to=2026-06-11&groupBy=user');
    expect(res.body.keys).toContain(alice.id);
    expect(res.body.labels[alice.id]).toBe('Alice');
  });

  it('folds the tail beyond 12 series into a single "other" bucket', async () => {
    const r2 = new Repository(':memory:');
    // 14 distinct models with descending totals m00 (highest) .. m13 (lowest).
    for (let i = 0; i < 14; i++) {
      seed(r2, [{ day: '2026-06-11', userId: 'u1', source: 'direct', model: `m${String(i).padStart(2, '0')}`, tin: 100 - i, tout: 0 }]);
    }
    const app = makeApp(r2, { authActive: false });
    const res = await request(app).get('/api/usage/daily?from=2026-06-11&to=2026-06-11&groupBy=model');
    expect(res.body.keys).toHaveLength(12);           // 11 kept + 'other'
    expect(res.body.keys[res.body.keys.length - 1]).toBe('other');
    // folded tail = m11+m12+m13 inputs = (100-11)+(100-12)+(100-13) = 264
    expect(res.body.totals.other).toMatchObject({ tokensIn: 264 });
    // no key is duplicated
    expect(new Set(res.body.keys).size).toBe(res.body.keys.length);
  });

  it('merges a real series literally named "other" with the folded tail (no dup key)', async () => {
    const r2 = new Repository(':memory:');
    // A high-volume model called 'other' that survives in the kept top-11…
    seed(r2, [{ day: '2026-06-11', userId: 'u1', source: 'direct', model: 'other', tin: 1000, tout: 0 }]);
    // …plus 13 smaller models so folding still happens.
    for (let i = 0; i < 13; i++) {
      seed(r2, [{ day: '2026-06-11', userId: 'u1', source: 'direct', model: `m${String(i).padStart(2, '0')}`, tin: 50 - i, tout: 0 }]);
    }
    const app = makeApp(r2, { authActive: false });
    const res = await request(app).get('/api/usage/daily?from=2026-06-11&to=2026-06-11&groupBy=model');
    // 'other' appears exactly once
    expect(res.body.keys.filter((k: string) => k === 'other')).toHaveLength(1);
    expect(new Set(res.body.keys).size).toBe(res.body.keys.length);
    // its total is the real 1000 PLUS the folded tail, not overwritten
    expect(res.body.totals.other.tokensIn).toBeGreaterThan(1000);
  });

  it('groupBy=org maps users to their org and buckets the orgless under no-org', async () => {
    const db = repo.getDb();
    db.prepare("INSERT INTO users (id, email, name, role, status, created_at) VALUES ('u1','a@x','A','user','active',datetime('now'))").run();
    db.prepare("INSERT INTO user_gitea_orgs (user_id, org_id, org_name) VALUES ('u1','g1','Acme')").run();
    const app = makeApp(repo, { authActive: false });
    const res = await request(app).get('/api/usage/daily?from=2026-06-11&to=2026-06-11&groupBy=org');
    // u1 → Acme, u2 has no org → no-org
    expect(res.body.keys.sort()).toEqual(['Acme', 'no-org']);
    expect(res.body.totals['Acme']).toMatchObject({ tokensIn: 120, tokensOut: 50 });
    expect(res.body.totals['no-org']).toMatchObject({ tokensIn: 5, tokensOut: 5 });
  });
});

describe('GET /api/usage/daily — tzOffset local re-bucketing', () => {
  let repo: Repository;
  beforeEach(() => {
    repo = new Repository(':memory:');
    // UTC 2026-06-10T20 → JST (+540) is 2026-06-11 05:00 → local day 06-11.
    seed(repo, [{ hour: '2026-06-10T20', userId: 'u1', source: 'direct', tin: 9, tout: 1 }]);
  });

  it('UTC offset (tzOffset=0) lands the row on the UTC day', async () => {
    const app = makeApp(repo, { authActive: false });
    const res = await request(app).get('/api/usage/daily?from=2026-06-10&to=2026-06-10&tzOffset=0');
    expect(res.body.series.map((b: { bucket: string }) => b.bucket)).toEqual(['2026-06-10']);
    expect(res.body.totals.direct).toMatchObject({ tokensIn: 9 });
  });

  it('JST offset (tzOffset=540) shifts the row onto the local next day', async () => {
    const app = makeApp(repo, { authActive: false });
    const res = await request(app).get('/api/usage/daily?from=2026-06-11&to=2026-06-11&tzOffset=540');
    expect(res.body.tzOffset).toBe(540);
    expect(res.body.series.map((b: { bucket: string }) => b.bucket)).toEqual(['2026-06-11']);
    expect(res.body.totals.direct).toMatchObject({ tokensIn: 9 });
  });

  it('the same UTC row is absent from the UTC day once viewed in JST', async () => {
    const app = makeApp(repo, { authActive: false });
    const res = await request(app).get('/api/usage/daily?from=2026-06-10&to=2026-06-10&tzOffset=540');
    // local day for the row is 06-11, so the 06-10 window is empty
    expect(res.body.series).toHaveLength(0);
  });

  it('a negative offset (US Eastern, -300) shifts an early-UTC row to the previous local day', async () => {
    const r2 = new Repository(':memory:');
    // UTC 2026-06-11T02 − 5h = 2026-06-10T21 → local day 06-10.
    seed(r2, [{ hour: '2026-06-11T02', userId: 'u1', source: 'direct', tin: 7, tout: 0 }]);
    const app = makeApp(r2, { authActive: false });
    const onUtcDay = await request(app).get('/api/usage/daily?from=2026-06-11&to=2026-06-11&tzOffset=-300');
    expect(onUtcDay.body.series).toHaveLength(0); // not on 06-11 local
    const onLocalDay = await request(app).get('/api/usage/daily?from=2026-06-10&to=2026-06-10&tzOffset=-300');
    expect(onLocalDay.body.series.map((b: { bucket: string }) => b.bucket)).toEqual(['2026-06-10']);
    expect(onLocalDay.body.totals.direct).toMatchObject({ tokensIn: 7 });
  });
});
