/**
 * Component: qBittorrent Integration Service Tests
 * Documentation: documentation/phase3/qbittorrent.md
 */

import path from 'path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { QBittorrentService, getQBittorrentService, invalidateQBittorrentService } from '@/lib/integrations/qbittorrent.service';
import { DownloadSourceError } from '@/lib/interfaces/download-client.interface';

const clientMock = vi.hoisted(() => ({
  get: vi.fn(),
  post: vi.fn(),
}));

const axiosMock = vi.hoisted(() => ({
  create: vi.fn(() => clientMock),
  post: vi.fn(),
  get: vi.fn(),
  isAxiosError: (error: any) => Boolean(error?.isAxiosError),
}));

const parseTorrentMock = vi.hoisted(() => vi.fn());
const configServiceMock = vi.hoisted(() => ({
  getMany: vi.fn(),
  get: vi.fn(),
}));

// Mock for DownloadClientManager
const downloadClientManagerMock = vi.hoisted(() => ({
  getClientForProtocol: vi.fn(),
  getAllClients: vi.fn(),
  hasClientForProtocol: vi.fn(),
}));

vi.mock('axios', () => ({
  default: axiosMock,
  ...axiosMock,
}));

vi.mock('parse-torrent', () => ({
  default: parseTorrentMock,
}));

vi.mock('@/lib/services/config.service', () => ({
  getConfigService: vi.fn(async () => configServiceMock),
}));

vi.mock('@/lib/services/download-client-manager.service', () => ({
  getDownloadClientManager: () => downloadClientManagerMock,
  invalidateDownloadClientManager: vi.fn(),
}));

