/**
 * Synology Photos integration tests (SYNO-001 – SYNO-040).
 * Covers settings, connection test, search, albums, asset streaming, and access control.
 *
 * safeFetch is mocked to return fake Synology API JSON responses based on the `api`
 * query/body parameter. The Synology service uses POST form-body requests so the mock
 * inspects URLSearchParams to dispatch the right fake response.
 *
 * No real HTTP calls are made.
 */
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';

// ── Hoisted DB mock ──────────────────────────────────────────────────────────

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);
vi.mock('../../src/config', () => ({
  JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
  ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
  updateJwtSecret: () => {},
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));

// ── SSRF guard mock — routes all Synology API calls to fake responses ─────────
vi.mock('../../src/utils/ssrfGuard', async () => {
  const actual = await vi.importActual<typeof import('../../src/utils/ssrfGuard')>('../../src/utils/ssrfGuard');

  function makeFakeSynologyFetch(url: string, init?: any) {
    const u = String(url);

    // Determine which API was called from the URL query param (e.g. ?api=SYNO.API.Auth)
    // or from the body for POST requests.
    let apiName = '';
    let params = new URLSearchParams();
    try {
      params = new URL(u).searchParams;
      apiName = params.get('api') || '';
    } catch {}
    if (!apiName && init?.body) {
      params = init.body instanceof URLSearchParams
        ? init.body
        : new URLSearchParams(String(init.body));
      apiName = params.get('api') || '';
    }

    // Auth login — used by settings save, status, test-connection
    if (apiName === 'SYNO.API.Auth') {
      return Promise.resolve({
        ok: true, status: 200,
        headers: { get: () => 'application/json' },
        json: () => Promise.resolve({ success: true, data: { sid: 'fake-session-id-abc' } }),
        body: null,
      });
    }

    // Album list
    if (apiName === 'SYNO.Foto.Browse.Album') {
      return Promise.resolve({
        ok: true, status: 200,
        headers: { get: () => 'application/json' },
        json: () => Promise.resolve({
          success: true,
          data: {
            list: [
              { id: 1, name: 'Summer Trip', item_count: 15 },
              { id: 2, name: 'Winter Holiday', item_count: 8 },
            ],
          },
        }),
        body: null,
      });
    }

    // Search photos
    if (apiName === 'SYNO.Foto.Search.Search') {
      return Promise.resolve({
        ok: true, status: 200,
        headers: { get: () => 'application/json' },
        json: () => Promise.resolve({
          success: true,
          data: {
            list: [
              {
                id: 101,
                filename: 'photo1.jpg',
                filesize: 1024000,
                time: 1717228800, // 2024-06-01 in Unix timestamp
                additional: {
                  thumbnail: { cache_key: '101_cachekey' },
                  address: { city: 'Tokyo', country: 'Japan', state: 'Tokyo' },
                  exif: { camera: 'Sony A7IV', focal_length: '50', aperture: '1.8', exposure_time: '1/250', iso: 400 },
                  gps: { latitude: 35.6762, longitude: 139.6503 },
                  resolution: { width: 6000, height: 4000 },
                  orientation: 1,
                  description: 'Tokyo street',
                },
              },
            ],
            total: 1,
          },
        }),
        body: null,
      });
    }

    // Browse items (for album sync or asset info)
    if (apiName === 'SYNO.Foto.Browse.Item') {
      return Promise.resolve({
        ok: true, status: 200,
        headers: { get: () => 'application/json' },
        json: () => Promise.resolve({
          success: true,
          data: {
            list: [
              {
                id: 101,
                filename: 'photo1.jpg',
                filesize: 1024000,
                time: 1717228800,
                additional: {
                  thumbnail: { cache_key: '101_cachekey' },
                  address: { city: 'Tokyo', country: 'Japan', state: 'Tokyo' },
                  exif: { camera: 'Sony A7IV' },
                  gps: { latitude: 35.6762, longitude: 139.6503 },
                  resolution: { width: 6000, height: 4000 },
                  orientation: 1,
                  description: null,
                },
              },
            ],
          },
        }),
        body: null,
      });
    }

    // Thumbnail stream
    if (apiName === 'SYNO.Foto.Thumbnail') {
      if (!(['sm', 'm', 'xl', 'preview'].includes(params.get('size') || '')))
        return Promise.reject(new Error(`Unexpected thumbnail size: ${params.get('size')}`));
      const imageBytes = Buffer.from('fake-synology-thumbnail');
      return Promise.resolve({
        ok: true, status: 200,
        headers: { get: (h: string) => h === 'content-type' ? 'image/jpeg' : null },
        body: new ReadableStream({ start(c) { c.enqueue(imageBytes); c.close(); } }),
      });
    }

    return Promise.reject(new Error(`Unexpected safeFetch call to Synology: ${u}, api=${apiName}`));
  }

  return {
    ...actual,
    checkSsrf: vi.fn().mockImplementation(async (rawUrl: string) => {
      try {
        const url = new URL(rawUrl);
        const h = url.hostname;
        if (h === '127.0.0.1' || h === '::1' || h === 'localhost') {
          return { allowed: false, isPrivate: true, error: 'Loopback not allowed' };
        }
        if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(h)) {
          return { allowed: false, isPrivate: true, error: 'Private IP not allowed' };
        }
        return { allowed: true, isPrivate: false, resolvedIp: '93.184.216.34' };
      } catch {
        return { allowed: false, isPrivate: false, error: 'Invalid URL' };
      }
    }),
    safeFetch: vi.fn().mockImplementation(makeFakeSynologyFetch),
  };
});

import { createApp } from '../../src/app';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { createUser, createTrip, addTripMember, addTripPhoto, setSynologyCredentials } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
import { safeFetch } from '../../src/utils/ssrfGuard';

const app: Application = createApp();

const SYNO = '/api/integrations/memories/synologyphotos';

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

beforeEach(() => {
  resetTestDb(testDb);
  loginAttempts.clear();
  mfaAttempts.clear();
});

