import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';

// ── DB setup (real in-memory SQLite — same pattern as mcp unit tests) ────────

const { testDb, dbMock } = vi.hoisted(() => {
  const Database = require('better-sqlite3');
  const db = new Database(':memory:');
  db.exec('PRAGMA journal_mode = WAL');
  db.exec('PRAGMA foreign_keys = ON');
  db.exec('PRAGMA busy_timeout = 5000');
  const mock = {
    db,
    closeDb: () => {},
    reinitialize: () => {},
    getPlaceWithTags: () => null,
    canAccessTrip: (tripId: any, userId: number) =>
      db.prepare(`
        SELECT t.id, t.user_id FROM trips t
        LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
        WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)
      `).get(userId, tripId, userId),
    isOwner: (tripId: any, userId: number) =>
      !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
  };
  return { testDb: db, dbMock: mock };
});

vi.mock('../../../src/db/database', () => dbMock);

import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip } from '../../helpers/factories';
import { getStats, getCached, setCache, getCountryFromCoords, getCountryFromAddress, reverseGeocodeCountry, getRegionGeo, getCountryPlaces, getVisitedRegions } from '../../../src/services/atlasService';

function insertPlace(db: any, tripId: number, name: string, address: string | null = null) {
  const cat = db.prepare('SELECT id FROM categories LIMIT 1').get() as { id: number } | undefined;
  const result = db.prepare(
    'INSERT INTO places (trip_id, name, address, category_id) VALUES (?, ?, ?, ?)'
  ).run(tripId, name, address, cat?.id ?? null);
  return db.prepare('SELECT * FROM places WHERE id = ?').get(result.lastInsertRowid);
}

beforeAll(() => {
  createTables(testDb);
  runMigrations(testDb);
});

beforeEach(() => {
  resetTestDb(testDb);
  // Stub fetch so reverseGeocodeCountry never makes real HTTP calls
  vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
    ok: false,
    json: async () => ({}),
  }));
});

afterAll(() => {
  vi.unstubAllGlobals();
  testDb.close();
});

// ── Tests ────────────────────────────────────────────────────────────────────

describe('getStats', () => {
  it('ATLAS-UNIT-001: returns mostVisited null when trips have no resolvable countries (guards reduce on empty array)', async () => {
    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id, { title: 'Mystery Trip' });
    // Place with no address and no coordinates → can't resolve country
    insertPlace(testDb, trip.id, 'Unknown Place', null);

    const stats = await getStats(user.id);

    expect(stats.mostVisited).toBeNull();
    expect(stats.countries).toEqual([]);
    expect(stats.stats.totalPlaces).toBe(1);
    expect(stats.stats.totalCountries).toBe(0);
  });

  it('ATLAS-UNIT-002: returns the country with the highest placeCount as mostVisited', async () => {
    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id, { title: 'Euro Tour' });

    // 3 places in France, 1 in Germany → France should win
    for (let i = 0; i < 3; i++) {
      insertPlace(testDb, trip.id, `Paris Place ${i}`, `Street ${i}, Paris, France`);
    }
    insertPlace(testDb, trip.id, 'Berlin Place', 'Some Street, Berlin, Germany');

    const stats = await getStats(user.id);

    expect(stats.mostVisited).not.toBeNull();
    expect(stats.mostVisited!.code).toBe('FR');
    expect(stats.mostVisited!.placeCount).toBe(3);
    expect(stats.countries).toHaveLength(2);
    expect(stats.stats.totalCountries).toBe(2);
  });

  it('ATLAS-UNIT-003: returns manually marked countries when user has no trips', async () => {
    const { user } = createUser(testDb);
    testDb.prepare('INSERT INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(user.id, 'JP');
    testDb.prepare('INSERT INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(user.id, 'AU');

    const stats = await getStats(user.id);

    expect(stats.countries).toHaveLength(2);
    expect(stats.countries.map((c: { code: string }) => c.code).sort()).toEqual(['AU', 'JP']);
    expect(stats.stats.totalTrips).toBe(0);
    expect(stats.stats.totalCountries).toBe(2);
  });

  it('ATLAS-UNIT-004: single country yields mostVisited equal to that country', async () => {
    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id, { title: 'Italy Trip' });
    insertPlace(testDb, trip.id, 'Colosseum', 'Piazza del Colosseo, Rome, Italy');

    const stats = await getStats(user.id);

    expect(stats.mostVisited).not.toBeNull();
    expect(stats.mostVisited!.code).toBe('IT');
    expect(stats.mostVisited!.placeCount).toBe(1);
  });
});

