import { describe, it, expect } from 'vitest';

import {
  parseDecimalWithUnit,
  ftToM,
  round2,
  mapStatus,
  parseMapData,
  toNumberish,
  extractNumerics,
  mapRow,
  buildPlan,
  type ExistingBerthRow,
  type ImportedBerth,
} from '@/lib/services/berth-import';
import type { NocoDbRow } from '@/lib/dedup/nocodb-source';

// ─── parseDecimalWithUnit ────────────────────────────────────────────────────

describe('parseDecimalWithUnit', () => {
  it('returns null for null/undefined', () => {
    expect(parseDecimalWithUnit(null)).toBeNull();
    expect(parseDecimalWithUnit(undefined)).toBeNull();
  });
  it('returns finite numbers as-is', () => {
    expect(parseDecimalWithUnit(63)).toBe(63);
    expect(parseDecimalWithUnit(63.5)).toBe(63.5);
    expect(parseDecimalWithUnit(0)).toBe(0);
  });
  it('rejects NaN / Infinity', () => {
    expect(parseDecimalWithUnit(Number.NaN)).toBeNull();
    expect(parseDecimalWithUnit(Number.POSITIVE_INFINITY)).toBeNull();
  });
  it('parses bare numeric strings', () => {
    expect(parseDecimalWithUnit('42')).toBe(42);
    expect(parseDecimalWithUnit('42.5')).toBe(42.5);
  });
  it('strips trailing imperial / metric / power units', () => {
    expect(parseDecimalWithUnit('63ft')).toBe(63);
    expect(parseDecimalWithUnit('63 ft')).toBe(63);
    expect(parseDecimalWithUnit('19 metres')).toBe(19);
    expect(parseDecimalWithUnit('330kw')).toBe(330);
    expect(parseDecimalWithUnit('480 V')).toBe(480);
  });
  it('strips currency markers', () => {
    expect(parseDecimalWithUnit('100 USD')).toBe(100);
    expect(parseDecimalWithUnit('100$')).toBe(100);
  });
  it('returns null on unparseable strings', () => {
    expect(parseDecimalWithUnit('approx 60ft')).toBeNull();
    expect(parseDecimalWithUnit('na')).toBeNull();
  });
  it('returns null on non-string non-number inputs', () => {
    expect(parseDecimalWithUnit({})).toBeNull();
    expect(parseDecimalWithUnit([1])).toBeNull();
    expect(parseDecimalWithUnit(true)).toBeNull();
  });
});

// ─── round2 + ftToM ──────────────────────────────────────────────────────────

describe('round2', () => {
  it('rounds to 2 decimals', () => {
    expect(round2(1.234)).toBe(1.23);
    expect(round2(1.235)).toBe(1.24);
    expect(round2(1)).toBe(1);
  });
  it('passes null through', () => {
    expect(round2(null)).toBeNull();
  });
});

describe('ftToM', () => {
  it('converts feet to meters at 0.3048', () => {
    expect(ftToM(100)).toBe(30.48);
    expect(ftToM(206.69)).toBeCloseTo(63.0, 1);
  });
  it('rounds to 2 decimals', () => {
    expect(ftToM(45)).toBe(13.72);
  });
  it('passes null through', () => {
    expect(ftToM(null)).toBeNull();
  });
});

// ─── mapStatus ───────────────────────────────────────────────────────────────

describe('mapStatus', () => {
  it('maps the three known display values', () => {
    expect(mapStatus('Available')).toBe('available');
    expect(mapStatus('Under Offer')).toBe('under_offer');
    expect(mapStatus('Sold')).toBe('sold');
  });
  it('trims whitespace before matching', () => {
    expect(mapStatus('  Sold  ')).toBe('sold');
  });
  it('falls back to available for unknown / null values', () => {
    expect(mapStatus(null)).toBe('available');
    expect(mapStatus(undefined)).toBe('available');
    expect(mapStatus('Pending')).toBe('available');
    expect(mapStatus(42)).toBe('available');
  });
});

// ─── parseMapData ────────────────────────────────────────────────────────────

describe('parseMapData', () => {
  it('parses a full NocoDB Map Data object', () => {
    const raw = {
      path: 'M838.8 897.2h200.74v44.191H838.8z',
      x: '922.819',
      y: '930.721',
      transform: 'translate(0 409.55)',
      fontSize: '32',
    };
    expect(parseMapData(raw)).toEqual(raw);
  });
  it('accepts numeric x / y / fontSize too', () => {
    expect(parseMapData({ x: 1, y: 2, fontSize: 32 })).toEqual({ x: 1, y: 2, fontSize: 32 });
  });
  it('parses JSON-string Map Data defensively', () => {
    const raw = JSON.stringify({ path: 'M0 0 L1 1', x: '5', y: '10' });
    expect(parseMapData(raw)).toEqual({ path: 'M0 0 L1 1', x: '5', y: '10' });
  });
  it('returns null for null/empty', () => {
    expect(parseMapData(null)).toBeNull();
    expect(parseMapData(undefined)).toBeNull();
    expect(parseMapData('')).toBeNull();
  });
  it('returns null on shape mismatch (e.g. number where path is required)', () => {
    expect(parseMapData({ path: 42 })).toBeNull();
  });
  it('returns null on malformed JSON string', () => {
    expect(parseMapData('{not valid json')).toBeNull();
  });
});