afterAll(() => testDb.close());

// ── Settings ──────────────────────────────────────────────────────────────────

describe('Synology settings', () => {
  it('SYNO-001 — GET /settings when not configured returns 400', async () => {
    const { user } = createUser(testDb);

    const res = await request(app)
      .get(`${SYNO}/settings`)
      .set('Cookie', authCookie(user.id));

    expect(res.status).toBe(400);
  });

  it('SYNO-002 — PUT /settings saves credentials and returns success', async () => {
    const { user } = createUser(testDb);

    const res = await request(app)
      .put(`${SYNO}/settings`)
      .set('Cookie', authCookie(user.id))
      .send({
        synology_url: 'https://synology.example.com',
        synology_username: 'admin',
        synology_password: 'secure-password',
      });

    expect(res.status).toBe(200);

    const row = testDb.prepare('SELECT synology_url, synology_username FROM users WHERE id = ?').get(user.id) as any;
    expect(row.synology_url).toBe('https://synology.example.com');
    expect(row.synology_username).toBe('admin');
  });

  it('SYNO-003 — PUT /settings with SSRF-blocked URL returns 400', async () => {
    const { user } = createUser(testDb);

    const res = await request(app)
      .put(`${SYNO}/settings`)
      .set('Cookie', authCookie(user.id))
      .send({
        synology_url: 'http://192.168.1.100',
        synology_username: 'admin',
        synology_password: 'pass',
      });

    expect(res.status).toBe(400);
  });

  it('SYNO-004 — PUT /settings without URL returns 400', async () => {
    const { user } = createUser(testDb);

    const res = await request(app)
      .put(`${SYNO}/settings`)
      .set('Cookie', authCookie(user.id))
      .send({ synology_username: 'admin', synology_password: 'pass' }); // no url

    expect(res.status).toBe(400);
  });
});

// ── Connection ────────────────────────────────────────────────────────────────

describe('Synology connection', () => {
  it('SYNO-010 — GET /status when not configured returns { connected: false }', async () => {
    const { user } = createUser(testDb);

    const res = await request(app)
      .get(`${SYNO}/status`)
      .set('Cookie', authCookie(user.id));

    expect(res.status).toBe(200);
    expect(res.body.connected).toBe(false);
  });

  it('SYNO-011 — GET /status when configured returns { connected: true }', async () => {
    const { user } = createUser(testDb);
    setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');

    const res = await request(app)
      .get(`${SYNO}/status`)
      .set('Cookie', authCookie(user.id));

    expect(res.status).toBe(200);
    expect(res.body.connected).toBe(true);
  });

  it('SYNO-012 — POST /test with valid credentials returns { connected: true }', async () => {
    const { user } = createUser(testDb);

    const res = await request(app)
      .post(`${SYNO}/test`)
      .set('Cookie', authCookie(user.id))
      .send({
        synology_url: 'https://synology.example.com',
        synology_username: 'admin',
        synology_password: 'secure-password',
      });

    expect(res.status).toBe(200);
    expect(res.body.connected).toBe(true);
  });

  it('SYNO-013 — POST /test with missing fields returns error', async () => {
    const { user } = createUser(testDb);

    const res = await request(app)
      .post(`${SYNO}/test`)
      .set('Cookie', authCookie(user.id))
      .send({ synology_url: 'https://synology.example.com' }); // missing username+password

    expect(res.status).toBe(200);
    expect(res.body.connected).toBe(false);
    expect(res.body.error).toBeDefined();
  });
});

// ── Search & Albums ───────────────────────────────────────────────────────────

describe('Synology search and albums', () => {
  it('SYNO-020 — POST /search returns mapped assets', async () => {
    const { user } = createUser(testDb);
    setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');

    const res = await request(app)
      .post(`${SYNO}/search`)
      .set('Cookie', authCookie(user.id))
      .send({});

    expect(res.status).toBe(200);
    expect(Array.isArray(res.body.assets)).toBe(true);
    expect(res.body.assets[0]).toMatchObject({ city: 'Tokyo', country: 'Japan' });
  });

  it('SYNO-021 — POST /search when upstream throws propagates 500', async () => {
    const { user } = createUser(testDb);
    setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');

    // Auth call succeeds, search call throws a network error
    vi.mocked(safeFetch)
      .mockResolvedValueOnce({
        ok: true, status: 200,
        headers: { get: () => 'application/json' },
        json: async () => ({ success: true, data: { sid: 'fake-sid' } }),
        body: null,
      } as any)
      .mockRejectedValueOnce(new Error('Synology unreachable'));

    const res = await request(app)
      .post(`${SYNO}/search`)
      .set('Cookie', authCookie(user.id))
      .send({});

    expect(res.status).toBe(500);
    expect(res.body.error).toBeDefined();
  });

  it('SYNO-022 — GET /albums returns album list', async () => {
    const { user } = createUser(testDb);
    setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');

    const res = await request(app)
      .get(`${SYNO}/albums`)
      .set('Cookie', authCookie(user.id));

    expect(res.status).toBe(200);
    expect(Array.isArray(res.body.albums)).toBe(true);
    expect(res.body.albums).toHaveLength(2);
    expect(res.body.albums[0]).toMatchObject({ albumName: 'Summer Trip', assetCount: 15 });
  });
});

// ── Album listing — multi-source merge ───────────────────────────────────────