// ── getCached / setCache ────────────────────────────────────────────────────

describe('getCached and setCache', () => {
  it('ATLAS-SVC-001: getCached returns undefined for unknown coordinates', () => {
    // Use uniquely large lat values to guarantee no prior cache entry
    const result = getCached(9001.001, 9001.001);
    expect(result).toBeUndefined();
  });

  it('ATLAS-SVC-002: setCache then getCached returns the stored code', () => {
    setCache(9002.002, 9002.002, 'DE');
    const result = getCached(9002.002, 9002.002);
    expect(result).toBe('DE');
  });

  it('ATLAS-SVC-003: setCache can store null (country unknown)', () => {
    setCache(9003.003, 9003.003, null);
    const result = getCached(9003.003, 9003.003);
    expect(result).toBeNull();
  });

  it('ATLAS-SVC-004: different coordinates return different cached values', () => {
    setCache(9004.004, 9004.004, 'FR');
    setCache(9004.005, 9004.005, 'ES');
    expect(getCached(9004.004, 9004.004)).toBe('FR');
    expect(getCached(9004.005, 9004.005)).toBe('ES');
  });
});

// ── getCountryFromCoords ────────────────────────────────────────────────────

describe('getCountryFromCoords', () => {
  it('ATLAS-SVC-005: returns country code for Paris coordinates (France)', () => {
    // Paris: approximately 48.85°N, 2.35°E — well inside FR bounding box
    const code = getCountryFromCoords(48.85, 2.35);
    expect(code).toBe('FR');
  });

  it('ATLAS-SVC-006: returns country code for NYC coordinates (USA)', () => {
    // New York City: approximately 40.71°N, -74.0°W — inside US bounding box
    const code = getCountryFromCoords(40.71, -74.0);
    expect(code).toBe('US');
  });

  it('ATLAS-SVC-007: returns null for coordinates with no country match (0,0)', () => {
    // Gulf of Guinea — no COUNTRY_BOXES entry covers 0°N, 0°E
    const code = getCountryFromCoords(0.0, 0.0);
    expect(code).toBeNull();
  });
});

// ── getCountryFromAddress ───────────────────────────────────────────────────

describe('getCountryFromAddress', () => {
  it('ATLAS-SVC-008: returns null for null address', () => {
    expect(getCountryFromAddress(null)).toBeNull();
  });

  it('ATLAS-SVC-009: returns null for empty string', () => {
    expect(getCountryFromAddress('')).toBeNull();
  });

  it('ATLAS-SVC-010: parses "France" in last position to "FR"', () => {
    expect(getCountryFromAddress('Eiffel Tower, Paris, France')).toBe('FR');
  });

  it('ATLAS-SVC-011: returns 2-letter ISO code directly when last part is uppercase 2-letter', () => {
    // "US" is uppercase and exactly 2 characters — returned verbatim
    expect(getCountryFromAddress('123 Main St, New York, US')).toBe('US');
  });

  it('ATLAS-SVC-012: returns null for unrecognized country name', () => {
    expect(getCountryFromAddress('Unknown City, Unknown Country')).toBeNull();
  });
});

// ── reverseGeocodeCountry ───────────────────────────────────────────────────