// ─── toNumberish ─────────────────────────────────────────────────────────────

describe('toNumberish', () => {
  it('returns numbers as-is, rejects NaN/Infinity', () => {
    expect(toNumberish(42)).toBe(42);
    expect(toNumberish(Number.NaN)).toBeNull();
    expect(toNumberish(Number.POSITIVE_INFINITY)).toBeNull();
  });
  it('parses numeric strings', () => {
    expect(toNumberish('42')).toBe(42);
    expect(toNumberish('42.5')).toBe(42.5);
  });
  it('returns null on non-numeric / blank', () => {
    expect(toNumberish('')).toBeNull();
    expect(toNumberish(null)).toBeNull();
    expect(toNumberish('apple')).toBeNull();
  });
});

// ─── extractNumerics ─────────────────────────────────────────────────────────

describe('extractNumerics', () => {
  it('extracts the full set + computes metric values from imperial', () => {
    const row: NocoDbRow = {
      Id: 1,
      Length: 206.69,
      Width: 46.56,
      Draft: 14.5,
      'Water Depth': 16.08,
      'Nominal Boat Size': 200,
      'Power Capacity': 330,
      Voltage: 480,
      Price: 3528000,
    };
    const n = extractNumerics(row);
    expect(n.lengthFt).toBe(206.69);
    expect(n.widthFt).toBe(46.56);
    expect(n.draftFt).toBe(14.5);
    expect(n.lengthM).toBe(63);
    expect(n.widthM).toBe(14.19);
    expect(n.draftM).toBe(4.42);
    expect(n.waterDepth).toBe(16.08);
    expect(n.waterDepthM).toBe(4.9);
    expect(n.nominalBoatSize).toBe(200);
    expect(n.nominalBoatSizeM).toBe(60.96);
    expect(n.powerCapacity).toBe(330);
    expect(n.voltage).toBe(480);
    expect(n.price).toBe(3528000);
  });
  it('handles missing fields', () => {
    const n = extractNumerics({ Id: 2 });
    expect(n.lengthFt).toBeNull();
    expect(n.lengthM).toBeNull();
  });
  it('rounds CRM values to 2 decimals to neutralize NocoDB precision drift', () => {
    const n = extractNumerics({ Id: 3, Length: 12.3456789 });
    expect(n.lengthFt).toBe(12.35);
  });
});

// ─── mapRow ──────────────────────────────────────────────────────────────────

describe('mapRow', () => {
  const sampleRow: NocoDbRow = {
    Id: 1,
    'Mooring Number': 'A1',
    Area: 'A',
    Status: 'Under Offer',
    Length: 206.69,
    Width: 46.56,
    Draft: 14.5,
    'Side Pontoon': 'Quay PT',
    'Power Capacity': 330,
    Voltage: 480,
    'Mooring Type': 'Side Pier / Med Mooring',
    'Cleat Type': 'A5',
    'Cleat Capacity': '20-24 ton break load',
    'Bollard Type': 'Bull bollard type B',
    'Bollard Capacity': '40 ton break load',
    Access: 'Car (3t) to Vessel',
    'Bow Facing': 'East',
    'Berth Approved': false,
    'Width Is Minimum': false,
    'Water Depth': 16.08,
    'Water Depth Is Minimum': false,
    'Nominal Boat Size': 200,
    Price: 3528000,
    status_override_mode: 'auto',
    'Map Data': { path: 'M0 0', x: '1', y: '2', transform: '', fontSize: '32' },
  };

  it('maps a representative NocoDB Berth row end-to-end', () => {
    const out = mapRow(sampleRow);
    expect(out).not.toBeNull();
    expect(out!.legacyId).toBe(1);
    expect(out!.mooringNumber).toBe('A1');
    expect(out!.status).toBe('under_offer');
    expect(out!.area).toBe('A');
    expect(out!.numerics.lengthFt).toBe(206.69);
    expect(out!.numerics.lengthM).toBe(63);
    expect(out!.statusOverrideMode).toBe('auto');
    expect(out!.mapData?.path).toBe('M0 0');
  });

  it('returns null when Mooring Number is missing', () => {
    const rest = { ...sampleRow };
    delete (rest as Record<string, unknown>)['Mooring Number'];
    const out = mapRow({ ...rest, Id: 99 } as NocoDbRow);
    expect(out).toBeNull();
  });

  it('trims whitespace from Mooring Number', () => {
    const out = mapRow({ ...sampleRow, 'Mooring Number': '  A1  ' });
    expect(out?.mooringNumber).toBe('A1');
  });

  it('coerces Berth Approved to a strict boolean', () => {
    const a = mapRow({ ...sampleRow, 'Berth Approved': true });
    const b = mapRow({ ...sampleRow, 'Berth Approved': null });
    const c = mapRow({ ...sampleRow, 'Berth Approved': 'yes' });
    expect(a?.berthApproved).toBe(true);
    expect(b?.berthApproved).toBe(false);
    expect(c?.berthApproved).toBe(false);
  });

  it('drops malformed Map Data gracefully', () => {
    const out = mapRow({ ...sampleRow, 'Map Data': { path: 42 } as unknown });
    expect(out?.mapData).toBeNull();
  });
});

