/**
 * Usage dashboard v2: hour-grain ledger (llm_usage_hourly) repository tests.
 *
 * Coverage:
 *  - incrementLlmUsageHourly UPSERTs on first call, accumulates on second
 *  - requests defaults to +1; a usage-less call still bumps requests
 *  - negative deltas clamp to zero
 *  - hour defaults to the UTC hour of `at`; boundary splits buckets
 *  - distinct (model)/(route)/(source) stay separate rows (uncollapsed grain)
 *  - queryLlmUsageHourly returns raw rows, inclusive range, userId scope
 *  - getUsageOrgMap unions gitea + local orgs, collapses multi-org via MIN
 *
 * Spec: docs/superpowers/specs/2026-06-11-usage-dashboard-v2-design.md
 */
import { describe, expect, it, beforeEach } from 'vitest';
import { Repository } from './repository.js';
import { runMigrations } from './migrate.js';

function makeRepo(): Repository {
  return new Repository(':memory:');
}

describe('llm_usage_hourly repository', () => {
  let repo: Repository;
  beforeEach(() => {
    repo = makeRepo();
  });

  it('UPSERTs first call and accumulates on the same grain', () => {
    const grain = { hour: '2026-06-11T08', userId: 'u1', source: 'direct' as const, model: 'm', route: 'r' };
    repo.incrementLlmUsageHourly({ ...grain, tokensIn: 100, tokensOut: 40 });
    repo.incrementLlmUsageHourly({ ...grain, tokensIn: 10, tokensOut: 5 });
    const rows = repo.queryLlmUsageHourly({ fromHour: '2026-06-11T00', toHour: '2026-06-11T23' });
    expect(rows).toHaveLength(1);
    expect(rows[0]).toMatchObject({
      hour: '2026-06-11T08', userId: 'u1', source: 'direct', model: 'm', route: 'r',
      tokensIn: 110, tokensOut: 45, requests: 2,
    });
  });

  it('a usage-less call still bumps requests', () => {
    repo.incrementLlmUsageHourly({ hour: '2026-06-11T08', userId: 'u1', source: 'gateway', model: 'm', route: 'r' });
    const rows = repo.queryLlmUsageHourly({ fromHour: '2026-06-11T00', toHour: '2026-06-11T23' });
    expect(rows[0]).toMatchObject({ tokensIn: 0, tokensOut: 0, requests: 1 });
  });

  it('clamps negative deltas to zero', () => {
    repo.incrementLlmUsageHourly({ hour: '2026-06-11T08', userId: 'u1', source: 'direct', model: 'm', route: 'r', tokensIn: -5, tokensOut: -9, requests: -3 });
    const rows = repo.queryLlmUsageHourly({ fromHour: '2026-06-11T00', toHour: '2026-06-11T23' });
    expect(rows[0]).toMatchObject({ tokensIn: 0, tokensOut: 0, requests: 0 });
  });

  it('derives the UTC hour from `at`; hour boundary splits buckets', () => {
    const grain = { userId: 'u1', source: 'direct' as const, model: 'm', route: 'r', tokensIn: 1, tokensOut: 0 };
    repo.incrementLlmUsageHourly({ ...grain, at: '2026-06-11T08:59:59.000Z' });
    repo.incrementLlmUsageHourly({ ...grain, at: '2026-06-11T09:00:01.000Z' });
    const rows = repo.queryLlmUsageHourly({ fromHour: '2026-06-11T00', toHour: '2026-06-11T23' });
    expect(rows.map((r) => r.hour)).toEqual(['2026-06-11T08', '2026-06-11T09']);
  });

  it('keeps model/route/source as distinct uncollapsed rows', () => {
    const base = { hour: '2026-06-11T08', userId: 'u1', tokensIn: 1, tokensOut: 1 };
    repo.incrementLlmUsageHourly({ ...base, source: 'direct', model: 'big', route: 'host-a' });
    repo.incrementLlmUsageHourly({ ...base, source: 'direct', model: 'small', route: 'host-a' });
    repo.incrementLlmUsageHourly({ ...base, source: 'gateway', model: 'big', route: 'host-a' });
    const rows = repo.queryLlmUsageHourly({ fromHour: '2026-06-11T00', toHour: '2026-06-11T23' });
    expect(rows).toHaveLength(3); // grain not collapsed
  });

  it('queryLlmUsageHourly honours an inclusive hour range', () => {
    for (const hour of ['2026-06-11T06', '2026-06-11T07', '2026-06-11T08', '2026-06-11T09']) {
      repo.incrementLlmUsageHourly({ hour, userId: 'u1', source: 'direct', model: 'm', route: 'r', tokensIn: 1, tokensOut: 0 });
    }
    const rows = repo.queryLlmUsageHourly({ fromHour: '2026-06-11T07', toHour: '2026-06-11T08' });
    expect(rows.map((r) => r.hour)).toEqual(['2026-06-11T07', '2026-06-11T08']);
  });

  it('userId filter scopes the query to one user', () => {
    repo.incrementLlmUsageHourly({ hour: '2026-06-11T08', userId: 'u1', source: 'direct', model: 'm', route: 'r', tokensIn: 1, tokensOut: 0 });
    repo.incrementLlmUsageHourly({ hour: '2026-06-11T08', userId: 'u2', source: 'direct', model: 'm', route: 'r', tokensIn: 9, tokensOut: 0 });
    const mine = repo.queryLlmUsageHourly({ fromHour: '2026-06-11T00', toHour: '2026-06-11T23', userId: 'u1' });
    expect(mine).toHaveLength(1);
    expect(mine[0]).toMatchObject({ userId: 'u1', tokensIn: 1 });
  });
});