describe('Synology listSynologyAlbums multi-source merge', () => {
  // Capture and restore the default safeFetch implementation around each test
  // in this block so the persistent mockImplementation we set doesn't leak.
  let _savedImpl: ((...args: any[]) => any) | undefined;
  beforeEach(() => { _savedImpl = vi.mocked(safeFetch).getMockImplementation(); });
  afterEach(() => { if (_savedImpl) vi.mocked(safeFetch).mockImplementation(_savedImpl); });

  it('SYNO-027 — personal-only: shared and shared-with-me return failure → merged result contains personal albums, no error', async () => {
    const { user } = createUser(testDb);
    setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');

    vi.mocked(safeFetch).mockImplementation((_url: string, init?: any) => {
      // Always read both URL params and body params; body takes precedence for request-specific fields.
      const urlParams = (() => { try { return new URL(String(_url)).searchParams; } catch { return new URLSearchParams(); } })();
      const bodyParams: URLSearchParams = init?.body instanceof URLSearchParams ? init.body : new URLSearchParams(String(init?.body ?? ''));
      const api = urlParams.get('api') || bodyParams.get('api') || '';
      const category = bodyParams.get('category') || urlParams.get('category');

      if (api === 'SYNO.API.Auth') {
        return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { sid: 'sid-027' } }), body: null } as any);
      }
      if (api === 'SYNO.Foto.Browse.Album') {
        if (!category) {
          // personal albums
          return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { list: [{ id: 1, name: 'Personal Album', item_count: 5 }] } }), body: null } as any);
        }
        // shared category → failure
        return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: false, error: { code: 400 } }), body: null } as any);
      }
      if (api === 'SYNO.Foto.Sharing.Misc') {
        return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: false, error: { code: 400 } }), body: null } as any);
      }
      return Promise.reject(new Error(`Unexpected API: ${api}`));
    });

    const res = await request(app)
      .get(`${SYNO}/albums`)
      .set('Cookie', authCookie(user.id));

    expect(res.status).toBe(200);
    expect(Array.isArray(res.body.albums)).toBe(true);
    expect(res.body.albums).toHaveLength(1);
    expect(res.body.albums[0]).toMatchObject({ albumName: 'Personal Album', assetCount: 5 });
  });

  it('SYNO-028 — full merge: personal + shared (with passphrase) + shared-with-me (with sharing_info.passphrase) → 4 albums with correct passphrases', async () => {
    const { user } = createUser(testDb);
    setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');

    vi.mocked(safeFetch).mockImplementation((_url: string, init?: any) => {
      const urlParams = (() => { try { return new URL(String(_url)).searchParams; } catch { return new URLSearchParams(); } })();
      const bodyParams: URLSearchParams = init?.body instanceof URLSearchParams ? init.body : new URLSearchParams(String(init?.body ?? ''));
      const api = urlParams.get('api') || bodyParams.get('api') || '';
      const category = bodyParams.get('category') || urlParams.get('category');

      if (api === 'SYNO.API.Auth') {
        return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { sid: 'sid-028' } }), body: null } as any);
      }
      if (api === 'SYNO.Foto.Browse.Album') {
        if (!category) {
          return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { list: [{ id: 10, name: 'Alpha Album', item_count: 3 }, { id: 11, name: 'Beta Album', item_count: 7 }] } }), body: null } as any);
        }
        // shared category — one album with passphrase
        return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { list: [{ id: 20, name: 'Shared Out', item_count: 2, passphrase: 'pp-abc' }] } }), body: null } as any);
      }
      if (api === 'SYNO.Foto.Sharing.Misc') {
        return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { list: [{ id: 30, name: 'Shared With Me', item_count: 4, sharing_info: { passphrase: 'pp-xyz' } }] } }), body: null } as any);
      }
      return Promise.reject(new Error(`Unexpected API: ${api}`));
    });

    const res = await request(app)
      .get(`${SYNO}/albums`)
      .set('Cookie', authCookie(user.id));

    expect(res.status).toBe(200);
    expect(Array.isArray(res.body.albums)).toBe(true);
    expect(res.body.albums).toHaveLength(4);

    const byName = (name: string) => res.body.albums.find((a: any) => a.albumName === name);
    expect(byName('Alpha Album')).toMatchObject({ id: '10', assetCount: 3 });
    expect(byName('Beta Album')).toMatchObject({ id: '11', assetCount: 7 });
    expect(byName('Shared Out')).toMatchObject({ id: '20', passphrase: 'pp-abc' });
    expect(byName('Shared With Me')).toMatchObject({ id: '30', passphrase: 'pp-xyz' });

    // personal albums carry no passphrase
    expect(byName('Alpha Album').passphrase).toBeUndefined();
  });

  it('SYNO-029 — dedup: same album id=99 in personal and shared-with-me → last-write-wins gives passphrase from shared-with-me', async () => {
    const { user } = createUser(testDb);
    setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');

    vi.mocked(safeFetch).mockImplementation((_url: string, init?: any) => {
      const urlParams = (() => { try { return new URL(String(_url)).searchParams; } catch { return new URLSearchParams(); } })();
      const bodyParams: URLSearchParams = init?.body instanceof URLSearchParams ? init.body : new URLSearchParams(String(init?.body ?? ''));
      const api = urlParams.get('api') || bodyParams.get('api') || '';
      const category = bodyParams.get('category') || urlParams.get('category');

      if (api === 'SYNO.API.Auth') {
        return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { sid: 'sid-029' } }), body: null } as any);
      }
      if (api === 'SYNO.Foto.Browse.Album') {
        if (!category) {
          // personal: album id=99 without passphrase
          return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { list: [{ id: 99, name: 'Dup Album', item_count: 10 }] } }), body: null } as any);
        }
        // shared: no entries
        return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { list: [] } }), body: null } as any);
      }
      if (api === 'SYNO.Foto.Sharing.Misc') {
        // shared-with-me: same album id=99 with passphrase
        return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { list: [{ id: 99, name: 'Dup Album', item_count: 10, passphrase: 'pp-dup' }] } }), body: null } as any);
      }
      return Promise.reject(new Error(`Unexpected API: ${api}`));
    });

    const res = await request(app)
      .get(`${SYNO}/albums`)
      .set('Cookie', authCookie(user.id));

    expect(res.status).toBe(200);
    expect(Array.isArray(res.body.albums)).toBe(true);
    // Deduplicated to a single album
    expect(res.body.albums).toHaveLength(1);
    expect(res.body.albums[0]).toMatchObject({ id: '99', albumName: 'Dup Album' });
    // shared-with-me wins (last write) → passphrase present
    expect(res.body.albums[0].passphrase).toBe('pp-dup');
  });
});

