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 taskHabit = () => f.json('/habits', 'POST', { name: 'Routine', method: 'tasks', tasks: [{ name: 'Weekly', schedule: { type: 'weekdays', days: [5] } }] }, 201);
const detail = (id: string) => f.json(`/habits/${id}/days/2026-09-04`);
const tick = (h: any, done: boolean) => f.json(`/habits/${h.id}/days/2026-09-04/tasks/${h.tasks[0].id}`, 'PUT', { done });
test('task PATCH preserves omitted recurrence and rejects an empty patch', async () => {
  const h = await taskHabit(); const path = `/habits/${h.id}/tasks/${h.tasks[0].id}`;
  expect((await f.json(path, 'PATCH', { name: 'Renamed' })).schedule).toEqual({ type: 'weekdays', days: [5] });
  expect((await f.request(path, 'PATCH', {})).status).toBe(422);
  expect((await f.json(path, 'PATCH', { schedule: { type: 'daily' } })).name).toBe('Renamed');
});
test('chart PATCH preserves omitted settings and rejects an empty patch', async () => {
  const h = await taskHabit();
  const c = await f.json('/charts', 'POST', { name: 'Chart', habitIds: [h.id], settings: { mainColor: '#ff0000', shadeCount: 8 } }, 201);
  expect((await f.json(`/charts/${c.id}`, 'PATCH', { name: 'Renamed' })).settings).toEqual(c.settings);
  expect((await f.request(`/charts/${c.id}`, 'PATCH', {})).status).toBe(422);
  expect((await f.json(`/charts/${c.id}`, 'PATCH', { settings: {} })).settings.mainColor).toBe('#196127');
});
test('archiving and restoring retains same-day task states and combined completion', async () => {
  const h = await taskHabit(); await tick(h, true);
  const c = await f.json('/charts', 'POST', { name: 'Combined', habitIds: [h.id] }, 201);
  await f.request(`/habits/${h.id}`, 'DELETE');
  expect((await detail(h.id)).due).toBe(false);
  await f.json(`/habits/${h.id}`, 'PATCH', { archived: false });
  expect((await detail(h.id)).value).toBe(1);
  expect((await f.json(`/charts/${c.id}/days/2026-09-04`)).ratio).toBe(1);
  await tick(h, false); await f.request(`/habits/${h.id}`, 'DELETE'); await f.json(`/habits/${h.id}`, 'PATCH', { archived: false });
  expect((await detail(h.id)).value).toBe(0);
});
test('temporarily disabling the schedule retains same-day occurrence progress', async () => {
  const h = await taskHabit(); await tick(h, true);
  await f.json(`/habits/${h.id}`, 'PATCH', { schedule: { type: 'weekdays', days: [1] } });
  await f.json(`/habits/${h.id}`, 'PATCH', { schedule: { type: 'daily' } });
  expect((await detail(h.id)).value).toBe(1);
});
test('post-deadline corrections never invent expiry for an originally completed occurrence', async () => {
  const h = await taskHabit(); await tick(h, true); f.setTime('2026-09-05T12:00Z');
  expect((await detail(h.id)).tasks[0].expiredAt).toBeNull();
  await tick(h, false);
  for (let i = 0; i < 2; i++) expect((await detail(h.id)).tasks[0].expiredAt).toBeNull();
  await tick(h, true); expect((await detail(h.id)).tasks[0].expiredAt).toBeNull();
});
test('expired occurrences preserve original expiry through repeated historical corrections', async () => {
  const h = await taskHabit(); f.setTime('2026-09-05T12:00Z');
  const expired = (await detail(h.id)).tasks[0].expiredAt;
  expect(expired).toBe(Date.parse('2026-09-04T22:00Z'));
  await tick(h, true); expect((await detail(h.id)).tasks[0].expiredAt).toBe(expired);
  await tick(h, false); expect((await detail(h.id)).tasks[0].expiredAt).toBe(expired);
});
test('overlapping patches preserve unrelated changes', async () => {
  const h = await f.json('/habits', 'POST', { name: 'Original', method: 'count', target: 8 }, 201);
  await Promise.all([
    f.json(`/habits/${h.id}`, 'PATCH', { name: 'Renamed' }),
    f.json(`/habits/${h.id}`, 'PATCH', { target: 10 }),
  ]);
  expect(await f.json(`/habits/${h.id}`)).toMatchObject({ name: 'Renamed', target: 10 });
});
test('count correction audit records inherited progress rather than the raw zero default', async () => {
  const h = await f.json('/habits', 'POST', { name: 'Carry', method: 'count', target: 8, carryPartialProgress: true }, 201);
  await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { count: 7 });
  f.setTime('2026-09-05T12:00Z');
  await f.json(`/habits/${h.id}/days/2026-09-05/progress`, 'PUT', { count: 8 });
  const audit = await f.json(`/habits/${h.id}/days/2026-09-05/audit`);
  expect(audit.records[0].events[0].before.count).toBe(7);
});
function delayedPatch(path: string, input: object) {
  let controller!: ReadableStreamDefaultController<Uint8Array>;
  let began!: () => void;
  const started = new Promise<void>(resolve => { began = resolve; });
  const encoded = new TextEncoder().encode(JSON.stringify(input));
  const stream = new ReadableStream<Uint8Array>({ start(value) { controller = value; }, pull() { began(); } });
  const response = f.app.request(`${f.origin}/api${path}`, { method: 'PATCH', body: stream,
    headers: { Cookie: `minabot_session=${'a'.repeat(43)}`, Origin: f.origin, 'Content-Type': 'application/json', 'Content-Length': String(encoded.length) } });
  return { started, response, finish() { controller.enqueue(encoded); controller.close(); } };
}
test('a request streaming across midnight applies configuration on its actual write date', async () => {
  const h = await taskHabit(); f.setTime('2026-09-04T21:59:59Z');
  const pending = delayedPatch(`/habits/${h.id}`, { name: 'Tomorrow' }); await pending.started;
  f.setTime('2026-09-04T22:01Z'); pending.finish();
  expect((await pending.response).status).toBe(200);
  expect((await f.json(`/habits/${h.id}`)).effectiveDate).toBe('2026-09-05');
  expect((await detail(h.id)).name).toBe('Routine');
});
test('a streaming request observes timezone changes completed before its write', async () => {
  const h = await taskHabit();
  const pending = delayedPatch(`/habits/${h.id}`, { name: 'After travel' }); await pending.started;
  await f.json('/me', 'PATCH', { timezone: 'Pacific/Kiritimati' }); pending.finish();
  expect((await pending.response).status).toBe(200);
  expect((await f.json(`/habits/${h.id}`)).effectiveDate).toBe('2026-09-05');
  expect((await f.json(`/habits/${h.id}/days/2026-09-05`)).timezone).toBe('Pacific/Kiritimati');
  expect((await detail(h.id)).name).toBe('Routine');
});
test('task occurrences retain definition order across dates and revisions', async () => {
  const h = await f.json('/habits', 'POST', { name: 'Order', method: 'tasks', tasks: [{ name: 'First' }, { name: 'Second' }, { name: 'Third' }] }, 201);
  for (let i = 0; i < 3; i++) f.sqlite.query('UPDATE task_occurrences SET id = ? WHERE task_id = ?').run(String(3 - i), h.tasks[i].id);
  expect((await detail(h.id)).tasks.map((t: any) => t.name)).toEqual(['First', 'Second', 'Third']);
  await f.json(`/habits/${h.id}`, 'PATCH', { name: 'Rename' });
  f.setTime('2026-09-05T12:00Z');
  expect((await f.json(`/habits/${h.id}/days/2026-09-05`)).tasks.map((t: any) => t.name)).toEqual(['First', 'Second', 'Third']);
});
test('impossible schedule intersections expose warnings while valid intervals remain allowed', async () => {
  const h = await f.json('/habits', 'POST', { name: 'Monday', method: 'tasks', schedule: { type: 'weekdays', days: [1] }, tasks: [
    { name: 'Friday', schedule: { type: 'weekdays', days: [5] } },
    { name: 'Every three days', schedule: { type: 'interval', every: 3, anchor: '2026-09-04' } },
  ] }, 201);
  expect(h.warnings).toHaveLength(1); expect(h.warnings[0]).toMatchObject({ code: 'task_never_due', taskId: h.tasks[0].id });
  await f.json(`/habits/${h.id}/tasks/${h.tasks[0].id}`, 'PATCH', { schedule: { type: 'daily' } });
  expect((await f.json(`/habits/${h.id}`)).warnings).toEqual([]);
  const empty = await f.json('/habits', 'POST', { name: 'Empty', method: 'tasks' }, 201);
  expect(empty.warnings[0].code).toBe('no_tasks');
});
test('revisions preserve a finalized null expiry when a timezone transition extends the local date', async () => {
  const h = await taskHabit(); await tick(h, true);
  await f.json('/me', 'PATCH', { timezone: 'America/Los_Angeles' });
  f.setTime('2026-09-04T23:00Z'); // Still Sept 4 locally, past its preserved Belgrade deadline.
  await tick(h, false);
  await f.json(`/habits/${h.id}/tasks/${h.tasks[0].id}`, 'PATCH', { name: 'Renamed after deadline' });
  expect((await detail(h.id)).tasks[0].expiredAt).toBeNull();
});