describe('getUsageOrgMap', () => {
  let repo: Repository;
  beforeEach(() => {
    repo = makeRepo();
  });

  it('maps users from the gitea org cache', () => {
    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 (?, ?, ?)').run('u1', 'g1', 'Acme');
    const map = repo.getUsageOrgMap();
    expect(map.get('u1')).toBe('Acme');
    expect(map.has('u2')).toBe(false);
  });

  it('maps users from local orgs and collapses multi-org via MIN(name)', () => {
    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 local_orgs (id, name) VALUES ('o1','Zeta'), ('o2','Alpha')").run();
    db.prepare("INSERT INTO local_org_members (org_id, user_id) VALUES ('o1','u1'), ('o2','u1')").run();
    const map = repo.getUsageOrgMap();
    expect(map.get('u1')).toBe('Alpha'); // MIN over {Zeta, Alpha}
  });

  it('unions gitea and local sources for the same user (MIN wins)', () => {
    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','Beta')").run();
    db.prepare("INSERT INTO local_orgs (id, name) VALUES ('o1','Alpha')").run();
    db.prepare("INSERT INTO local_org_members (org_id, user_id) VALUES ('o1','u1')").run();
    const map = repo.getUsageOrgMap();
    expect(map.get('u1')).toBe('Alpha'); // MIN over {Beta, Alpha}
  });
});

describe('daily → hourly backfill (migrate)', () => {
  it('backfills daily rows into the hourly table at hour T00, idempotently', () => {
    const repo = new Repository(':memory:');
    const db = repo.getDb();
    // Simulate a pre-v2 production DB: a row only in the daily archive.
    db.prepare(
      `INSERT INTO llm_usage_daily
         (day, user_id, source, model, route, tokens_in, tokens_out, requests, last_updated_at)
       VALUES ('2026-06-10','u1','direct','m','r',100,40,3,'2026-06-10T23:00:00Z')`,
    ).run();

    runMigrations(db);
    const after1 = repo.queryLlmUsageHourly({ fromHour: '2026-06-10T00', toHour: '2026-06-10T23' });
    expect(after1).toHaveLength(1);
    expect(after1[0]).toMatchObject({ hour: '2026-06-10T00', userId: 'u1', tokensIn: 100, tokensOut: 40, requests: 3 });

    // Re-running the migration must not double-count (INSERT OR IGNORE).
    runMigrations(db);
    const after2 = repo.queryLlmUsageHourly({ fromHour: '2026-06-10T00', toHour: '2026-06-10T23' });
    expect(after2).toHaveLength(1);
    expect(after2[0]).toMatchObject({ tokensIn: 100, tokensOut: 40, requests: 3 });
  });
});