// ── Asset access ──────────────────────────────────────────────────────────────

describe('Synology asset access', () => {
  it('SYNO-030 — GET /assets/info returns metadata for own photo', async () => {
    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id);
    setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
    addTripPhoto(testDb, trip.id, user.id, '101_cachekey', 'synologyphotos', { shared: false });

    const res = await request(app)
      .get(`${SYNO}/assets/${trip.id}/101_cachekey/${user.id}/info`)
      .set('Cookie', authCookie(user.id));

    expect(res.status).toBe(200);
    expect(res.body).toMatchObject({ city: 'Tokyo', country: 'Japan' });
  });

  it('SYNO-031 — GET /assets/info by non-owner of unshared photo returns 403', async () => {
    const { user: owner } = createUser(testDb);
    const { user: member } = createUser(testDb);
    const trip = createTrip(testDb, owner.id);
    addTripMember(testDb, trip.id, member.id);
    addTripPhoto(testDb, trip.id, owner.id, '101_cachekey', 'synologyphotos', { shared: false });

    const res = await request(app)
      .get(`${SYNO}/assets/${trip.id}/101_cachekey/${owner.id}/info`)
      .set('Cookie', authCookie(member.id));

    expect(res.status).toBe(403);
  });

  it('SYNO-032 — GET /assets/thumbnail streams image data for own photo', async () => {
    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id);
    setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
    addTripPhoto(testDb, trip.id, user.id, '101_cachekey', 'synologyphotos', { shared: false });

    const res = await request(app)
      .get(`${SYNO}/assets/${trip.id}/101_cachekey/${user.id}/thumbnail`)
      .set('Cookie', authCookie(user.id));

    expect(res.status).toBe(200);
    expect(res.headers['content-type']).toContain('image/jpeg');
  });

  it('SYNO-032b — GET /api/photos/:id/thumbnail uses an allowed Synology thumbnail size', async () => {
    const { user } = createUser(testDb);
    setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');

    const insert = testDb.prepare(
      'INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)'
    ).run('synologyphotos', '101_cachekey', user.id);
    const trekPhotoId = Number(insert.lastInsertRowid);

    vi.mocked(safeFetch).mockClear();

    const res = await request(app)
      .get(`/api/photos/${trekPhotoId}/thumbnail`)
      .set('Cookie', authCookie(user.id));

    expect(res.status).toBe(200);
  });

  it('SYNO-033 — GET /assets/original streams image data for shared photo', async () => {
    const { user: owner } = createUser(testDb);
    const { user: member } = createUser(testDb);
    const trip = createTrip(testDb, owner.id);
    addTripMember(testDb, trip.id, member.id);
    setSynologyCredentials(testDb, owner.id, 'https://synology.example.com', 'admin', 'pass');
    addTripPhoto(testDb, trip.id, owner.id, '101_cachekey', 'synologyphotos', { shared: true });

    const res = await request(app)
      .get(`${SYNO}/assets/${trip.id}/101_cachekey/${owner.id}/original`)
      .set('Cookie', authCookie(member.id));

    expect(res.status).toBe(200);
    expect(res.headers['content-type']).toContain('image/jpeg');
  });

  it('SYNO-034 — GET /assets with invalid kind returns 400', async () => {
    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id);
    addTripPhoto(testDb, trip.id, user.id, '101_cachekey', 'synologyphotos', { shared: false });

    const res = await request(app)
      .get(`${SYNO}/assets/${trip.id}/101_cachekey/${user.id}/badkind`)
      .set('Cookie', authCookie(user.id));

    expect(res.status).toBe(400);
  });

  it('SYNO-035 — GET /assets/info where trip does not exist returns 403', async () => {
    const { user: owner } = createUser(testDb);
    const { user: member } = createUser(testDb);
    // Insert a shared photo referencing a trip that doesn't exist (FK disabled temporarily)
    testDb.exec('PRAGMA foreign_keys = OFF');
    testDb.prepare('INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('synologyphotos', '101_cachekey', owner.id);
    const tkpSyno35 = testDb.prepare('SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?').get('synologyphotos', '101_cachekey', owner.id) as any;
    testDb.prepare(
      'INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, ?)'
    ).run(9999, owner.id, tkpSyno35.id, 1);
    testDb.exec('PRAGMA foreign_keys = ON');

    const res = await request(app)
      .get(`${SYNO}/assets/9999/101_cachekey/${owner.id}/info`)
      .set('Cookie', authCookie(member.id));

    // canAccessUserPhoto: shared photo found, but canAccessTrip(9999) → null → false → 403
    expect(res.status).toBe(403);
  });

  it('SYNO-036 — GET /assets/info when upstream throws propagates 500', async () => {
    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id);
    setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
    addTripPhoto(testDb, trip.id, user.id, '101_cachekey', 'synologyphotos', { shared: false });

    // Auth call succeeds, Browse.Item call throws a network error
    vi.mocked(safeFetch)
      .mockResolvedValueOnce({
        ok: true, status: 200,
        headers: { get: () => 'application/json' },
        json: async () => ({ success: true, data: { sid: 'fake-sid' } }),
        body: null,
      } as any)
      .mockRejectedValueOnce(new Error('network failure'));

    const res = await request(app)
      .get(`${SYNO}/assets/${trip.id}/101_cachekey/${user.id}/info`)
      .set('Cookie', authCookie(user.id));

    expect(res.status).toBe(500);
    expect(res.body.error).toBeDefined();
  });
});

// ── Auth checks ───────────────────────────────────────────────────────────────