// ─── buildPlan ───────────────────────────────────────────────────────────────

describe('buildPlan', () => {
  function importedBerth(mooring: string, overrides: Partial<ImportedBerth> = {}): ImportedBerth {
    return {
      legacyId: 0,
      mooringNumber: mooring,
      area: null,
      status: 'available',
      numerics: extractNumerics({ Id: 0 }),
      widthIsMinimum: false,
      waterDepthIsMinimum: false,
      sidePontoon: null,
      mooringType: null,
      cleatType: null,
      cleatCapacity: null,
      bollardType: null,
      bollardCapacity: null,
      access: null,
      bowFacing: null,
      berthApproved: false,
      statusOverrideMode: null,
      mapData: null,
      ...overrides,
    };
  }
  function existing(
    mooring: string,
    updatedAt: Date,
    lastImportedAt: Date | null,
  ): ExistingBerthRow {
    return { id: `id-${mooring}`, mooringNumber: mooring, updatedAt, lastImportedAt };
  }

  it('plans inserts for moorings missing from CRM', () => {
    const { plan, orphans } = buildPlan([importedBerth('A1')], new Map(), false);
    expect(plan).toEqual([
      expect.objectContaining({
        action: 'insert',
        imported: expect.objectContaining({ mooringNumber: 'A1' }),
      }),
    ]);
    expect(orphans).toEqual([]);
  });

  it('plans updates for unedited rows', () => {
    const map = new Map<string, ExistingBerthRow>([
      ['A1', existing('A1', new Date('2026-04-01'), new Date('2026-04-02'))],
    ]);
    const { plan } = buildPlan([importedBerth('A1')], map, false);
    expect(plan[0]?.action).toBe('update');
  });

  it('skips human-edited rows by default', () => {
    const map = new Map<string, ExistingBerthRow>([
      ['A1', existing('A1', new Date('2026-04-10'), new Date('2026-04-01'))],
    ]);
    const { plan } = buildPlan([importedBerth('A1')], map, false);
    expect(plan[0]?.action).toBe('skip-edited');
    expect(plan[0]?.reason).toMatch(/updated_at=.*last_imported_at=/);
  });

  it('honours --force by updating even human-edited rows', () => {
    const map = new Map<string, ExistingBerthRow>([
      ['A1', existing('A1', new Date('2026-04-10'), new Date('2026-04-01'))],
    ]);
    const { plan } = buildPlan([importedBerth('A1')], map, true);
    expect(plan[0]?.action).toBe('update');
  });

  it('treats lastImportedAt=null as never-imported, not human-edited (so we update)', () => {
    const map = new Map<string, ExistingBerthRow>([
      ['A1', existing('A1', new Date('2026-04-10'), null)],
    ]);
    const { plan } = buildPlan([importedBerth('A1')], map, false);
    expect(plan[0]?.action).toBe('update');
  });

  it('reports CRM-side moorings missing from the import as orphans', () => {
    const map = new Map<string, ExistingBerthRow>([
      ['A1', existing('A1', new Date(), null)],
      ['Z99', existing('Z99', new Date(), null)],
    ]);
    const { plan, orphans } = buildPlan([importedBerth('A1')], map, false);
    expect(plan).toHaveLength(1);
    expect(orphans.map((o) => o.mooringNumber)).toEqual(['Z99']);
  });

  it('1-second tolerance: tiny updated_at lead is treated as in-sync, not edited', () => {
    const lastImported = new Date('2026-04-10T12:00:00.000Z');
    const updatedAt = new Date('2026-04-10T12:00:00.500Z'); // 500ms later
    const map = new Map<string, ExistingBerthRow>([
      ['A1', existing('A1', updatedAt, lastImported)],
    ]);
    const { plan } = buildPlan([importedBerth('A1')], map, false);
    expect(plan[0]?.action).toBe('update');
  });
});
