import { afterEach, beforeEach, expect, test } from 'bun:test';
import { fixture } from './test-fixture';
let f: ReturnType<typeof fixture>;
beforeEach(() => { f = fixture(); });
afterEach(() => f.close());
const create = (input: object) => f.json('/habits', 'POST', input, 201);
const count = () => create({ name: 'Hydration', method: 'count', target: 8, unit: 'glasses' });
const manual = () => create({ name: 'Read', method: 'manual' });
const tasks = () => create({ name: 'Routine', method: 'tasks', tasks: [{ name: 'A' }, { name: 'B' }, { name: 'C' }] });
const day = (id: string, date = '2026-09-04') => f.json(`/habits/${id}/days/${date}`);
const log = (id: string, input: object, date = '2026-09-04') => f.json(`/habits/${id}/days/${date}/progress`, 'PUT', input);
const tick = (id: string, taskId: string, done = true, date = '2026-09-04') => f.json(`/habits/${id}/days/${date}/tasks/${taskId}`, 'PUT', { done });
const combined = (ids: string[]) => f.json('/charts', 'POST', { name: 'Combined', habitIds: ids }, 201);
const square = (id: string, date = '2026-09-04') => f.json(`/charts/${id}/days/${date}`);

test('habit CRUD, defaults, archive, restore, history, and date inspection', async () => {
  const h = await count(); expect(h.schedule).toEqual({ type: 'daily' }); expect(h.archived).toBe(false);
  expect((await f.json('/habits')).habits).toHaveLength(1);
  expect((await f.json(`/habits/${h.id}`)).name).toBe('Hydration');
  expect((await f.json(`/habits/${h.id}`, 'PATCH', { name: 'Water' })).name).toBe('Water');
  expect((await f.json(`/habits/${h.id}/history`)).revisions).toHaveLength(2);
  expect((await f.json('/days/2026-09-04')).habits).toHaveLength(1);
  expect((await f.request(`/habits/${h.id}`, 'DELETE')).status).toBe(204);
  expect((await f.json('/habits')).habits).toHaveLength(0);
  expect((await f.json('/habits?archived=true')).habits).toHaveLength(1);
  expect((await day(h.id)).due).toBe(false);
  await f.json(`/habits/${h.id}`, 'PATCH', { archived: false });
  expect((await day(h.id)).due).toBe(true);
});
test('count exact underlying values, over-target logging, and correction', async () => {
  const h = await count(); const c = await combined([h.id]);
  expect((await log(h.id, { count: 7 })).ratio).toBe(7 / 8);
  expect((await square(c.id)).completed).toBe(0);
  expect((await log(h.id, { count: 8 })).complete).toBe(true);
  expect((await square(c.id)).ratio).toBe(1);
  expect((await log(h.id, { count: 12 })).value).toBe(12);
  await log(h.id, { count: 2 }); expect((await square(c.id)).ratio).toBe(0);
  const detail = await day(h.id); expect(detail.target).toBe(8); expect(detail.unit).toBe('glasses');
  const audit = await f.json(`/habits/${h.id}/days/2026-09-04/audit`);
  expect(audit.records[0].events).toHaveLength(4);
});
test('manual habits expose only binary progress', async () => {
  const h = await manual(); expect((await day(h.id)).ratio).toBe(0);
  expect((await log(h.id, { done: true })).ratio).toBe(1);
  expect((await log(h.id, { done: false })).ratio).toBe(0);
  expect((await f.request(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { count: 1 })).status).toBe(422);
});
test('task completion is derived; 2/3 is partial and contributes zero to every combined chart', async () => {
  const h = await tasks(); const a = await combined([h.id]); const b = await combined([h.id]);
  await tick(h.id, h.tasks[0].id); const partial = await tick(h.id, h.tasks[1].id);
  expect(partial.ratio).toBe(2 / 3); expect(partial.complete).toBe(false);
  expect((await square(a.id)).ratio).toBe(0); expect((await square(b.id)).ratio).toBe(0);
  await tick(h.id, h.tasks[2].id); expect((await square(a.id)).ratio).toBe(1);
  await tick(h.id, h.tasks[1].id, false); expect((await square(b.id)).ratio).toBe(0);
  expect((await f.request(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { done: true })).status).toBe(409);
});
test('combined equal weights, 3/4, partial contributes zero, scheduled denominator, full and neutral', async () => {
  const a = await manual(); const b = await manual(); const c = await manual(); const d = await count();
  const chart = await combined([a.id,b.id,c.id,d.id]);
  for (const h of [a,b,c]) await log(h.id, { done: true });
  await log(d.id, { count: 7 });
  expect(await square(chart.id)).toMatchObject({ due: 4, completed: 3, ratio: 0.75, level: 3 });
  await log(d.id, { count: 8 }); const full = await square(chart.id);
  expect(full).toMatchObject({ due: 4, completed: 4, ratio: 1 });
  const one = await combined([a.id]); expect((await square(one.id)).color).toBe(full.color);
  for (const h of [b,c,d]) await f.json(`/habits/${h.id}`, 'PATCH', { schedule: { type: 'weekdays', days: [1] } });
  expect(await square(chart.id)).toMatchObject({ due: 1, completed: 1, ratio: 1 });
  await f.json(`/habits/${a.id}`, 'PATCH', { schedule: { type: 'weekdays', days: [1] } });
  expect(await square(chart.id)).toMatchObject({ due: 0, ratio: null, status: 'not_due', level: null });
});
test('task schedule intersects parent and zero due tasks never auto-completes', async () => {
  const h = await create({ name: 'Monday', method: 'tasks', schedule: { type: 'weekdays', days: [1] }, tasks: [{ name: 'Friday', schedule: { type: 'weekdays', days: [5] } }] });
  const c = await combined([h.id]); expect((await square(c.id)).ratio).toBeNull();
  expect((await day(h.id)).tasks).toHaveLength(0);
  expect((await day(h.id, '2026-09-07')).due).toBe(false);
  const empty = await create({ name: 'Empty', method: 'tasks' }); expect((await day(empty.id)).due).toBe(false);
});
test('incomplete occurrences expire at exact local midnight and correction retains expiry', async () => {
  const h = await tasks(); await tick(h.id, h.tasks[0].id);
  f.setTime('2026-09-04T21:59:59.999Z'); expect((await day(h.id)).tasks.every((t: any) => t.expiredAt === null)).toBe(true);
  f.setTime('2026-09-04T22:00:00Z');
  const previous = await day(h.id); const a = previous.tasks.find((t: any) => t.taskId === h.tasks[0].id); const b = previous.tasks.find((t: any) => t.taskId === h.tasks[1].id);
  expect(a.expiredAt).toBeNull(); expect(b.expiredAt).toBe(Date.parse('2026-09-04T22:00Z'));
  expect((await day(h.id, '2026-09-05')).tasks.every((t: any) => !t.done && t.expiredAt === null)).toBe(true);
  const corrected = await tick(h.id, h.tasks[1].id); expect(corrected.tasks.find((t: any) => t.taskId === h.tasks[1].id).expiredAt).toBe(b.expiredAt);
  expect((await day(h.id, '2026-09-05')).value).toBe(0);
});
test('downtime materializes missing occurrences with original expiry deadlines', async () => {
  const h = await tasks(); f.setTime('2026-09-08T12:00Z');
  expect((await day(h.id, '2026-09-06')).tasks.every((t: any) => t.expiredAt === Date.parse('2026-09-06T22:00Z'))).toBe(true);
  expect((await day(h.id, '2026-09-08')).tasks.every((t: any) => t.expiredAt === null)).toBe(true);
});
test('target and method edits preserve yesterday, same-day carry and superseded audit records', async () => {
  const h = await count(); await log(h.id, { count: 8 }); f.setTime('2026-09-05T12:00Z');
  await log(h.id, { count: 8 }, '2026-09-05');
  await f.json(`/habits/${h.id}`, 'PATCH', { target: 10 });
  expect(await day(h.id)).toMatchObject({ value: 8, target: 8, complete: true });
  expect(await day(h.id, '2026-09-05')).toMatchObject({ value: 8, target: 10, complete: false });
  await f.json(`/habits/${h.id}`, 'PATCH', { method: 'manual' });
  expect(await day(h.id, '2026-09-05')).toMatchObject({ method: 'manual', value: 0 });
  await log(h.id, { count: 7 }); expect((await day(h.id)).ratio).toBe(7 / 8);
  const audit = await f.json(`/habits/${h.id}/days/2026-09-05/audit`);
  expect(audit.records).toHaveLength(3); expect(audit.records[0].count).toBe(8);
  expect((await f.request(`/habits/${h.id}`, 'PATCH', { method: 'count' })).status).toBe(422);
});
test('task CRUD and recurrence edits preserve historical tasks, expiry and requirements', async () => {
  const h = await tasks(); const t = h.tasks[0];
  expect((await f.json(`/habits/${h.id}/tasks`)).tasks).toHaveLength(3);
  expect((await f.json(`/habits/${h.id}/tasks/${t.id}`)).name).toBe('A');
  await tick(h.id, t.id); f.setTime('2026-09-05T12:00Z');
  const newTask = await f.json(`/habits/${h.id}/tasks`, 'POST', { name: 'New' }, 201);
  expect((await day(h.id)).target).toBe(3); expect((await day(h.id, '2026-09-05')).target).toBe(4);
  await f.json(`/habits/${h.id}/tasks/${newTask.id}`, 'PATCH', { schedule: { type: 'interval', every: 3, anchor: '2026-09-05' }, name: 'Every three days' });
  expect((await f.request(`/habits/${h.id}/tasks/${t.id}`, 'DELETE')).status).toBe(204);
  expect((await day(h.id)).tasks.find((v: any) => v.taskId === t.id).done).toBe(true);
  expect((await f.request(`/habits/${h.id}/tasks/${t.id}`)).status).toBe(404);
  await tick(h.id, t.id, false); expect((await day(h.id)).value).toBe(0);
  expect((await day(h.id, '2026-09-06')).tasks.some((v: any) => v.taskId === newTask.id)).toBe(false);
  expect((await day(h.id, '2026-09-08')).tasks.some((v: any) => v.taskId === newTask.id)).toBe(true);
});
test('schedule edits and archiving apply today while earlier logs remain correctable', async () => {
  const h = await count(); await log(h.id, { count: 8 }); f.setTime('2026-09-05T12:00Z');
  await f.json(`/habits/${h.id}`, 'PATCH', { schedule: { type: 'weekdays', days: [1] } });
  expect((await day(h.id)).complete).toBe(true); expect((await day(h.id, '2026-09-05')).due).toBe(false);
  await f.request(`/habits/${h.id}`, 'DELETE'); await log(h.id, { count: 6 }); expect((await day(h.id)).value).toBe(6);
});
test('future and pre-creation dates are inspectable but not writable', async () => {
  const h = await count(); expect((await day(h.id, '2026-09-05')).status).toBe('future');
  expect((await day(h.id, '2026-09-03')).status).toBe('not_due');
  for (const d of ['2026-09-05', '2026-09-03']) expect((await f.request(`/habits/${h.id}/days/${d}/progress`, 'PUT', { count: 8 })).status).toBe(409);
});
test('combined CRUD, membership edits recompute history, calendar data and settings', async () => {
  const h = await count(); const m = await manual(); await log(h.id, { count: 8 });
  const c = await combined([h.id]); expect((await f.json('/charts')).charts).toHaveLength(1);
  expect((await f.json(`/charts/${c.id}`)).habitIds).toEqual([h.id]);
  await f.json(`/charts/${c.id}`, 'PATCH', { name: 'Both', habitIds: [h.id, m.id], settings: { mainColor: '#ff0000', shadeCount: 8 } });
  expect((await square(c.id)).ratio).toBe(0.5);
  const cal = await f.json(`/charts/${c.id}/calendar?from=2026-09-03&to=2026-09-05`);
  expect(cal.data).toHaveLength(3); expect(cal.data[0].value).toBe(-1); expect(cal.data[2].value).toBe(-2);
  expect(cal.settings.mainColor).toBe('#ff0000');
  const settings = await f.json(`/habits/${h.id}/calendar-settings`, 'PUT', { mainColor: '#123456', shadeCount: 6 });
  expect((await f.json(`/habits/${h.id}/calendar-settings`))).toEqual(settings);
  const individual = await f.json(`/habits/${h.id}/calendar?from=2026-09-04&to=2026-09-04`);
  expect(individual.data).toEqual([{ day: '2026-09-04', value: 6 }]); expect(individual.days[0].color).toBe('#123456');
  expect((await f.request(`/charts/${c.id}`, 'DELETE')).status).toBe(204);
  expect((await f.request(`/charts/${c.id}`)).status).toBe(404);
});
test('timezone changes preserve recorded deadlines and never rewind the tracking date', async () => {
  const h = await tasks(); const before = await day(h.id);
  expect((await f.json('/me', 'PATCH', { timezone: 'America/Los_Angeles' })).timezone).toBe('America/Los_Angeles');
  expect((await f.json('/me')).timezone).toBe('America/Los_Angeles');
  expect((await day(h.id)).endsAt).toBe(before.endsAt);
  f.setTime('2026-09-05T12:00Z'); const next = await day(h.id, '2026-09-05');
  expect(next.timezone).toBe('America/Los_Angeles'); expect(next.endsAt).toBe(Date.parse('2026-09-06T07:00Z'));
  await f.json('/me', 'PATCH', { timezone: 'Pacific/Honolulu' });
  expect((await f.json('/today')).date).toBe('2026-09-05');
});