describe('reverseGeocodeCountry', () => {
  it('ATLAS-SVC-013: returns null when fetch fails (ok:false)', async () => {
    // The beforeEach stub already returns ok:false — this is the default path
    const code = await reverseGeocodeCountry(9013.013, 9013.013);
    expect(code).toBeNull();
  });

  it('ATLAS-SVC-014: returns country code when Nominatim returns valid response', async () => {
    vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
      ok: true,
      json: async () => ({ address: { country_code: 'fr' } }),
    }));
    // Berlin-ish coords not used elsewhere — unique to avoid cache collision
    const code = await reverseGeocodeCountry(52.52, 13.40);
    expect(code).toBe('FR');
  });

  it('ATLAS-SVC-015: returns null when fetch throws a network error', async () => {
    vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
    const code = await reverseGeocodeCountry(9015.015, 9015.015);
    expect(code).toBeNull();
  });

  it('ATLAS-SVC-016: returns cached result on second call (fetch called only once)', async () => {
    const mockFetch = vi.fn().mockResolvedValue({
      ok: true,
      json: async () => ({ address: { country_code: 'gb' } }),
    });
    vi.stubGlobal('fetch', mockFetch);

    // Use unique coords so neither call hits a prior cache entry
    const first = await reverseGeocodeCountry(9016.016, 9016.016);
    const second = await reverseGeocodeCountry(9016.016, 9016.016);

    expect(first).toBe('GB');
    expect(second).toBe('GB');
    // fetch should have been invoked only once; the second call uses the in-memory cache
    expect(mockFetch).toHaveBeenCalledTimes(1);
  });
});

// ── getRegionGeo ────────────────────────────────────────────────────────────

describe('getRegionGeo', () => {
  it('ATLAS-SVC-017: returns empty FeatureCollection when fetch throws a network error', async () => {
    // Override the default stub to throw so loadAdmin1Geo's .catch handler runs,
    // returning null — which causes getRegionGeo to return the empty FeatureCollection.
    // (The default ok:false stub does NOT trigger the catch; it still resolves json()
    // to {}, which loadAdmin1Geo caches as a non-null truthy value.)
    vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network failure')));
    const result = await getRegionGeo(['DE', 'FR']);
    expect(result).toEqual({ type: 'FeatureCollection', features: [] });
  });

  it('ATLAS-SVC-018: returns filtered features for matching country codes when fetch returns mock GeoJSON', async () => {
    // ATLAS-SVC-017 ran with a throwing fetch, so admin1GeoCache is null and
    // admin1GeoLoading is null — this test's fetch override will be called.
    const mockGeoJson = {
      type: 'FeatureCollection',
      features: [
        { type: 'Feature', properties: { iso_a2: 'DE' }, geometry: {} },
        { type: 'Feature', properties: { iso_a2: 'FR' }, geometry: {} },
      ],
    };
    vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
      ok: true,
      json: async () => mockGeoJson,
    }));

    // Pass lowercase 'de' — getRegionGeo uppercases internally for matching
    const result = await getRegionGeo(['de']);

    expect(result.type).toBe('FeatureCollection');
    expect(result.features).toHaveLength(1);
    expect(result.features[0].properties.iso_a2).toBe('DE');
  });
});

// ── Helpers for new tests ────────────────────────────────────────────────────

function insertPlaceWithCoords(db: any, tripId: number, name: string, lat: number, lng: number, address: string | null = null) {
  const cat = db.prepare('SELECT id FROM categories LIMIT 1').get() as { id: number } | undefined;
  const result = db.prepare(
    'INSERT INTO places (trip_id, name, address, lat, lng, category_id) VALUES (?, ?, ?, ?, ?, ?)'
  ).run(tripId, name, address, lat, lng, cat?.id ?? null);
  return db.prepare('SELECT * FROM places WHERE id = ?').get(result.lastInsertRowid);
}

// ── getStats — extended ──────────────────────────────────────────────────────

