import test from 'node:test';
import assert from 'node:assert/strict';
import { calculatePlan, exportPlanCsv, plannerExamples, validatePlan, type Plan } from '../../src/tools/planner/core';
import { dateFromFrappe, ganttTasks } from '../../src/tools/planner/chart-model';

const fixture = (): Plan => ({
  schemaVersion: 1, kind: 'project-plan', title: 'Example project', description: '', currency: 'CAD',
  referenceDate: '2026-09-07', contingencyPercent: '15', tasks: [
    { id: 'task-a', phase: 'Discovery', title: 'Task A', start: '2026-09-07', end: '2026-09-11', hours: '40', rate: '100.00', progressPercent: '0', predecessors: [] },
    { id: 'task-b', phase: 'Build', title: 'Task B', start: '2026-09-14', end: '2026-09-18', hours: '60', rate: '125.00', progressPercent: '25', predecessors: ['task-a'] },
  ],
});

test('planner independently matches the manager-approved arithmetic fixture', () => {
  const result = calculatePlan(fixture());
  assert.deepEqual({ hours: result.totalHours, base: result.baseCost, contingency: result.contingency, total: result.totalCost },
    { hours: '100', base: '11500.00', contingency: '1725.00', total: '13225.00' });
  assert.equal(result.taskCosts['task-a'], '4000.00');
  assert.equal(result.taskCosts['task-b'], '7500.00');
  assert.equal(result.calendarDays, 12);
  assert.equal(result.workingDays, 10);
});

test('task costs round half-up before addition, without floating-point errors', () => {
  const plan = fixture();
  plan.tasks[0].hours = '0.1'; plan.tasks[0].rate = '0.05';
  plan.tasks[1].hours = '0.2'; plan.tasks[1].rate = '0.025';
  plan.contingencyPercent = '25';
  const result = calculatePlan(plan);
  assert.equal(result.totalHours, '0.3');
  assert.equal(result.baseCost, '0.02');
  assert.equal(result.contingency, '0.01');
  assert.equal(result.totalCost, '0.03');
});

test('zero-cost work, zero hours and zero contingency remain valid', () => {
  const plan = fixture();
  plan.tasks[0].rate = '0'; plan.tasks[1].hours = '0'; plan.contingencyPercent = '0';
  assert.equal(calculatePlan(plan).totalCost, '0.00');
  assert.equal(calculatePlan(plan).totalHours, '40');
});

test('all three illustrative templates validate with no schedule conflict', () => {
  assert.equal(Object.keys(plannerExamples).length, 3);
  for (const plan of Object.values(plannerExamples)) {
    const result = validatePlan(plan);
    assert.equal(result.ok, true);
    assert.equal(result.warnings.length, 0);
    assert.equal(plan.tasks.length, 6);
  }
});

test('one-day tasks count inclusive calendar and working days', () => {
  const plan = fixture();
  plan.tasks = [{ ...plan.tasks[0], start: '2028-02-29', end: '2028-02-29' }];
  assert.equal(calculatePlan(plan).calendarDays, 1);
  assert.equal(calculatePlan(plan).workingDays, 1);
});

test('date-only calendar spans cross DST, year and leap boundaries', () => {
  const plan = fixture();
  for (const [start, end, days, work] of [
    ['2026-03-06', '2026-03-09', 4, 2], ['2026-10-30', '2026-11-02', 4, 2],
    ['2027-12-31', '2028-01-03', 4, 2], ['2028-02-28', '2028-03-01', 3, 3],
  ] as const) {
    plan.tasks = [{ ...plan.tasks[0], start, end }];
    const result = calculatePlan(plan);
    assert.equal(result.calendarDays, days); assert.equal(result.workingDays, work);
  }
});

test('finish-to-start conflicts warn without silently rescheduling tasks', () => {
  const plan = fixture();
  plan.tasks[1].start = '2026-09-12'; // Saturday is too early after Friday.
  const before = JSON.stringify(plan);
  const checked = validatePlan(plan);
  assert.equal(checked.ok, true);
  assert.equal(checked.warnings.length, 1);
  assert.equal(JSON.stringify(plan), before);
  plan.tasks[1].start = '2026-09-14';
  assert.equal(validatePlan(plan).warnings.length, 0);
});

test('self, missing, duplicate and cyclic predecessor links are rejected', () => {
  for (const predecessors of [['task-b'], ['missing-task'], ['task-a', 'task-a']]) {
    const plan = fixture(); plan.tasks[1].predecessors = predecessors;
    assert.equal(validatePlan(plan).ok, false);
  }
  const plan = fixture(); plan.tasks[0].predecessors = ['task-b'];
  const checked = validatePlan(plan);
  assert.equal(checked.ok, false);
  if (!checked.ok) assert.ok(checked.errors.some(error => error.message.includes('loop')));
});