describe('Synology auth checks', () => {
  it('SYNO-040 — GET /settings without auth returns 401', async () => {
    expect((await request(app).get(`${SYNO}/settings`)).status).toBe(401);
  });

  it('SYNO-040 — PUT /settings without auth returns 401', async () => {
    expect((await request(app).put(`${SYNO}/settings`)).status).toBe(401);
  });

  it('SYNO-040 — GET /status without auth returns 401', async () => {
    expect((await request(app).get(`${SYNO}/status`)).status).toBe(401);
  });

  it('SYNO-040 — POST /test without auth returns 401', async () => {
    expect((await request(app).post(`${SYNO}/test`)).status).toBe(401);
  });

  it('SYNO-040 — GET /albums without auth returns 401', async () => {
    expect((await request(app).get(`${SYNO}/albums`)).status).toBe(401);
  });

  it('SYNO-040 — POST /search without auth returns 401', async () => {
    expect((await request(app).post(`${SYNO}/search`)).status).toBe(401);
  });

  it('SYNO-040 — GET /assets/info without auth returns 401', async () => {
    expect((await request(app).get(`${SYNO}/assets/1/photo-x/1/info`)).status).toBe(401);
  });

  it('SYNO-040 — GET /assets/thumbnail without auth returns 401', async () => {
    expect((await request(app).get(`${SYNO}/assets/1/photo-x/1/thumbnail`)).status).toBe(401);
  });
});

// ── Album sync ────────────────────────────────────────────────────────────────

import { addAlbumLink } from '../helpers/factories';
import { encrypt_api_key } from '../../src/services/apiKeyCrypto';

describe('Synology syncSynologyAlbumLink', () => {
  it('SYNO-050 — POST sync happy path: trip owner with album link saves photos to DB', async () => {
    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id);
    setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
    // The migration inserts synologyphotos with enabled=0; ensure it is enabled for this test.
    testDb.prepare("UPDATE photo_providers SET enabled = 1 WHERE id = 'synologyphotos'").run();
    // album_id must be a numeric string so getAlbumIdFromLink returns it and
    // syncSynologyAlbumLink passes Number(album_id) to the API.
    const link = addAlbumLink(testDb, trip.id, user.id, 'synologyphotos', '1', 'Summer Trip');

    const res = await request(app)
      .post(`${SYNO}/trips/${trip.id}/album-links/${link.id}/sync`)
      .set('Cookie', authCookie(user.id));

    expect(res.status).toBe(200);
    expect(typeof res.body.added).toBe('number');
    expect(typeof res.body.total).toBe('number');

    // Verify photos were inserted into the DB
    const photos = testDb.prepare(`
      SELECT tp.*, tkp.provider FROM trip_photos tp
      JOIN trek_photos tkp ON tkp.id = tp.photo_id
      WHERE tp.trip_id = ? AND tp.user_id = ?
    `).all(trip.id, user.id) as any[];
    expect(photos.length).toBeGreaterThan(0);
    expect(photos[0].provider).toBe('synologyphotos');
  });

  it('SYNO-051 — POST sync when user is not a trip member returns 404', async () => {
    const { user: owner } = createUser(testDb);
    const { user: outsider } = createUser(testDb);
    const trip = createTrip(testDb, owner.id);
    setSynologyCredentials(testDb, owner.id, 'https://synology.example.com', 'admin', 'pass');
    const link = addAlbumLink(testDb, trip.id, owner.id, 'synologyphotos', '1', 'Summer Trip');

    const res = await request(app)
      .post(`${SYNO}/trips/${trip.id}/album-links/${link.id}/sync`)
      .set('Cookie', authCookie(outsider.id));

    expect(res.status).toBe(404);
  });

  it('SYNO-052 — POST sync when Synology is not configured returns 400', async () => {
    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id);
    // No credentials — album link still exists for the user
    const link = addAlbumLink(testDb, trip.id, user.id, 'synologyphotos', '1', 'Summer Trip');

    const res = await request(app)
      .post(`${SYNO}/trips/${trip.id}/album-links/${link.id}/sync`)
      .set('Cookie', authCookie(user.id));

    expect(res.status).toBe(400);
    expect(res.body.error).toBeDefined();
  });

  it('SYNO-053 — POST sync without auth returns 401', async () => {
    expect((await request(app).post(`${SYNO}/trips/1/album-links/1/sync`)).status).toBe(401);
  });

  it('SYNO-054 — POST sync with passphrase link: uses passphrase in item-list call and persists encrypted passphrase on trek_photos', async () => {
    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id);
    setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
    testDb.prepare("UPDATE photo_providers SET enabled = 1 WHERE id = 'synologyphotos'").run();

    // Insert a link with an encrypted passphrase directly into the DB.
    const rawPassphrase = 'syno-share-pass-abc';
    const result = testDb.prepare(
      'INSERT INTO trip_album_links (trip_id, user_id, provider, album_id, album_name, passphrase) VALUES (?, ?, ?, ?, ?, ?)'
    ).run(trip.id, user.id, 'synologyphotos', '99', 'Shared Album', encrypt_api_key(rawPassphrase));
    const link = testDb.prepare('SELECT * FROM trip_album_links WHERE id = ?').get(result.lastInsertRowid) as any;

    // Override safeFetch so browse-item only succeeds when called with the passphrase param.
    vi.mocked(safeFetch).mockImplementation(async (url: any, init?: any) => {
      const bodyParams = init?.body instanceof URLSearchParams
        ? init.body
        : new URLSearchParams(String(init?.body ?? ''));
      const apiName = bodyParams.get('api') || (new URL(String(url)).searchParams.get('api') ?? '');

      if (apiName === 'SYNO.API.Auth') {
        return { ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { sid: 'fake-sid-054' } }), body: null } as any;
      }

      if (apiName === 'SYNO.Foto.Browse.Item') {
        // Only respond successfully when the passphrase param is present.
        if (bodyParams.get('passphrase') !== rawPassphrase) {
          return { ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: false, error: { code: 105 } }), body: null } as any;
        }
        return {
          ok: true, status: 200,
          headers: { get: () => 'application/json' },
          json: async () => ({
            success: true,
            data: {
              list: [{ id: 201, filename: 'shared.jpg', filesize: 512000, time: 1717228800, additional: { thumbnail: { cache_key: '201_sharedkey' } } }],
            },
          }),
          body: null,
        } as any;
      }

      return Promise.reject(new Error(`SYNO-054: unexpected safeFetch call: api=${apiName}`));
    });

    const res = await request(app)
      .post(`${SYNO}/trips/${trip.id}/album-links/${link.id}/sync`)
      .set('Cookie', authCookie(user.id));

    expect(res.status).toBe(200);
    expect(res.body.added).toBeGreaterThan(0);

    // The trek_photos row for the synced photo must have a non-null passphrase.
    const photo = testDb.prepare(`
      SELECT tkp.passphrase FROM trip_photos tp
      JOIN trek_photos tkp ON tkp.id = tp.photo_id
      WHERE tp.trip_id = ? AND tp.user_id = ?
      LIMIT 1
    `).get(trip.id, user.id) as { passphrase: string | null } | undefined;

    expect(photo).toBeDefined();
    expect(photo!.passphrase).not.toBeNull();
  });
});