describe('getStats — extended', () => {
  it('ATLAS-UNIT-005: totalDays is calculated when trip has start_date and end_date', async () => {
    const { user } = createUser(testDb);
    createTrip(testDb, user.id, { title: 'Short Trip', start_date: '2024-03-01', end_date: '2024-03-03' });

    const stats = await getStats(user.id);

    // March 1, 2, 3 → diff = 2 + 1 = 3
    expect(stats.stats.totalDays).toBe(3);
  });

  it('ATLAS-UNIT-006: totalDays is 0 when trip has no dates', async () => {
    const { user } = createUser(testDb);
    createTrip(testDb, user.id, { title: 'Dateless' });

    const stats = await getStats(user.id);

    expect(stats.stats.totalDays).toBe(0);
  });

  it('ATLAS-UNIT-007: manually marked country is merged when user has trips but no resolvable places for that country', async () => {
    const { user } = createUser(testDb);
    createTrip(testDb, user.id, { title: 'Japan Trip', start_date: '2024-01-01', end_date: '2024-01-10' });
    testDb.prepare('INSERT INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(user.id, 'JP');

    const stats = await getStats(user.id);

    const codes = stats.countries.map((c: any) => c.code);
    expect(codes).toContain('JP');
    const jp = stats.countries.find((c: any) => c.code === 'JP');
    expect(jp?.placeCount).toBe(0);
  });

  it('ATLAS-UNIT-008: lastTrip is resolved with a country code when its places have an address', async () => {
    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id, { title: 'Past France Trip', start_date: '2023-05-01', end_date: '2023-05-10' });
    insertPlace(testDb, trip.id, 'Eiffel Tower', 'Champ de Mars, Paris, France');

    const stats = await getStats(user.id);

    expect(stats.lastTrip).not.toBeNull();
    expect(stats.lastTrip!.countryCode).toBe('FR');
  });

  it('ATLAS-UNIT-009: nextTrip has daysUntil calculated', async () => {
    const { user } = createUser(testDb);
    const futureDate = new Date();
    futureDate.setFullYear(futureDate.getFullYear() + 1);
    const futureDateStr = futureDate.toISOString().split('T')[0];
    createTrip(testDb, user.id, { title: 'Future Trip', start_date: futureDateStr });

    const stats = await getStats(user.id);

    expect(stats.nextTrip).not.toBeNull();
    expect(stats.nextTrip!.daysUntil).toBeGreaterThan(0);
  });

  it('ATLAS-UNIT-010: streak counts consecutive years with trips and firstYear is the earliest', async () => {
    const { user } = createUser(testDb);
    const currentYear = new Date().getFullYear();
    createTrip(testDb, user.id, { title: 'This Year', start_date: `${currentYear}-06-01`, end_date: `${currentYear}-06-10` });
    createTrip(testDb, user.id, { title: 'Last Year', start_date: `${currentYear - 1}-07-01`, end_date: `${currentYear - 1}-07-10` });

    const stats = await getStats(user.id);

    expect(stats.streak).toBeGreaterThanOrEqual(1);
    expect(stats.firstYear).toBe(currentYear - 1);
  });

  it('ATLAS-UNIT-011: tripsThisYear counts only trips whose start_date is in the current year', async () => {
    const { user } = createUser(testDb);
    const currentYear = new Date().getFullYear();
    createTrip(testDb, user.id, { title: 'This Year', start_date: `${currentYear}-03-01` });
    createTrip(testDb, user.id, { title: 'Last Year', start_date: `${currentYear - 1}-03-01` });

    const stats = await getStats(user.id);

    expect(stats.tripsThisYear).toBe(1);
  });

  it('ATLAS-UNIT-012: lastTrip is null when all trips end in the future', async () => {
    const { user } = createUser(testDb);
    const nextYear = new Date().getFullYear() + 1;
    createTrip(testDb, user.id, { title: 'Future', start_date: `${nextYear}-01-01`, end_date: `${nextYear}-01-10` });

    const stats = await getStats(user.id);

    expect(stats.lastTrip).toBeNull();
  });
});

// ── getCountryPlaces ─────────────────────────────────────────────────────────