describe('QBittorrentService', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    clientMock.get.mockReset();
    clientMock.post.mockReset();
    axiosMock.get.mockReset();
    axiosMock.post.mockReset();
    parseTorrentMock.mockReset();
    configServiceMock.getMany.mockReset();
    configServiceMock.get.mockReset();
    downloadClientManagerMock.getClientForProtocol.mockReset();
    downloadClientManagerMock.getAllClients.mockReset();
    downloadClientManagerMock.hasClientForProtocol.mockReset();
    invalidateQBittorrentService();
  });

  it('maps download progress from torrent info', () => {
    const service = new QBittorrentService('http://qb', 'user', 'pass');
    const progress = service.getDownloadProgress({
      progress: 0.42,
      downloaded: 420,
      size: 1000,
      dlspeed: 50,
      eta: 120,
      state: 'pausedDL',
    } as any);

    expect(progress.percent).toBe(42);
    expect(progress.bytesDownloaded).toBe(420);
    expect(progress.bytesTotal).toBe(1000);
    expect(progress.speed).toBe(50);
    expect(progress.eta).toBe(120);
    expect(progress.state).toBe('paused');
  });

  it('extracts info hash from magnet links', () => {
    const service = new QBittorrentService('http://qb', 'user', 'pass');
    const hash = (service as any).extractHashFromMagnet(
      'magnet:?xt=urn:btih:0123456789ABCDEF0123456789ABCDEF01234567'
    );

    expect(hash).toBe('0123456789abcdef0123456789abcdef01234567');
    expect((service as any).extractHashFromMagnet('magnet:?xt=urn:btih:')).toBeNull();
  });

  it('maps allocating state to downloading', () => {
    const service = new QBittorrentService('http://qb', 'user', 'pass');
    const progress = service.getDownloadProgress({
      progress: 0.1,
      downloaded: 100,
      size: 1000,
      dlspeed: 0,
      eta: 0,
      state: 'allocating',
    } as any);

    expect(progress.state).toBe('downloading');
  });

  describe('mapState - forced states (Force Resume in qBittorrent UI)', () => {
    it('maps forcedDL to downloading', () => {
      const service = new QBittorrentService('http://qb', 'user', 'pass');
      const progress = service.getDownloadProgress({
        progress: 0.5, downloaded: 500, size: 1000, dlspeed: 100, eta: 50, state: 'forcedDL',
      } as any);
      expect(progress.state).toBe('downloading');
    });

    it('maps forcedUP to completed', () => {
      const service = new QBittorrentService('http://qb', 'user', 'pass');
      const progress = service.getDownloadProgress({
        progress: 1.0, downloaded: 1000, size: 1000, dlspeed: 0, eta: 0, state: 'forcedUP',
      } as any);
      expect(progress.state).toBe('completed');
    });
  });

  describe('mapState - metadata fetching states', () => {
    it('maps metaDL to downloading', () => {
      const service = new QBittorrentService('http://qb', 'user', 'pass');
      const progress = service.getDownloadProgress({
        progress: 0, downloaded: 0, size: 0, dlspeed: 0, eta: 0, state: 'metaDL',
      } as any);
      expect(progress.state).toBe('downloading');
    });

    it('maps forcedMetaDL to downloading', () => {
      const service = new QBittorrentService('http://qb', 'user', 'pass');
      const progress = service.getDownloadProgress({
        progress: 0, downloaded: 0, size: 0, dlspeed: 0, eta: 0, state: 'forcedMetaDL',
      } as any);
      expect(progress.state).toBe('downloading');
    });
  });

  describe('mapState - qBittorrent v5.x stopped states', () => {
    it('maps stoppedDL to paused', () => {
      const service = new QBittorrentService('http://qb', 'user', 'pass');
      const progress = service.getDownloadProgress({
        progress: 0.3, downloaded: 300, size: 1000, dlspeed: 0, eta: 0, state: 'stoppedDL',
      } as any);
      expect(progress.state).toBe('paused');
    });

    it('maps stoppedUP to completed (download finished, stopped on upload side)', () => {
      const service = new QBittorrentService('http://qb', 'user', 'pass');
      const progress = service.getDownloadProgress({
        progress: 1.0, downloaded: 1000, size: 1000, dlspeed: 0, eta: 0, state: 'stoppedUP',
      } as any);
      expect(progress.state).toBe('completed');
    });
  });

  describe('mapState - other states', () => {
    it('maps checkingResumeData to checking', () => {
      const service = new QBittorrentService('http://qb', 'user', 'pass');
      const progress = service.getDownloadProgress({
        progress: 0, downloaded: 0, size: 1000, dlspeed: 0, eta: 0, state: 'checkingResumeData',
      } as any);
      expect(progress.state).toBe('checking');
    });

    it('maps moving to downloading', () => {
      const service = new QBittorrentService('http://qb', 'user', 'pass');
      const progress = service.getDownloadProgress({
        progress: 1.0, downloaded: 1000, size: 1000, dlspeed: 0, eta: 0, state: 'moving',
      } as any);
      expect(progress.state).toBe('downloading');
    });
  });

  describe('mapState - pausedUP/stoppedUP as completion states', () => {
    it('maps pausedUP to completed (download finished, paused on upload side)', () => {
      const service = new QBittorrentService('http://qb', 'user', 'pass');
      const progress = service.getDownloadProgress({
        progress: 0.5, downloaded: 0, size: 0, dlspeed: 0, eta: 0, state: 'pausedUP',
      } as any);
      expect(progress.state).toBe('completed');
    });

    it('maps pausedDL to paused (download not finished)', () => {
      const service = new QBittorrentService('http://qb', 'user', 'pass');
      const progress = service.getDownloadProgress({
        progress: 0.3, downloaded: 300, size: 1000, dlspeed: 0, eta: 0, state: 'pausedDL',
      } as any);
      expect(progress.state).toBe('paused');
    });
  });

  describe('mapStateToDownloadStatus - forced and new states via getDownload', () => {
    it('maps forcedUP to seeding status (triggers completion in monitor)', async () => {
      const service = new QBittorrentService('http://qb', 'user', 'pass');
      (service as any).cookie = 'SID=forced';
      clientMock.get.mockResolvedValueOnce({
        data: [{
          hash: 'abc123', name: 'Audiobook', size: 1000, progress: 1.0,
          dlspeed: 0, upspeed: 5000, downloaded: 1000, uploaded: 500,
          eta: 0, state: 'forcedUP', category: 'readmeabook', tags: '',
          save_path: '/downloads', content_path: '/downloads/Audiobook',
          completion_on: 1700000000, added_on: 1699000000,
        }],
      });

      const info = await service.getDownload('abc123');

      expect(info).not.toBeNull();
      expect(info!.status).toBe('seeding');
    });

    it('maps forcedDL to downloading status', async () => {
      const service = new QBittorrentService('http://qb', 'user', 'pass');
      (service as any).cookie = 'SID=forced';
      clientMock.get.mockResolvedValueOnce({
        data: [{
          hash: 'abc123', name: 'Audiobook', size: 1000, progress: 0.5,
          dlspeed: 1000, upspeed: 0, downloaded: 500, uploaded: 0,
          eta: 500, state: 'forcedDL', category: 'readmeabook', tags: '',
          save_path: '/downloads', completion_on: 0, added_on: 1699000000,
        }],
      });

      const info = await service.getDownload('abc123');

      expect(info).not.toBeNull();
      expect(info!.status).toBe('downloading');
    });

    it('maps stoppedUP to seeding status (qBittorrent v5.x, triggers completion)', async () => {
      const service = new QBittorrentService('http://qb', 'user', 'pass');
      (service as any).cookie = 'SID=stopped';
      clientMock.get.mockResolvedValueOnce({
        data: [{
          hash: 'abc123', name: 'Audiobook', size: 1000, progress: 1.0,
          dlspeed: 0, upspeed: 0, downloaded: 1000, uploaded: 200,
          eta: 0, state: 'stoppedUP', category: 'readmeabook', tags: '',
          save_path: '/downloads', completion_on: 1700000000, added_on: 1699000000,
        }],
      });

      const info = await service.getDownload('abc123');

      expect(info).not.toBeNull();
      expect(info!.status).toBe('seeding');
    });

    it('maps pausedUP to seeding status (download finished, paused on upload side)', async () => {
      const service = new QBittorrentService('http://qb', 'user', 'pass');
      (service as any).cookie = 'SID=pausedup';
      clientMock.get.mockResolvedValueOnce({
        data: [{
          hash: 'd5d767f07e5d9027f7f9d9b50b877386dc92b177', name: 'Audiobook', size: 0, progress: 0.5,
          dlspeed: 0, upspeed: 0, downloaded: 0, uploaded: 0,
          eta: 0, state: 'pausedUP', category: 'readmeabook', tags: '',
          save_path: '/data/torrents/readmeabook', content_path: '/data/torrents/readmeabook/Audiobook',
          completion_on: 1769135244, added_on: 1769135108,
        }],
      });

      const info = await service.getDownload('d5d767f07e5d9027f7f9d9b50b877386dc92b177');

      expect(info).not.toBeNull();
      expect(info!.status).toBe('seeding');
    });

    it('maps stoppedDL to paused status (qBittorrent v5.x)', async () => {
      const service = new QBittorrentService('http://qb', 'user', 'pass');
      (service as any).cookie = 'SID=stopped';
      clientMock.get.mockResolvedValueOnce({
        data: [{
          hash: 'abc123', name: 'Audiobook', size: 1000, progress: 0.3,
          dlspeed: 0, upspeed: 0, downloaded: 300, uploaded: 0,
          eta: 0, state: 'stoppedDL', category: 'readmeabook', tags: '',
          save_path: '/downloads', completion_on: 0, added_on: 1699000000,
        }],
      });

      const info = await service.getDownload('abc123');

      expect(info).not.toBeNull();
      expect(info!.status).toBe('paused');
    });

    it('maps metaDL to downloading status', async () => {
      const service = new QBittorrentService('http://qb', 'user', 'pass');
      (service as any).cookie = 'SID=meta';
      clientMock.get.mockResolvedValueOnce({
        data: [{
          hash: 'abc123', name: 'Audiobook', size: 0, progress: 0,
          dlspeed: 0, upspeed: 0, downloaded: 0, uploaded: 0,
          eta: 0, state: 'metaDL', category: 'readmeabook', tags: '',
          save_path: '/downloads', completion_on: 0, added_on: 1699000000,
        }],
      });

      const info = await service.getDownload('abc123');

      expect(info).not.toBeNull();
      expect(info!.status).toBe('downloading');
    });

    it('maps checkingResumeData to checking status', async () => {
      const service = new QBittorrentService('http://qb', 'user', 'pass');
      (service as any).cookie = 'SID=resume';
      clientMock.get.mockResolvedValueOnce({
        data: [{
          hash: 'abc123', name: 'Audiobook', size: 1000, progress: 0,
          dlspeed: 0, upspeed: 0, downloaded: 0, uploaded: 0,
          eta: 0, state: 'checkingResumeData', category: 'readmeabook', tags: '',
          save_path: '/downloads', completion_on: 0, added_on: 1699000000,
        }],
      });

      const info = await service.getDownload('abc123');

      expect(info).not.toBeNull();
      expect(info!.status).toBe('checking');
    });
  });

  describe('downloadPath resolution', () => {
    describe('normal operation (content_path under save_path)', () => {
      it('uses content_path directly for seeding multi-file torrent', async () => {
        const service = new QBittorrentService('http://qb', 'user', 'pass');
        (service as any).cookie = 'SID=normal-multi';
        clientMock.get.mockResolvedValueOnce({
          data: [{
            hash: 'abc123', name: 'Audiobook', size: 1000, progress: 1.0,
            dlspeed: 0, upspeed: 5000, downloaded: 1000, uploaded: 500,
            eta: 0, state: 'uploading', category: 'readmeabook', tags: '',
            save_path: '/downloads/', content_path: '/downloads/Audiobook',
            completion_on: 1700000000, added_on: 1699000000,
          }],
        });

        const info = await service.getDownload('abc123');

        expect(info).not.toBeNull();
        expect(info!.status).toBe('seeding');
        expect(info!.downloadPath).toBe('/downloads/Audiobook');
        expect(info!.savePath).toBe('/downloads/');
      });

      it('uses content_path directly for single-file torrent in folder', async () => {
        const service = new QBittorrentService('http://qb', 'user', 'pass');
        (service as any).cookie = 'SID=normal-single-folder';
        clientMock.get.mockResolvedValueOnce({
          data: [{
            hash: 'abc123', name: 'Audiobook Name', size: 3700000000, progress: 1.0,
            dlspeed: 0, upspeed: 0, downloaded: 3700000000, uploaded: 100000,
            eta: 0, state: 'stalledUP', category: 'readmeabook', tags: '',
            save_path: '/downloads/books/',
            content_path: '/downloads/books/Audiobook Folder/Audiobook.m4b',
            completion_on: 1700000000, added_on: 1699000000,
          }],
        });

        const info = await service.getDownload('abc123');

        expect(info!.status).toBe('seeding');
        // Must preserve the full path including intermediate folder
        expect(info!.downloadPath).toBe('/downloads/books/Audiobook Folder/Audiobook.m4b');
        expect(info!.savePath).toBe('/downloads/books/');
      });

      it('uses content_path directly when torrent name differs from folder name', async () => {
        const service = new QBittorrentService('http://qb', 'user', 'pass');
        (service as any).cookie = 'SID=name-mismatch';
        clientMock.get.mockResolvedValueOnce({
          data: [{
            hash: 'abc123',
            name: 'Harry Potter [Full-Cast] (aka Philosophers Stone) - J.K. Rowling',
            size: 3006477107, progress: 1.0,
            dlspeed: 0, upspeed: 0, downloaded: 3006477107, uploaded: 500000,
            eta: 0, state: 'uploading', category: 'readmeabook', tags: '',
            save_path: '/downloads/books/',
            content_path: '/downloads/books/Harry Potter (Full-Cast Edition) EAC3 6ch - J.K. Rowling',
            completion_on: 1700000000, added_on: 1699000000,
          }],
        });

        const info = await service.getDownload('abc123');

        expect(info!.status).toBe('seeding');
        // Must use content_path (real folder name), NOT torrent.name
        expect(info!.downloadPath).toBe(
          '/downloads/books/Harry Potter (Full-Cast Edition) EAC3 6ch - J.K. Rowling'
        );
        expect(info!.downloadPath).not.toContain('[Full-Cast]');
        expect(info!.savePath).toBe('/downloads/books/');
      });

      it('uses content_path directly for all seeding states (pausedUP, stalledUP, forcedUP, queuedUP, stoppedUP)', async () => {
        const seedingStates = ['pausedUP', 'stalledUP', 'forcedUP', 'queuedUP', 'stoppedUP'];

        for (const state of seedingStates) {
          const service = new QBittorrentService('http://qb', 'user', 'pass');
          (service as any).cookie = `SID=state-${state}`;
          clientMock.get.mockResolvedValueOnce({
            data: [{
              hash: 'abc123', name: 'Audiobook', size: 1000, progress: 1.0,
              dlspeed: 0, upspeed: 0, downloaded: 1000, uploaded: 100,
              eta: 0, state, category: 'readmeabook', tags: '',
              save_path: '/downloads/', content_path: '/downloads/Audiobook',
              completion_on: 1700000000, added_on: 1699000000,
            }],
          });

          const info = await service.getDownload('abc123');

          expect(info!.status).toBe('seeding');
          expect(info!.downloadPath).toBe('/downloads/Audiobook');
        }
      });
    });

    describe('TempPathEnabled (content_path outside save_path)', () => {
      it('passes through content_path as-is even when pointing to temp dir (monitor handles wait)', async () => {
        const service = new QBittorrentService('http://qb', 'user', 'pass');
        (service as any).cookie = 'SID=temppath';
        clientMock.get.mockResolvedValueOnce({
          data: [{
            hash: 'abc123', name: 'Audiobook', size: 1000, progress: 1.0,
            dlspeed: 0, upspeed: 5000, downloaded: 1000, uploaded: 500,
            eta: 0, state: 'uploading', category: 'readmeabook', tags: '',
            save_path: '/downloads/', content_path: '/incomplete/Audiobook',
            completion_on: 1700000000, added_on: 1699000000,
          }],
        });

        const info = await service.getDownload('abc123');

        expect(info!.status).toBe('seeding');
        // content_path is always used directly — monitor detects temp path via savePath
        expect(info!.downloadPath).toBe('/incomplete/Audiobook');
        expect(info!.savePath).toBe('/downloads/');
      });

      it('exposes savePath so monitor can detect temp path for pausedUP', async () => {
        const service = new QBittorrentService('http://qb', 'user', 'pass');
        (service as any).cookie = 'SID=pausedup-temp';
        clientMock.get.mockResolvedValueOnce({
          data: [{
            hash: 'abc123', name: 'Audiobook', size: 1000, progress: 1.0,
            dlspeed: 0, upspeed: 0, downloaded: 1000, uploaded: 0,
            eta: 0, state: 'pausedUP', category: 'readmeabook', tags: '',
            save_path: '/data/torrents/readmeabook/', content_path: '/tmp/incomplete/Audiobook',
            completion_on: 1700000000, added_on: 1699000000,
          }],
        });

        const info = await service.getDownload('abc123');

        expect(info!.status).toBe('seeding');
        // content_path is always used directly — no reconstruction
        expect(info!.downloadPath).toBe('/tmp/incomplete/Audiobook');
        expect(info!.savePath).toBe('/data/torrents/readmeabook/');
      });
    });

    describe('downloading torrents', () => {
      it('uses content_path for actively downloading torrents', async () => {
        const service = new QBittorrentService('http://qb', 'user', 'pass');
        (service as any).cookie = 'SID=downloading';
        clientMock.get.mockResolvedValueOnce({
          data: [{
            hash: 'abc123', name: 'Audiobook', size: 1000, progress: 0.5,
            dlspeed: 5000, upspeed: 0, downloaded: 500, uploaded: 0,
            eta: 100, state: 'downloading', category: 'readmeabook', tags: '',
            save_path: '/downloads/', content_path: '/incomplete/Audiobook',
            completion_on: 0, added_on: 1699000000,
          }],
        });

        const info = await service.getDownload('abc123');

        expect(info!.status).toBe('downloading');
        // During download, content_path is used as-is (points to where files currently are)
        expect(info!.downloadPath).toBe('/incomplete/Audiobook');
      });

      it('falls back to save_path + name when content_path is empty', async () => {
        const service = new QBittorrentService('http://qb', 'user', 'pass');
        (service as any).cookie = 'SID=nocontent';
        clientMock.get.mockResolvedValueOnce({
          data: [{
            hash: 'abc123', name: 'Audiobook', size: 1000, progress: 0.3,
            dlspeed: 1000, upspeed: 0, downloaded: 300, uploaded: 0,
            eta: 700, state: 'downloading', category: 'readmeabook', tags: '',
            save_path: '/downloads/', content_path: '',
            completion_on: 0, added_on: 1699000000,
          }],
        });

        const info = await service.getDownload('abc123');

        expect(info!.status).toBe('downloading');
        expect(info!.downloadPath).toBe(path.join('/downloads/', 'Audiobook'));
      });
    });

    describe('empty content_path fallback', () => {
      it('falls back to save_path + name for finished torrents with no content_path', async () => {
        const service = new QBittorrentService('http://qb', 'user', 'pass');
        (service as any).cookie = 'SID=nocontent-finished';
        clientMock.get.mockResolvedValueOnce({
          data: [{
            hash: 'abc123', name: 'Audiobook', size: 1000, progress: 1.0,
            dlspeed: 0, upspeed: 0, downloaded: 1000, uploaded: 0,
            eta: 0, state: 'pausedUP', category: 'readmeabook', tags: '',
            save_path: '/downloads/', content_path: '',
            completion_on: 1700000000, added_on: 1699000000,
          }],
        });

        const info = await service.getDownload('abc123');

        expect(info!.status).toBe('seeding');
        expect(info!.downloadPath).toBe(path.join('/downloads/', 'Audiobook'));
      });
    });
  });

  it('authenticates and stores a session cookie', async () => {
    axiosMock.post.mockResolvedValue({
      status: 200,
      statusText: 'OK',
      data: 'Ok.',
      headers: { 'set-cookie': ['SID=abc; Path=/;'] },
    });

    const service = new QBittorrentService('http://qb', 'user', 'pass');
    await service.login();

    expect((service as any).cookie).toBe('SID=abc');
  });

  it('throws when login response lacks a cookie', async () => {
    axiosMock.post.mockResolvedValue({
      status: 200,
      statusText: 'OK',
      data: 'Ok.',
      headers: {},
    });

    const service = new QBittorrentService('http://qb', 'user', 'pass');

    await expect(service.login()).rejects.toThrow('Failed to authenticate with qBittorrent');
  });

  it('rejects empty torrent URLs', async () => {
    const service = new QBittorrentService('http://qb', 'user', 'pass');

    await expect(service.addTorrent('')).rejects.toThrow('Invalid download URL');
  });

  it('skips adding duplicate magnet links', async () => {
    const service = new QBittorrentService('http://qb', 'user', 'pass');
    (service as any).cookie = 'SID=dup';
    vi.spyOn(service as any, 'ensureCategory').mockResolvedValue(undefined);
    vi.spyOn(service as any, 'getTorrent').mockResolvedValue({ hash: 'existing' });

    const hash = await service.addTorrent('magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567');

    expect(hash).toBe('0123456789abcdef0123456789abcdef01234567');
    expect(clientMock.post).not.toHaveBeenCalled();
  });

  it('adds magnet links when not already present', async () => {
    const service = new QBittorrentService('http://qb', 'user', 'pass');
    (service as any).cookie = 'SID=add';
    vi.spyOn(service as any, 'ensureCategory').mockResolvedValue(undefined);
    vi.spyOn(service as any, 'getTorrent').mockRejectedValue(new Error('not found'));
    clientMock.post.mockResolvedValue({ data: 'Ok.' });

    const hash = await service.addTorrent(
      'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567',
      { tags: ['tag1', 'tag2'] }
    );

    expect(hash).toBe('0123456789abcdef0123456789abcdef01234567');
    expect(clientMock.post).toHaveBeenCalledWith(
      '/torrents/add',
      expect.any(URLSearchParams),
      expect.objectContaining({
        headers: expect.objectContaining({ 'Content-Type': 'application/x-www-form-urlencoded' }),
      })
    );
  });

  it('throws when magnet link is invalid', async () => {
    const service = new QBittorrentService('http://qb', 'user', 'pass');
    (service as any).cookie = 'SID=badmagnet';

    await expect(
      (service as any).addMagnetLink('magnet:?xt=urn:btih:', 'readmeabook')
    ).rejects.toThrow('Invalid magnet link');
  });

  it('throws when qBittorrent rejects magnet uploads', async () => {
    const service = new QBittorrentService('http://qb', 'user', 'pass');
    (service as any).cookie = 'SID=rejected';
    vi.spyOn(service as any, 'getTorrent').mockRejectedValue(new Error('not found'));
    clientMock.post.mockResolvedValue({ data: 'Nope' });

    await expect(
      (service as any).addMagnetLink(
        'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567',
        'readmeabook'
      )
    ).rejects.toThrow('qBittorrent rejected magnet link');
  });

  it('re-authenticates after a 403 and retries adding torrents', async () => {
    const service = new QBittorrentService('http://qb', 'user', 'pass');
    (service as any).cookie = 'SID=old';

    vi.spyOn(service as any, 'ensureCategory').mockResolvedValue(undefined);
    const loginSpy = vi.spyOn(service, 'login').mockResolvedValue();
    const addMagnetSpy = vi.spyOn(service as any, 'addMagnetLink')
      .mockRejectedValueOnce({ isAxiosError: true, response: { status: 403 } })
      .mockResolvedValueOnce('rehash');

    const hash = await service.addTorrent('magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567');

    expect(hash).toBe('rehash');
    expect(loginSpy).toHaveBeenCalledTimes(1);
    expect(addMagnetSpy).toHaveBeenCalledTimes(2);
  });

  it('follows redirect to magnet link when downloading torrent files', async () => {
    const service = new QBittorrentService('http://qb', 'user', 'pass');
    (service as any).cookie = 'SID=redir';
    vi.spyOn(service as any, 'ensureCategory').mockResolvedValue(undefined);
    const addMagnetSpy = vi.spyOn(service as any, 'addMagnetLink').mockResolvedValue('redirect-hash');

    axiosMock.get.mockRejectedValueOnce({
      isAxiosError: true,
      response: { status: 302, headers: { location: 'magnet:?xt=urn:btih:abcdef0123456789abcdef0123456789abcdef01' } },
    });

    const hash = await service.addTorrent('http://example.com/file.torrent');

    expect(hash).toBe('redirect-hash');
    expect(addMagnetSpy).toHaveBeenCalled();
  });

  it('treats magnet response bodies as magnet links', async () => {
    const service = new QBittorrentService('http://qb', 'user', 'pass');
    (service as any).cookie = 'SID=body';
    vi.spyOn(service as any, 'ensureCategory').mockResolvedValue(undefined);
    const addMagnetSpy = vi.spyOn(service as any, 'addMagnetLink').mockResolvedValue('body-hash');

    axiosMock.get.mockResolvedValueOnce({
      data: Buffer.from('magnet:?xt=urn:btih:abcdef0123456789abcdef0123456789abcdef01'),
    });

    const hash = await service.addTorrent('http://example.com/file.torrent');

    expect(hash).toBe('body-hash');
    expect(addMagnetSpy).toHaveBeenCalled();
    expect(parseTorrentMock).not.toHaveBeenCalled();
  });

  it('adds torrent files after parsing successfully', async () => {
    const service = new QBittorrentService('http://qb', 'user', 'pass');
    (service as any).cookie = 'SID=ok';
    vi.spyOn(service as any, 'ensureCategory').mockResolvedValue(undefined);
    vi.spyOn(service as any, 'getTorrent').mockRejectedValue(new Error('not found'));

    axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('torrent') });
    parseTorrentMock.mockResolvedValueOnce({ infoHash: 'hash-1', name: 'Book' });
    clientMock.post.mockResolvedValue({ data: 'Ok.' });

    const hash = await service.addTorrent('http://example.com/file.torrent');

    expect(hash).toBe('hash-1');
    expect(clientMock.post).toHaveBeenCalledWith(
      '/torrents/add',
      expect.any(Object),
      expect.objectContaining({ maxBodyLength: Infinity })
    );
  });

  it('preserves upstream HTTP status and forwards source headers', async () => {
    const service = new QBittorrentService('http://qb', 'user', 'pass');
    (service as any).cookie = 'SID=source-error';
    vi.spyOn(service as any, 'ensureCategory').mockResolvedValue(undefined);

    axiosMock.get.mockRejectedValueOnce({
      isAxiosError: true,
      response: { status: 500, headers: {} },
      message: 'Request failed',
    });

    const error = await service.addDownload(
      'https://prowlarr/1/download/limited',
      { sourceHeaders: { 'X-Api-Key': 'secret' } }
    ).catch((caught) => caught);

    expect(error).toBeInstanceOf(DownloadSourceError);
    expect(error).toMatchObject({ status: 500 });
    expect(axiosMock.get).toHaveBeenCalledWith(
      'https://prowlarr/1/download/limited',
      expect.objectContaining({
        headers: expect.objectContaining({ 'X-Api-Key': 'secret' }),
      })
    );
  });

  it('throws for invalid redirect locations when fetching torrents', async () => {
    const service = new QBittorrentService('http://qb', 'user', 'pass');

    axiosMock.get.mockRejectedValueOnce({
      isAxiosError: true,
      response: { status: 302, headers: { location: 'ftp://bad' } },
      message: 'redirect',
    });

    await expect(
      (service as any).addTorrentFile('http://example.com/file.torrent', 'readmeabook')
    ).rejects.toThrow('Invalid redirect location');
  });

  it('throws when torrent file parsing fails directly', async () => {
    const service = new QBittorrentService('http://qb', 'user', 'pass');

    axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('torrent') });
    parseTorrentMock.mockRejectedValueOnce(new Error('bad torrent'));

    await expect(
      (service as any).addTorrentFile('http://example.com/file.torrent', 'readmeabook')
    ).rejects.toThrow('Invalid .torrent file - failed to parse');
  });

  it('throws when torrent file has no info hash', async () => {
    const service = new QBittorrentService('http://qb', 'user', 'pass');

    axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('torrent') });
    parseTorrentMock.mockResolvedValueOnce({ infoHash: null });

    await expect(
      (service as any).addTorrentFile('http://example.com/file.torrent', 'readmeabook')
    ).rejects.toThrow('Failed to extract info_hash');
  });

  it('throws when qBittorrent rejects torrent file uploads', async () => {
    const service = new QBittorrentService('http://qb', 'user', 'pass');
    (service as any).cookie = 'SID=reject';
    vi.spyOn(service as any, 'getTorrent').mockRejectedValue(new Error('not found'));

    axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('torrent') });
    parseTorrentMock.mockResolvedValueOnce({ infoHash: 'hash-2', name: 'Book' });
    clientMock.post.mockResolvedValue({ data: 'Nope' });

    await expect(
      (service as any).addTorrentFile('http://example.com/file.torrent', 'readmeabook')
    ).rejects.toThrow('qBittorrent rejected .torrent file');
  });

  it('throws when torrent parsing fails', async () => {
    const service = new QBittorrentService('http://qb', 'user', 'pass');
    (service as any).cookie = 'SID=parse';
    vi.spyOn(service as any, 'ensureCategory').mockResolvedValue(undefined);

    axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('not-a-torrent') });
    parseTorrentMock.mockRejectedValueOnce(new Error('bad torrent'));

    await expect(service.addTorrent('http://example.com/file.torrent')).rejects.toThrow(
      'Failed to add torrent to qBittorrent'
    );
  });

  it('creates categories when missing', async () => {
    const service = new QBittorrentService('http://qb', 'user', 'pass', '/downloads');
    (service as any).cookie = 'SID=newcat';
    clientMock.get.mockResolvedValue({ data: {} });
    clientMock.post.mockResolvedValue({ data: 'Ok.' });

    await (service as any).ensureCategory('readmeabook');

    expect(clientMock.post).toHaveBeenCalledWith(
      '/torrents/createCategory',
      expect.any(URLSearchParams),
      expect.objectContaining({
        headers: expect.objectContaining({ 'Content-Type': 'application/x-www-form-urlencoded' }),
      })
    );
  });

  it('does not throw when ensuring categories fails', async () => {
    const service = new QBittorrentService('http://qb', 'user', 'pass');
    (service as any).cookie = 'SID=catfail';
    clientMock.get.mockRejectedValueOnce({
      isAxiosError: true,
      response: { status: 500 },
    });

    await expect((service as any).ensureCategory('readmeabook')).resolves.toBeUndefined();
  });

  it('updates category when save path mismatches', async () => {
    const service = new QBittorrentService('http://qb', 'user', 'pass', '/downloads');
    (service as any).cookie = 'SID=cat';
    clientMock.get.mockResolvedValue({
      data: {
        readmeabook: { savePath: '/old' },
      },
    });
    clientMock.post.mockResolvedValue({ data: 'Ok.' });

    await (service as any).ensureCategory('readmeabook');

    expect(clientMock.post).toHaveBeenCalledWith(
      '/torrents/editCategory',
      expect.any(URLSearchParams),
      expect.objectContaining({
        headers: expect.objectContaining({ 'Content-Type': 'application/x-www-form-urlencoded' }),
      })
    );
  });

  it('does not update category when save path matches', async () => {
    const service = new QBittorrentService('http://qb', 'user', 'pass', '/downloads');
    (service as any).cookie = 'SID=cat-ok';
    clientMock.get.mockResolvedValue({
      data: {
        readmeabook: { savePath: '/downloads' },
      },
    });

    await (service as any).ensureCategory('readmeabook');

    expect(clientMock.post).not.toHaveBeenCalled();
  });

  it('applies reverse path mapping when creating category', async () => {
    const service = new QBittorrentService(
      'http://qb',
      'user',
      'pass',
      '/downloads',
      'readmeabook',
      false,
      { enabled: true, remotePath: 'F:\\Docker\\downloads', localPath: '/downloads' }
    );
    (service as any).cookie = 'SID=pathmap';
    clientMock.get.mockResolvedValue({ data: {} }); // No existing categories
    clientMock.post.mockResolvedValue({ data: 'Ok.' });

    await (service as any).ensureCategory('readmeabook');

    expect(clientMock.post).toHaveBeenCalledWith(
      '/torrents/createCategory',
      expect.any(URLSearchParams),
      expect.any(Object)
    );

    // Verify the savePath was reverse transformed (local → remote)
    const postCall = clientMock.post.mock.calls[0];
    const params = postCall[1] as URLSearchParams;
    expect(params.get('savePath')).toBe('F:\\Docker\\downloads');
  });

  it('applies reverse path mapping when updating category', async () => {
    const service = new QBittorrentService(
      'http://qb',
      'user',
      'pass',
      '/downloads',
      'readmeabook',
      false,
      { enabled: true, remotePath: 'F:\\Docker\\downloads', localPath: '/downloads' }
    );
    (service as any).cookie = 'SID=pathmap-update';
    // Category exists with old path
    clientMock.get.mockResolvedValue({
      data: {
        readmeabook: { savePath: 'F:\\OldPath' },
      },
    });
    clientMock.post.mockResolvedValue({ data: 'Ok.' });

    await (service as any).ensureCategory('readmeabook');

    expect(clientMock.post).toHaveBeenCalledWith(
      '/torrents/editCategory',
      expect.any(URLSearchParams),
      expect.any(Object)
    );

    // Verify the savePath was reverse transformed (local → remote)
    const postCall = clientMock.post.mock.calls[0];
    const params = postCall[1] as URLSearchParams;
    expect(params.get('savePath')).toBe('F:\\Docker\\downloads');
  });

  it('does not update category when remote path already matches', async () => {
    const service = new QBittorrentService(
      'http://qb',
      'user',
      'pass',
      '/downloads',
      'readmeabook',
      false,
      { enabled: true, remotePath: 'F:\\Docker\\downloads', localPath: '/downloads' }
    );
    (service as any).cookie = 'SID=pathmap-match';
    // Category already has the correct remote path
    clientMock.get.mockResolvedValue({
      data: {
        readmeabook: { savePath: 'F:\\Docker\\downloads' },
      },
    });

    await (service as any).ensureCategory('readmeabook');

    // Should not call post since path already matches
    expect(clientMock.post).not.toHaveBeenCalled();
  });

  it('pauses and resumes torrents', async () => {
    const service = new QBittorrentService('http://qb', 'user', 'pass');
    (service as any).cookie = 'SID=pause';
    clientMock.post.mockResolvedValue({ data: 'Ok.' });

    await service.pauseTorrent('hash-1');
    await service.resumeTorrent('hash-1');

    expect(clientMock.post).toHaveBeenCalledWith(
      '/torrents/pause',
      expect.any(URLSearchParams),
      expect.any(Object)
    );
    expect(clientMock.post).toHaveBeenCalledWith(
      '/torrents/resume',
      expect.any(URLSearchParams),
      expect.any(Object)
    );
  });

  it('throws when torrent state updates fail', async () => {
    const service = new QBittorrentService('http://qb', 'user', 'pass');
    (service as any).cookie = 'SID=fail';
    clientMock.post.mockRejectedValue(new Error('boom'));

    await expect(service.pauseTorrent('hash-1')).rejects.toThrow('Failed to pause torrent');
    await expect(service.resumeTorrent('hash-1')).rejects.toThrow('Failed to resume torrent');
    await expect(service.deleteTorrent('hash-1', false)).rejects.toThrow('Failed to delete torrent');
    await expect(service.setCategory('hash-1', 'books')).rejects.toThrow('Failed to set torrent category');
  });

  it('sets categories, deletes torrents, and fetches files', async () => {
    const service = new QBittorrentService('http://qb', 'user', 'pass');
    (service as any).cookie = 'SID=ops';
    clientMock.post.mockResolvedValue({ data: 'Ok.' });
    clientMock.get.mockResolvedValue({ data: [{ name: 'file1' }] });

    await service.setCategory('hash-1', 'books');
    await service.deleteTorrent('hash-1', true);
    const files = await service.getFiles('hash-1');

    expect(files).toEqual([{ name: 'file1' }]);
    expect(clientMock.post).toHaveBeenCalledWith(
      '/torrents/setCategory',
      expect.any(URLSearchParams),
      expect.any(Object)
    );
    expect(clientMock.post).toHaveBeenCalledWith(
      '/torrents/delete',
      expect.any(URLSearchParams),
      expect.any(Object)
    );
  });

  it('throws when fetching torrent files fails', async () => {
    const service = new QBittorrentService('http://qb', 'user', 'pass');
    (service as any).cookie = 'SID=files';
    clientMock.get.mockRejectedValue(new Error('no files'));

    await expect(service.getFiles('hash-1')).rejects.toThrow('Failed to get torrent files');
  });

  it('throws when torrent is not found', async () => {
    const service = new QBittorrentService('http://qb', 'user', 'pass');
    (service as any).cookie = 'SID=missing';
    clientMock.get.mockResolvedValueOnce({ data: [] });

    await expect(service.getTorrent('hash-404')).rejects.toThrow('Torrent hash-404 not found');
  });

  it('ignores unrelated torrents returned by RDTClient-like clients that ignore hash filter', async () => {
    const service = new QBittorrentService('http://qb', 'user', 'pass');
    (service as any).cookie = 'SID=rdtclient';
    // RDTClient ignores the hashes param and returns all torrents
    clientMock.get.mockResolvedValueOnce({
      data: [
        { hash: 'aaaa1111bbbb2222cccc3333dddd4444eeee5555', name: 'Other Book' },
        { hash: 'ffff6666aaaa7777bbbb8888cccc9999dddd0000', name: 'Another Book' },
      ],
    });

    await expect(
      service.getTorrent('0f54898dc1b8e49d96e32827377f651ea6c935af')
    ).rejects.toThrow('Torrent 0f54898dc1b8e49d96e32827377f651ea6c935af not found');
  });

  it('finds the correct torrent when RDTClient returns all torrents including the match', async () => {
    const service = new QBittorrentService('http://qb', 'user', 'pass');
    (service as any).cookie = 'SID=rdtclient2';
    clientMock.get.mockResolvedValueOnce({
      data: [
        { hash: 'aaaa1111bbbb2222cccc3333dddd4444eeee5555', name: 'Other Book' },
        { hash: '0F54898DC1B8E49D96E32827377F651EA6C935AF', name: 'Target Book' },
      ],
    });

    const result = await service.getTorrent('0f54898dc1b8e49d96e32827377f651ea6c935af');

    expect(result.name).toBe('Target Book');
  });

  it('returns error when getTorrents fails', async () => {
    const service = new QBittorrentService('http://qb', 'user', 'pass');
    (service as any).cookie = 'SID=list';
    clientMock.get.mockRejectedValue(new Error('boom'));

    await expect(service.getTorrents()).rejects.toThrow('Failed to get torrents from qBittorrent');
  });

  it('returns torrent lists with a category filter', async () => {
    const service = new QBittorrentService('http://qb', 'user', 'pass');
    (service as any).cookie = 'SID=list';
    clientMock.get.mockResolvedValueOnce({ data: [{ hash: 'h1' }] });

    const torrents = await service.getTorrents('books');

    expect(torrents).toEqual([{ hash: 'h1' }]);
    expect(clientMock.get).toHaveBeenCalledWith(
      '/torrents/info',
      expect.objectContaining({ params: { category: 'books' } })
    );
  });

  it('returns unknown state for unrecognized torrent states', () => {
    const service = new QBittorrentService('http://qb', 'user', 'pass');
    const progress = service.getDownloadProgress({
      progress: 0,
      downloaded: 0,
      size: 1,
      dlspeed: 0,
      eta: 0,
      state: 'weird' as any,
    } as any);

    expect(progress.state).toBe('unknown');
  });

  it('throws specific errors for invalid credentials in testConnectionWithCredentials', async () => {
    axiosMock.post.mockResolvedValueOnce({
      status: 200,
      statusText: 'OK',
      data: 'Ok.',
      headers: { 'set-cookie': ['SID=abc; Path=/;'] },
    });
    axiosMock.get.mockRejectedValueOnce({
      isAxiosError: true,
      response: { status: 401 },
      config: { url: 'http://qb/api/v2/app/version' },
      message: 'Unauthorized',
    });

    await expect(
      QBittorrentService.testConnectionWithCredentials('http://qb', 'user', 'bad')
    ).rejects.toThrow('Authentication failed');
  });

  it('returns version on successful credential test', async () => {
    axiosMock.post.mockResolvedValueOnce({
      status: 200,
      statusText: 'OK',
      data: 'Ok.',
      headers: { 'set-cookie': ['SID=abc; Path=/;'] },
    });
    axiosMock.get.mockResolvedValueOnce({
      data: 'v4.6.0',
      headers: {},
    });

    const version = await QBittorrentService.testConnectionWithCredentials('http://qb', 'user', 'pass');

    expect(version).toBe('4.6.0');
  });

  it('throws when test connection receives no cookies', async () => {
    axiosMock.post.mockResolvedValueOnce({
      status: 200,
      statusText: 'OK',
      data: 'Ok.',
      headers: {},
    });

    await expect(
      QBittorrentService.testConnectionWithCredentials('http://qb', 'user', 'pass')
    ).rejects.toThrow('Failed to authenticate - no session cookie received');
  });

  it('throws SSL-specific errors for certificate failures', async () => {
    axiosMock.post.mockRejectedValueOnce({
      isAxiosError: true,
      code: 'DEPTH_ZERO_SELF_SIGNED_CERT',
      message: 'self signed',
      config: { url: 'https://qb/api/v2/auth/login' },
    });

    await expect(
      QBittorrentService.testConnectionWithCredentials('https://qb', 'user', 'pass', true)
    ).rejects.toThrow('SSL certificate verification failed');
  });

  it('throws when connection is refused', async () => {
    axiosMock.post.mockRejectedValueOnce({
      isAxiosError: true,
      code: 'ECONNREFUSED',
      message: 'refused',
      config: { url: 'http://qb/api/v2/auth/login' },
    });

    await expect(
      QBittorrentService.testConnectionWithCredentials('http://qb', 'user', 'pass')
    ).rejects.toThrow('Connection refused');
  });

  it('throws when server returns 404', async () => {
    axiosMock.post.mockRejectedValueOnce({
      isAxiosError: true,
      response: { status: 404 },
      message: 'Not found',
      config: { url: 'http://qb/api/v2/auth/login' },
    });

    await expect(
      QBittorrentService.testConnectionWithCredentials('http://qb', 'user', 'pass')
    ).rejects.toThrow('qBittorrent Web UI not found');
  });

  it('throws on qBittorrent server errors', async () => {
    axiosMock.post.mockRejectedValueOnce({
      isAxiosError: true,
      response: { status: 503 },
      message: 'Server error',
      config: { url: 'http://qb/api/v2/auth/login' },
    });

    await expect(
      QBittorrentService.testConnectionWithCredentials('http://qb', 'user', 'pass')
    ).rejects.toThrow('qBittorrent server error');
  });

  it('throws when qBittorrent configuration is incomplete', async () => {
    // Mock: no qBittorrent client configured
    downloadClientManagerMock.getClientForProtocol.mockResolvedValue(null);

    await expect(getQBittorrentService()).rejects.toThrow('qBittorrent is not configured');
  });

  it('returns a cached instance after successful initialization', async () => {
    // Mock: qBittorrent client configured
    downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
      id: 'client-1',
      type: 'qbittorrent',
      name: 'qBittorrent',
      enabled: true,
      url: 'http://qb',
      username: 'user',
      password: 'pass',
      disableSSLVerify: false,
      remotePathMappingEnabled: false,
    });
    configServiceMock.get.mockResolvedValue('/downloads');

    const testConnectionSpy = vi.spyOn(QBittorrentService.prototype, 'testConnection').mockResolvedValue({ success: true, message: 'Connected' });

    const first = await getQBittorrentService();
    const second = await getQBittorrentService();

    expect(first).toBe(second);
    // Should only call getClientForProtocol once (cached after first call)
    expect(downloadClientManagerMock.getClientForProtocol).toHaveBeenCalledTimes(1);

    testConnectionSpy.mockRestore();
  });

  it('throws when connection test fails during service creation', async () => {
    // Mock: qBittorrent client configured
    downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
      id: 'client-1',
      type: 'qbittorrent',
      name: 'qBittorrent',
      enabled: true,
      url: 'http://qb',
      username: 'user',
      password: 'pass',
      disableSSLVerify: false,
      remotePathMappingEnabled: false,
    });
    configServiceMock.get.mockResolvedValue('/downloads');

    const testConnectionSpy = vi.spyOn(QBittorrentService.prototype, 'testConnection').mockResolvedValue({ success: false, message: 'qBittorrent connection test failed. Please check your configuration in admin settings.' });

    await expect(getQBittorrentService()).rejects.toThrow('qBittorrent connection test failed');

    testConnectionSpy.mockRestore();
  });

  it('returns false when connection test fails', async () => {
    const service = new QBittorrentService('http://qb', 'user', 'pass');
    const loginSpy = vi.spyOn(service, 'login').mockRejectedValue(new Error('bad auth'));

    const result = await service.testConnection();

    expect(result.success).toBe(false);
    expect(loginSpy).toHaveBeenCalled();
  });

  it('returns true when connection test succeeds', async () => {
    const service = new QBittorrentService('http://qb', 'user', 'pass');
    const loginSpy = vi.spyOn(service, 'login').mockResolvedValue();

    const result = await service.testConnection();

    expect(result.success).toBe(true);
    expect(loginSpy).toHaveBeenCalled();
  });

  describe('auth-optional mode (blank credentials)', () => {
    it('flags service as auth-optional when both credentials are blank', () => {
      const service = new QBittorrentService('http://qb', '', '');
      expect((service as any).authOptional).toBe(true);
    });

    it('flags service as credentialed when any credential is provided', () => {
      const withUser = new QBittorrentService('http://qb', 'user', '');
      const withPass = new QBittorrentService('http://qb', '', 'pass');
      expect((withUser as any).authOptional).toBe(false);
      expect((withPass as any).authOptional).toBe(false);
    });

    it('login() is a no-op when auth-optional', async () => {
      const service = new QBittorrentService('http://qb', '', '');
      await service.login();
      expect(axiosMock.post).not.toHaveBeenCalled();
      expect((service as any).cookie).toBeUndefined();
    });

    it('testConnection() succeeds when /app/version returns a version (auth-optional)', async () => {
      const service = new QBittorrentService('http://qb', '', '');
      clientMock.get.mockResolvedValueOnce({ data: 'v4.6.0' });

      const result = await service.testConnection();

      expect(result.success).toBe(true);
      expect(result.version).toBe('4.6.0');
      expect(axiosMock.post).not.toHaveBeenCalled();
      expect(clientMock.get).toHaveBeenCalledWith('/app/version', expect.objectContaining({
        headers: {},
      }));
    });

    it('testConnection() returns failure when /app/version returns 401 (auth-optional)', async () => {
      const service = new QBittorrentService('http://qb', '', '');
      clientMock.get.mockRejectedValueOnce({
        isAxiosError: true,
        response: { status: 401 },
        message: 'Unauthorized',
      });

      const result = await service.testConnection();

      expect(result.success).toBe(false);
      expect(result.message).toMatch(/requires authentication/i);
    });

    it('testConnection() returns failure when /app/version is unreachable (auth-optional)', async () => {
      const service = new QBittorrentService('http://qb', '', '');
      clientMock.get.mockRejectedValueOnce({
        isAxiosError: true,
        code: 'ECONNREFUSED',
        message: 'connect ECONNREFUSED',
      });

      const result = await service.testConnection();

      expect(result.success).toBe(false);
      expect(result.message).toMatch(/Failed to reach qBittorrent/i);
    });

    it('testConnectionWithCredentials() probes /app/version directly when both creds blank', async () => {
      axiosMock.get.mockResolvedValueOnce({ data: 'v4.6.0' });

      const version = await QBittorrentService.testConnectionWithCredentials('http://qb', '', '');

      expect(version).toBe('4.6.0');
      expect(axiosMock.post).not.toHaveBeenCalled();
      expect(axiosMock.get).toHaveBeenCalledWith(
        'http://qb/api/v2/app/version',
        expect.objectContaining({ httpsAgent: undefined })
      );
    });

    it('testConnectionWithCredentials() reports auth-required when blank creds get 401', async () => {
      axiosMock.get.mockRejectedValueOnce({
        isAxiosError: true,
        response: { status: 401 },
        message: 'Unauthorized',
        config: { url: 'http://qb/api/v2/app/version' },
      });

      await expect(
        QBittorrentService.testConnectionWithCredentials('http://qb', '', '')
      ).rejects.toThrow(/requires authentication/i);
    });

    it('addTorrent does not attempt re-login on 403 when auth-optional', async () => {
      const service = new QBittorrentService('http://qb', '', '');
      vi.spyOn(service as any, 'ensureCategory').mockResolvedValue(undefined);
      const loginSpy = vi.spyOn(service, 'login');
      vi.spyOn(service as any, 'addMagnetLink').mockRejectedValueOnce({
        isAxiosError: true,
        response: { status: 403 },
      });

      await expect(
        service.addTorrent('magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567')
      ).rejects.toThrow('Failed to add torrent');

      expect(loginSpy).not.toHaveBeenCalled();
    });

    it('omits Cookie header on requests when auth-optional', async () => {
      const service = new QBittorrentService('http://qb', '', '');
      vi.spyOn(service as any, 'getTorrent').mockRejectedValue(new Error('not found'));
      clientMock.post.mockResolvedValue({ data: 'Ok.' });

      await (service as any).addMagnetLink(
        'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567',
        'readmeabook'
      );

      const headers = clientMock.post.mock.calls[0][2].headers;
      expect(headers.Cookie).toBeUndefined();
      expect(headers['Content-Type']).toBe('application/x-www-form-urlencoded');
    });
  });
});