// ── Session retry logic ───────────────────────────────────────────────────────

describe('Synology session retry on error codes 106/107/119', () => {
  it('SYNO-060 — request retries with fresh session when API returns error code 119', async () => {
    const { user } = createUser(testDb);
    setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');

    // Clear previous call history so the count only reflects this test's calls
    vi.mocked(safeFetch).mockClear();

    // Call sequence:
    //   1. Auth login (fresh session — no cached SID) → success with sid
    //   2. SYNO.Foto.Browse.Album call → returns { success: false, error: { code: 119 } }
    //   3. Auth login again (retry session after clearing SID) → success with new sid
    //   4. SYNO.Foto.Browse.Album retry call → success
    vi.mocked(safeFetch)
      .mockResolvedValueOnce({
        // call 1: initial login
        ok: true, status: 200,
        headers: { get: () => 'application/json' },
        json: async () => ({ success: true, data: { sid: 'first-sid' } }),
        body: null,
      } as any)
      .mockResolvedValueOnce({
        // call 2: album list → session expired (119)
        ok: true, status: 200,
        headers: { get: () => 'application/json' },
        json: async () => ({ success: false, error: { code: 119 } }),
        body: null,
      } as any)
      .mockResolvedValueOnce({
        // call 3: retry login after clearing SID
        ok: true, status: 200,
        headers: { get: () => 'application/json' },
        json: async () => ({ success: true, data: { sid: 'second-sid' } }),
        body: null,
      } as any)
      .mockResolvedValueOnce({
        // call 4: retry album list → success
        ok: true, status: 200,
        headers: { get: () => 'application/json' },
        json: async () => ({
          success: true,
          data: {
            list: [{ id: 99, name: 'Retry Album', item_count: 5 }],
          },
        }),
        body: null,
      } as any);

    const res = await request(app)
      .get(`${SYNO}/albums`)
      .set('Cookie', authCookie(user.id));

    expect(res.status).toBe(200);
    expect(Array.isArray(res.body.albums)).toBe(true);
    expect(res.body.albums[0]).toMatchObject({ albumName: 'Retry Album' });
    // Five safeFetch calls: login, failed album list (119), re-login, successful album list retry,
    // plus one additional call for the shared or shared-with-me source (handled by default mock)
    expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(5);
  });

  it('SYNO-061 — request retries with fresh session when API returns error code 106', async () => {
    const { user } = createUser(testDb);
    setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');

    vi.mocked(safeFetch).mockClear();
    vi.mocked(safeFetch)
      .mockResolvedValueOnce({
        ok: true, status: 200,
        headers: { get: () => 'application/json' },
        json: async () => ({ success: true, data: { sid: 'sid-one' } }),
        body: null,
      } as any)
      .mockResolvedValueOnce({
        ok: true, status: 200,
        headers: { get: () => 'application/json' },
        json: async () => ({ success: false, error: { code: 106 } }),
        body: null,
      } as any)
      .mockResolvedValueOnce({
        ok: true, status: 200,
        headers: { get: () => 'application/json' },
        json: async () => ({ success: true, data: { sid: 'sid-two' } }),
        body: null,
      } as any)
      .mockResolvedValueOnce({
        ok: true, status: 200,
        headers: { get: () => 'application/json' },
        json: async () => ({
          success: true,
          data: { list: [{ id: 3, name: 'Timeout Album', item_count: 2 }] },
        }),
        body: null,
      } as any);

    const res = await request(app)
      .get(`${SYNO}/albums`)
      .set('Cookie', authCookie(user.id));

    expect(res.status).toBe(200);
    expect(res.body.albums[0]).toMatchObject({ albumName: 'Timeout Album' });
    // Five safeFetch calls: login, failed album list (106), re-login, successful album list retry,
    // plus one additional call for the shared or shared-with-me source (handled by default mock)
    expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(5);
  });
});

// ── Date range search ─────────────────────────────────────────────────────────