test('task order does not change identity, dependencies or budget', () => {
  const plan = fixture(); const before = calculatePlan(plan);
  plan.tasks.reverse();
  assert.equal(calculatePlan(plan).totalCost, before.totalCost);
  assert.deepEqual(plan.tasks[0].predecessors, ['task-a']);
  assert.equal(validatePlan(plan).ok, true);
});

test('empty plans and duplicate task IDs are rejected', () => {
  const plan = fixture(); plan.tasks = [];
  assert.equal(validatePlan(plan).ok, false);
  plan.tasks = [fixture().tasks[0], fixture().tasks[0]];
  assert.equal(validatePlan(plan).ok, false);
});

test('invalid numbers, future schema versions and unknown properties are rejected', () => {
  for (const hours of ['-1', 'NaN', 'Infinity', '1e3', '', '0.12345', '100001']) {
    const plan = fixture(); plan.tasks[0].hours = hours;
    assert.equal(validatePlan(plan).ok, false, hours);
  }
  assert.equal(validatePlan({ ...fixture(), schemaVersion: 2 }).ok, false);
  assert.equal(validatePlan({ ...fixture(), executable: 'alert(1)' }).ok, false);
  const plan = fixture(); plan.contingencyPercent = '100.0001';
  assert.equal(validatePlan(plan).ok, false);
});

test('end-before-start and non-existent dates are rejected', () => {
  for (const end of ['2026-09-06', '2027-02-29', '2026-04-31', '2026-13-01', '2026-09-11T00:00:00Z']) {
    const plan = fixture(); plan.tasks[0].end = end;
    assert.equal(validatePlan(plan).ok, false, end);
  }
});

test('prototype keys, complex input and excessive task counts are rejected', () => {
  assert.equal(validatePlan(JSON.parse('{"__proto__":{"polluted":true}}')).ok, false);
  assert.equal(validatePlan({ ...fixture(), constructor: {} }).ok, false);
  const plan = fixture(); plan.tasks = Array.from({ length: 101 }, (_, i) => ({ ...fixture().tasks[0], id: `task-${i}` }));
  assert.equal(validatePlan(plan).ok, false);
  const circular: Record<string, unknown> = {}; circular.self = circular;
  assert.equal(validatePlan(circular).ok, false);
});

test('JSON save and reopen preserve decimal values, task IDs and order', () => {
  const plan = fixture(); plan.tasks[0].rate = '1.005';
  const restored = validatePlan(JSON.parse(JSON.stringify(plan)));
  assert.equal(restored.ok, true);
  if (restored.ok) assert.deepEqual(restored.value, plan);
});

test('CSV neutralizes malicious text, quotes commas/newlines and keeps numbers numeric', () => {
  const plan = fixture();
  plan.tasks[0].title = '=HYPERLINK("https://example.invalid","click")';
  plan.tasks[0].phase = '@SUM(1,2)';
  plan.tasks[1].title = '+command'; plan.tasks[1].phase = 'A, "quoted"\nphase';
  const csv = exportPlanCsv(plan);
  assert.ok(csv.includes('"\'=HYPERLINK(""https://example.invalid"",""click"")"'));
  assert.ok(csv.includes('"\'@SUM(1,2)"'));
  assert.ok(csv.includes('"\'+command"'));
  assert.ok(csv.includes('"A, ""quoted""\nphase"'));
  assert.ok(csv.includes('"40","100.00","4000.00","CAD"'));
});

test('task data cannot smuggle Frappe configuration into JSON import', () => {
  const plan = fixture();
  assert.equal(validatePlan({ ...plan, tasks: [{ ...plan.tasks[0], custom_class: 'unsafe', _start: {} }] }).ok, false);
});

test('Frappe end callbacks use local date parts without subtracting another day', () => {
  for (const hour of [0, 12, 23]) {
    assert.equal(dateFromFrappe(new Date(2026, 2, 8, hour, 59, 59)), '2026-03-08');
  }
  assert.equal(dateFromFrappe(new Date(2028, 1, 29, 23, 59, 59)), '2028-02-29');
  assert.throws(() => dateFromFrappe(new Date('bad')));
});

test('Frappe labels are escaped in a separate rendering copy', () => {
  const plan = fixture();
  plan.tasks[0].title = '<image href="https://example.invalid" onload="alert(1)">';
  const tasks = ganttTasks(plan.tasks);
  assert.ok(tasks[0].name.includes('&lt;image'));
  assert.ok(!tasks[0].name.includes('<'));
  assert.equal(plan.tasks[0].title.startsWith('<image'), true);
  assert.equal(tasks[1].dependencies, 'task-a');
  assert.equal(tasks[1].end, '2026-09-18');
});