describe('getCountryPlaces', () => {
  it('ATLAS-UNIT-013: returns empty result when user has no trips', () => {
    const { user } = createUser(testDb);

    const result = getCountryPlaces(user.id, 'FR');

    expect(result.places).toHaveLength(0);
    expect(result.trips).toHaveLength(0);
    expect(result.manually_marked).toBe(false);
  });

  it('ATLAS-UNIT-014: returns matching places when place address resolves to the requested country', () => {
    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id, { title: 'France Trip' });
    insertPlace(testDb, trip.id, 'Louvre', '75001 Paris, France');
    insertPlace(testDb, trip.id, 'Berlin Wall', 'Bernauer Str., Berlin, Germany');

    const result = getCountryPlaces(user.id, 'FR');

    expect(result.places).toHaveLength(1);
    expect(result.places[0].name).toBe('Louvre');
    expect(result.trips).toHaveLength(1);
    expect(result.trips[0].id).toBe(trip.id);
  });

  it('ATLAS-UNIT-015: manually_marked is true when country is in visited_countries', () => {
    const { user } = createUser(testDb);
    testDb.prepare('INSERT INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(user.id, 'JP');
    createTrip(testDb, user.id, { title: 'Japan' });

    const result = getCountryPlaces(user.id, 'JP');

    expect(result.manually_marked).toBe(true);
  });

  it('ATLAS-UNIT-016: place with coordinates resolves via bbox when address is absent', () => {
    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id, { title: 'Coord Trip' });
    // Paris coordinates (48.85°N, 2.35°E) — falls inside FR bounding box
    insertPlaceWithCoords(testDb, trip.id, 'Secret Paris Spot', 48.85, 2.35);

    const result = getCountryPlaces(user.id, 'FR');

    expect(result.places).toHaveLength(1);
    expect(result.places[0].name).toBe('Secret Paris Spot');
  });
});

// ── getVisitedRegions ────────────────────────────────────────────────────────

describe('getVisitedRegions', () => {
  it('ATLAS-UNIT-017: returns empty regions object when user has no trips', async () => {
    const { user } = createUser(testDb);

    const result = await getVisitedRegions(user.id);

    expect(result.regions).toEqual({});
  });

  it('ATLAS-UNIT-018: returns manually marked regions even when user has no places with coordinates', async () => {
    const { user } = createUser(testDb);
    testDb.prepare('INSERT INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(user.id, 'DE');
    testDb.prepare('INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)').run(user.id, 'DE-BY', 'Bayern', 'DE');

    const result = await getVisitedRegions(user.id);

    expect(result.regions['DE']).toBeDefined();
    const codes = result.regions['DE'].map((r: any) => r.code);
    expect(codes).toContain('DE-BY');
    const bayernRegion = result.regions['DE'].find((r: any) => r.code === 'DE-BY');
    expect(bayernRegion?.manuallyMarked).toBe(true);
  });

  it('ATLAS-UNIT-019: geocodes places with lat/lng using reverseGeocodeRegion via fetch', async () => {
    vi.useFakeTimers();
    vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
      ok: true,
      json: async () => ({
        address: {
          country_code: 'fr',
          'ISO3166-2-lvl4': 'FR-75',
          state: 'Île-de-France',
        },
      }),
    }));

    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id, { title: 'Paris Trip' });
    insertPlaceWithCoords(testDb, trip.id, 'Paris Hotel', 48.85, 2.35);

    // First call triggers the background geocoding fire-and-forget
    await getVisitedRegions(user.id);
    // Advance all pending timers (including the 1100ms Nominatim rate-limit delay)
    await vi.runAllTimersAsync();
    // Second call returns now-cached data
    const result = await getVisitedRegions(user.id);

    expect(result.regions['FR']).toBeDefined();

    vi.useRealTimers();
  });

  it('ATLAS-UNIT-020: places already cached in place_regions are not re-geocoded', async () => {
    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id, { title: 'Cached Trip' });
    const place = insertPlaceWithCoords(testDb, trip.id, 'Cached Place', 48.85, 2.35);

    // Pre-populate the place_regions cache so the fetch path is never reached
    testDb.prepare(
      'INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)'
    ).run(place.id, 'FR', 'FR-75', 'Île-de-France');

    const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) });
    vi.stubGlobal('fetch', mockFetch);

    const result = await getVisitedRegions(user.id);

    expect(mockFetch).not.toHaveBeenCalled();
    expect(result.regions['FR']).toBeDefined();
    const codes = result.regions['FR'].map((r: any) => r.code);
    expect(codes).toContain('FR-75');
  });
});