describe('Synology searchSynologyPhotos date range', () => {
  it('SYNO-070 — POST /search with from/to passes start_time and end_time to Synology API', async () => {
    const { user } = createUser(testDb);
    setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');

    // Capture the body sent on the search call (second safeFetch call after auth)
    let capturedBody: URLSearchParams | null = null;
    vi.mocked(safeFetch)
      .mockResolvedValueOnce({
        // login
        ok: true, status: 200,
        headers: { get: () => 'application/json' },
        json: async () => ({ success: true, data: { sid: 'fake-sid' } }),
        body: null,
      } as any)
      .mockImplementationOnce((_url: string, init?: any) => {
        capturedBody = init?.body instanceof URLSearchParams
          ? init.body
          : new URLSearchParams(String(init?.body ?? ''));
        return Promise.resolve({
          ok: true, status: 200,
          headers: { get: () => 'application/json' },
          json: async () => ({
            success: true,
            data: {
              list: [
                {
                  id: 201,
                  filename: 'dated.jpg',
                  filesize: 512000,
                  time: 1717228800,
                  additional: {
                    thumbnail: { cache_key: '201_abc' },
                    address: { city: 'Kyoto', country: 'Japan', state: 'Kyoto' },
                    exif: {},
                    gps: {},
                    resolution: { width: 4000, height: 3000 },
                    orientation: 1,
                    description: null,
                  },
                },
              ],
            },
          }),
          body: null,
        } as any);
      });

    const res = await request(app)
      .post(`${SYNO}/search`)
      .set('Cookie', authCookie(user.id))
      .send({ from: '2024-06-01', to: '2024-06-30' });

    expect(res.status).toBe(200);
    expect(Array.isArray(res.body.assets)).toBe(true);

    // Verify date parameters were forwarded in the Synology API request body
    expect(capturedBody).not.toBeNull();
    const startTime = capturedBody!.get('start_time');
    const endTime = capturedBody!.get('end_time');
    expect(startTime).toBeDefined();
    expect(Number(startTime)).toBeGreaterThan(0);
    expect(endTime).toBeDefined();
    expect(Number(endTime)).toBeGreaterThan(Number(startTime));
  });

  it('SYNO-071 — POST /search without date range omits start_time and end_time', async () => {
    const { user } = createUser(testDb);
    setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');

    let capturedBody: URLSearchParams | null = null;
    vi.mocked(safeFetch)
      .mockResolvedValueOnce({
        ok: true, status: 200,
        headers: { get: () => 'application/json' },
        json: async () => ({ success: true, data: { sid: 'fake-sid' } }),
        body: null,
      } as any)
      .mockImplementationOnce((_url: string, init?: any) => {
        capturedBody = init?.body instanceof URLSearchParams
          ? init.body
          : new URLSearchParams(String(init?.body ?? ''));
        return Promise.resolve({
          ok: true, status: 200,
          headers: { get: () => 'application/json' },
          json: async () => ({ success: true, data: { list: [] } }),
          body: null,
        } as any);
      });

    const res = await request(app)
      .post(`${SYNO}/search`)
      .set('Cookie', authCookie(user.id))
      .send({});

    expect(res.status).toBe(200);
    expect(capturedBody).not.toBeNull();
    expect(capturedBody!.get('start_time')).toBeNull();
    expect(capturedBody!.get('end_time')).toBeNull();
  });
});

// ── Search pagination ─────────────────────────────────────────────────────────

describe('Synology search pagination', () => {
  it('SYNO-025 — POST /search with { page: 2, size: 50 } sends offset=50 and limit=50 to Synology API', async () => {
    const { user } = createUser(testDb);
    setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');

    let capturedBody: URLSearchParams | null = null;
    vi.mocked(safeFetch)
      .mockResolvedValueOnce({
        // login
        ok: true, status: 200,
        headers: { get: () => 'application/json' },
        json: async () => ({ success: true, data: { sid: 'fake-sid' } }),
        body: null,
      } as any)
      .mockImplementationOnce((_url: string, init?: any) => {
        capturedBody = init?.body instanceof URLSearchParams
          ? init.body
          : new URLSearchParams(String(init?.body ?? ''));
        return Promise.resolve({
          ok: true, status: 200,
          headers: { get: () => 'application/json' },
          json: async () => ({ success: true, data: { list: [] } }),
          body: null,
        } as any);
      });

    const res = await request(app)
      .post(`${SYNO}/search`)
      .set('Cookie', authCookie(user.id))
      .send({ page: 2, size: 50 });

    expect(res.status).toBe(200);
    expect(capturedBody).not.toBeNull();
    // With the fix: limit=50 is resolved first, then offset = (2-1)*50 = 50
    expect(capturedBody!.get('offset')).toBe('50');
    expect(capturedBody!.get('limit')).toBe('50');
  });

  it('SYNO-026 — POST /search with { page: 3, size: 25 } sends offset=50 and limit=25 to Synology API', async () => {
    const { user } = createUser(testDb);
    setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');

    let capturedBody: URLSearchParams | null = null;
    vi.mocked(safeFetch)
      .mockResolvedValueOnce({
        ok: true, status: 200,
        headers: { get: () => 'application/json' },
        json: async () => ({ success: true, data: { sid: 'fake-sid' } }),
        body: null,
      } as any)
      .mockImplementationOnce((_url: string, init?: any) => {
        capturedBody = init?.body instanceof URLSearchParams
          ? init.body
          : new URLSearchParams(String(init?.body ?? ''));
        return Promise.resolve({
          ok: true, status: 200,
          headers: { get: () => 'application/json' },
          json: async () => ({ success: true, data: { list: [] } }),
          body: null,
        } as any);
      });

    const res = await request(app)
      .post(`${SYNO}/search`)
      .set('Cookie', authCookie(user.id))
      .send({ page: 3, size: 25 });

    expect(res.status).toBe(200);
    expect(capturedBody).not.toBeNull();
    // page 3 → page index = 2 (after subtracting 1), offset = 2 * 25 = 50
    expect(capturedBody!.get('offset')).toBe('50');
    expect(capturedBody!.get('limit')).toBe('25');
  });
});

// ── SSRF catch branch in _fetchSynologyJson ────────────────────────────────────

describe('Synology SSRF blocked error handling', () => {
  it('SYNO-080 — safeFetch throwing SsrfBlockedError for private IP URL returns connected: false', async () => {
    const { user } = createUser(testDb);
    setSynologyCredentials(testDb, user.id, 'http://192.168.1.200', 'admin', 'pass');

    const { SsrfBlockedError: SsrfErr } = await import('../../src/utils/ssrfGuard');

    // Make safeFetch throw SsrfBlockedError — simulating the SSRF guard blocking the private IP.
    // _fetchSynologyJson catches SsrfBlockedError and returns fail(message, 400).
    // getSynologyStatus receives the failure from _getSynologySession and returns { connected: false }.
    vi.mocked(safeFetch).mockRejectedValueOnce(new SsrfErr('Private IP not allowed'));

    const res = await request(app)
      .get(`${SYNO}/status`)
      .set('Cookie', authCookie(user.id));

    expect(res.status).toBe(200);
    expect(res.body.connected).toBe(false);
  });

  it('SYNO-081 — safeFetch throwing SsrfBlockedError during one album source is swallowed; other sources still return albums', async () => {
    const { user } = createUser(testDb);
    setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');

    const { SsrfBlockedError: SsrfErr } = await import('../../src/utils/ssrfGuard');

    const emptyAlbumResponse = {
      ok: true, status: 200,
      headers: { get: () => 'application/json' },
      json: async () => ({ success: true, data: { list: [{ id: 99, name: 'Shared Album', item_count: 2, passphrase: 'pp-test' }] } }),
      body: null,
    } as any;

    // Auth succeeds, personal album source throws SSRF, shared + shared-with-me succeed.
    // listSynologyAlbums uses Promise.allSettled so the SSRF failure is logged and skipped.
    vi.mocked(safeFetch)
      .mockResolvedValueOnce({
        ok: true, status: 200,
        headers: { get: () => 'application/json' },
        json: async () => ({ success: true, data: { sid: 'sid-x' } }),
        body: null,
      } as any)
      .mockRejectedValueOnce(new SsrfErr('Private IP detected'))
      .mockResolvedValueOnce(emptyAlbumResponse)
      .mockResolvedValueOnce(emptyAlbumResponse);

    const res = await request(app)
      .get(`${SYNO}/albums`)
      .set('Cookie', authCookie(user.id));

    // Personal failed (SSRF), shared sources returned an album — 200 with non-empty list.
    expect(res.status).toBe(200);
    expect(Array.isArray(res.body.albums)).toBe(true);
    expect(res.body.albums.length).toBeGreaterThan(0);
  });
});

// ── Passphrase persistence fixes ─────────────────────────────────────────────

import { getOrCreateTrekPhoto, deleteTrekPhotoIfOrphan } from '../../src/services/memories/photoResolverService';
import { decrypt_api_key } from '../../src/services/apiKeyCrypto';

describe('trek_photos passphrase healing (SYNO-090)', () => {
  it('SYNO-090 — getOrCreateTrekPhoto overwrites an existing bad passphrase when a new one is supplied', () => {
    const { user } = createUser(testDb);

    const wrongPass = 'wrong-passphrase';
    const correctPass = 'correct-passphrase';

    const id1 = getOrCreateTrekPhoto('synologyphotos', 'asset-heal-test', user.id, wrongPass);
    const row1 = testDb.prepare('SELECT passphrase FROM trek_photos WHERE id = ?').get(id1) as { passphrase: string };
    expect(decrypt_api_key(row1.passphrase)).toBe(wrongPass);

    const id2 = getOrCreateTrekPhoto('synologyphotos', 'asset-heal-test', user.id, correctPass);
    expect(id2).toBe(id1);
    const row2 = testDb.prepare('SELECT passphrase FROM trek_photos WHERE id = ?').get(id2) as { passphrase: string };
    expect(decrypt_api_key(row2.passphrase)).toBe(correctPass);
  });
});

describe('trek_photos orphan cleanup (SYNO-091)', () => {
  it('SYNO-091 — deleteTrekPhotoIfOrphan removes the trek_photos row when no trip_photos or journey_photos reference it', () => {
    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id);
    testDb.prepare("UPDATE photo_providers SET enabled = 1 WHERE id = 'synologyphotos'").run();

    const trekPhotoId = getOrCreateTrekPhoto('synologyphotos', 'asset-orphan-test', user.id, 'pass-A');

    testDb.prepare(
      'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 1)'
    ).run(trip.id, user.id, trekPhotoId);

    // Still referenced — must not be deleted.
    deleteTrekPhotoIfOrphan(trekPhotoId);
    expect(testDb.prepare('SELECT id FROM trek_photos WHERE id = ?').get(trekPhotoId)).toBeDefined();

    // Remove the reference, then orphan-cleanup should delete the trek_photos row.
    testDb.prepare('DELETE FROM trip_photos WHERE photo_id = ?').run(trekPhotoId);
    deleteTrekPhotoIfOrphan(trekPhotoId);
    expect(testDb.prepare('SELECT id FROM trek_photos WHERE id = ?').get(trekPhotoId)).toBeUndefined();
  });

  it('SYNO-092 — re-adding a previously removed Synology photo stores the new passphrase correctly', () => {
    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id);
    testDb.prepare("UPDATE photo_providers SET enabled = 1 WHERE id = 'synologyphotos'").run();

    const firstPass = 'first-passphrase';
    const secondPass = 'second-passphrase';

    // Add with wrong passphrase, then remove (simulating the bug scenario).
    const id1 = getOrCreateTrekPhoto('synologyphotos', 'asset-readd-test', user.id, firstPass);
    testDb.prepare(
      'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 1)'
    ).run(trip.id, user.id, id1);
    testDb.prepare('DELETE FROM trip_photos WHERE photo_id = ?').run(id1);
    deleteTrekPhotoIfOrphan(id1);

    // trek_photos row should be gone.
    expect(testDb.prepare('SELECT id FROM trek_photos WHERE id = ?').get(id1)).toBeUndefined();

    // Re-add with the correct passphrase.
    const id2 = getOrCreateTrekPhoto('synologyphotos', 'asset-readd-test', user.id, secondPass);
    const row = testDb.prepare('SELECT passphrase FROM trek_photos WHERE id = ?').get(id2) as { passphrase: string };
    expect(decrypt_api_key(row.passphrase)).toBe(secondPass);
  });
});
