/**
 * Component: Audible Integration Service Tests
 * Documentation: documentation/integrations/audible.md
 */

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AudibleService } from '@/lib/integrations/audible.service';
import { AUDIBLE_REGIONS, DEFAULT_AUDIBLE_REGION } from '@/lib/types/audible';

// ---------------------------------------------------------------------------
// Hoisted mocks
// ---------------------------------------------------------------------------

// Two separate client mocks so we can distinguish htmlClient vs apiClient calls.
const htmlClientMock = vi.hoisted(() => ({ get: vi.fn() }));
const apiClientMock = vi.hoisted(() => ({ get: vi.fn() }));

const axiosMock = vi.hoisted(() => ({
  // First call → htmlClient, second call → apiClient (matches initialize() order).
  create: vi.fn(),
  get: vi.fn(),
}));

const configServiceMock = vi.hoisted(() => ({
  getAudibleRegion: vi.fn(),
}));

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

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

// ---------------------------------------------------------------------------
// Fixture helpers
// ---------------------------------------------------------------------------

interface ProductOverrides {
  asin?: string;
  title?: string;
  authors?: Array<{ asin?: string; name: string }>;
  narrators?: Array<{ name: string }>;
  publisher_summary?: string;
  merchandising_summary?: string;
  product_images?: Record<string, string>;
  runtime_length_min?: number;
  release_date?: string;
  language?: string;
  format_type?: string;
  publisher_name?: string;
  rating?: { overall_distribution?: { display_stars?: number } };
  category_ladders?: Array<{ ladder: Array<{ name: string }> }>;
  series?: Array<{ asin?: string; title?: string; sequence?: string }>;
}

function makeProduct(overrides: ProductOverrides = {}): ProductOverrides {
  return {
    asin: 'B000000001',
    title: 'Test Book',
    authors: [{ asin: 'A000000001', name: 'Test Author' }],
    narrators: [{ name: 'Test Narrator' }],
    publisher_summary: 'A plain description.',
    product_images: { '500': 'https://images.example.com/cover.jpg' },
    runtime_length_min: 300,
    release_date: '2024-01-01',
    language: 'english',
    rating: { overall_distribution: { display_stars: 4.5 } },
    ...overrides,
  };
}

function makeProductsResponse(products: ProductOverrides[], totalResults = products.length) {
  return { products, total_results: totalResults };
}

// Produces the value that client.get() should resolve to (the axios response object).
// fetchWithRetry captures this as `response`, then callers do `response.data` to
// unwrap the API envelope. So the mock must be shaped as: { data: <catalog_envelope> }.
function apiResponse(envelope: object) {
  return { data: envelope };
}

// ---------------------------------------------------------------------------
// HTML fixture helpers (for getPopularAudiobooks / getNewReleases / getCategoryBooks,
// which scrape Audible's curated HTML pages)
// ---------------------------------------------------------------------------

interface HtmlBookOverrides {
  asin?: string;
  title?: string;
  author?: string;
  authorAsin?: string;
  /** Single-narrator shorthand; mutually exclusive with `narrators`. */
  narrator?: string;
  /** Multi-narrator productions render each name as its own searchNarrator anchor. */
  narrators?: string[];
  coverArtUrl?: string;
  rating?: number;
}

/** Render one or more narrator anchor links suitable for embedding in .narratorLabel. */
function renderNarratorLinks(names: string[]): string {
  return names
    .map(
      (name) =>
        `<a href="/search?searchNarrator=${encodeURIComponent(name)}">${name}</a>`,
    )
    .join(', ');
}

/**
 * Produces a single .productListItem block matching the selectors parsed by
 * parseProductListItems(). The parser looks for an `<li data-asin>` descendant,
 * with an `<a href="/pd/...">` fallback — using a real `<li>` here both
 * exercises the primary path and keeps the markup well-formed.
 */
function makeProductListItemHtml(overrides: HtmlBookOverrides = {}): string {
  const {
    asin = 'B000000001',
    title = 'Test Book',
    author = 'Test Author',
    authorAsin = 'A000000001',
    narrator = 'Test Narrator',
    narrators,
    coverArtUrl = 'https://images.example.com/cover._SL500_.jpg',
    rating = 4.5,
  } = overrides;

  // Real Audible storefront markup embeds each narrator as its own anchor inside
  // .narratorLabel for multi-narrator productions. The single-narrator case keeps
  // the original plain-text span for backward compatibility with existing tests.
  const narratorMarkup = narrators && narrators.length > 0
    ? `<span class="narratorLabel">Narrated by: ${renderNarratorLinks(narrators)}</span>`
    : `<span class="narratorLabel">${narrator}</span>`;

  return `
    <div class="productListItem">
      <ul>
        <li data-asin="${asin}">
          <img src="${coverArtUrl}" />
          <h3><a href="/pd/test/${asin}">${title}</a></h3>
          <a class="authorLabel" href="/author/test/${authorAsin}">${author}</a>
          ${narratorMarkup}
          <span class="ratingsLabel">${rating} out of 5</span>
        </li>
      </ul>
    </div>
  `;
}

/**
 * Produces a single .s-result-item block matching the selectors parsed by
 * parseSearchResultItems(). Used for /search?node=<categoryId> category pages.
 */
function makeSearchResultItemHtml(overrides: HtmlBookOverrides = {}): string {
  const {
    asin = 'B000000001',
    title = 'Test Book',
    author = 'Test Author',
    authorAsin = 'A000000001',
    narrator = 'Test Narrator',
    narrators,
    coverArtUrl = 'https://images.example.com/cover._SL500_.jpg',
    rating = 4.5,
  } = overrides;

  const narratorLinks = narrators && narrators.length > 0
    ? renderNarratorLinks(narrators)
    : `<a href="/search?searchNarrator=${encodeURIComponent(narrator)}">${narrator}</a>`;

  return `
    <div class="s-result-item">
      <ul>
        <li data-asin="${asin}">
          <img src="${coverArtUrl}" />
          <h2><a href="/pd/test/${asin}">${title}</a></h2>
          <a href="/author/test/${authorAsin}">${author}</a>
          ${narratorLinks}
          <span class="ratingsLabel">${rating} out of 5</span>
        </li>
      </ul>
    </div>
  `;
}

/** Wrap one or more item-HTML strings in a minimal page document. */
function makeHtmlPage(items: string[]): string {
  return `<html><body>${items.join('')}</body></html>`;
}

/**
 * Produces the value that client.get() should resolve to for HTML responses.
 * cheerio.load() is called on response.data, so .data must be the raw HTML string.
 */
function htmlResponse(html: string) {
  return { data: html };
}

// ---------------------------------------------------------------------------
// Test setup
// ---------------------------------------------------------------------------

describe('AudibleService', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    htmlClientMock.get.mockReset();
    apiClientMock.get.mockReset();
    axiosMock.get.mockReset();
    configServiceMock.getAudibleRegion.mockReset();

    // Default: first create() → htmlClient, second → apiClient.
    axiosMock.create
      .mockReturnValueOnce(htmlClientMock)
      .mockReturnValueOnce(apiClientMock);

    configServiceMock.getAudibleRegion.mockResolvedValue('us');
  });

  // -------------------------------------------------------------------------
  // Initialization
  // -------------------------------------------------------------------------

  describe('initialization', () => {
    it('calls axios.create twice on first search (htmlClient + apiClient)', async () => {
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([])));

      const service = new AudibleService();
      await service.search('test', 1);

      expect(axiosMock.create).toHaveBeenCalledTimes(2);
    });

    it('creates htmlClient with the region baseUrl', async () => {
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([])));

      const service = new AudibleService();
      await service.search('test', 1);

      expect(axiosMock.create.mock.calls[0][0].baseURL).toBe(AUDIBLE_REGIONS.us.baseUrl);
    });

    it('creates apiClient with the region apiBaseUrl', async () => {
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([])));

      const service = new AudibleService();
      await service.search('test', 1);

      expect(axiosMock.create.mock.calls[1][0].baseURL).toBe(AUDIBLE_REGIONS.us.apiBaseUrl);
    });

    it('does not reinitialize when the region is unchanged between calls', async () => {
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([])));

      const service = new AudibleService();
      await service.search('test', 1);
      await service.search('test', 1);

      // Still only 2 creates total (not 4).
      expect(axiosMock.create).toHaveBeenCalledTimes(2);
    });

    it('reinitializes when the configured region changes between calls', async () => {
      configServiceMock.getAudibleRegion
        .mockResolvedValueOnce('us')
        .mockResolvedValueOnce('uk')
        .mockResolvedValueOnce('uk');

      // Prepare creates for both init cycles.
      axiosMock.create.mockReset();
      axiosMock.create
        .mockReturnValueOnce(htmlClientMock) // first init: htmlClient
        .mockReturnValueOnce(apiClientMock)  // first init: apiClient
        .mockReturnValueOnce(htmlClientMock) // second init: htmlClient
        .mockReturnValueOnce(apiClientMock); // second init: apiClient

      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([])));

      const service = new AudibleService();
      await service.search('test', 1);
      await service.search('test', 1);

      expect(axiosMock.create).toHaveBeenCalledTimes(4);
      expect(axiosMock.create.mock.calls[2][0].baseURL).toBe(AUDIBLE_REGIONS.uk.baseUrl);
    });

    it('reinitializes after forceReinitialize() is called', async () => {
      axiosMock.create.mockReset();
      axiosMock.create
        .mockReturnValueOnce(htmlClientMock)
        .mockReturnValueOnce(apiClientMock)
        .mockReturnValueOnce(htmlClientMock)
        .mockReturnValueOnce(apiClientMock);

      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([])));

      const service = new AudibleService();
      await service.search('test', 1);
      service.forceReinitialize();
      await service.search('test', 1);

      expect(axiosMock.create).toHaveBeenCalledTimes(4);
    });

    it('falls back to the default US region when config service throws', async () => {
      configServiceMock.getAudibleRegion.mockRejectedValue(new Error('config fail'));
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([])));

      const service = new AudibleService();
      await service.search('fallback', 1);

      expect(axiosMock.create.mock.calls[0][0].baseURL).toBe(
        AUDIBLE_REGIONS[DEFAULT_AUDIBLE_REGION].baseUrl,
      );
    });

    it('creates both clients even when config service throws', async () => {
      configServiceMock.getAudibleRegion.mockRejectedValue(new Error('config fail'));
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([])));

      const service = new AudibleService();
      await service.search('fallback', 1);

      expect(axiosMock.create).toHaveBeenCalledTimes(2);
    });
  });

  // -------------------------------------------------------------------------
  // search()
  // -------------------------------------------------------------------------

  describe('search()', () => {
    it('sends correct endpoint, keywords, num_results, and response_groups to apiClient', async () => {
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([])));

      const service = new AudibleService();
      await service.search('fantasy', 1);

      expect(apiClientMock.get).toHaveBeenCalledWith(
        '/1.0/catalog/products',
        expect.objectContaining({
          params: expect.objectContaining({
            keywords: 'fantasy',
            num_results: 50,
            response_groups: expect.stringContaining('contributors'),
          }),
        }),
      );
    });

    it('subtracts 1 from public page=1 before calling the API (page offset regression)', async () => {
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([])));
      const service = new AudibleService();

      await service.search('test', 1);
      expect(apiClientMock.get.mock.calls[0][1].params.page).toBe(0);
    });

    it('subtracts 1 from public page=2 before calling the API', async () => {
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([])));
      const service = new AudibleService();

      await service.search('test', 2);
      expect(apiClientMock.get.mock.calls[0][1].params.page).toBe(1);
    });

    it('subtracts 1 from public page=3 before calling the API', async () => {
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([])));
      const service = new AudibleService();

      await service.search('test', 3);
      expect(apiClientMock.get.mock.calls[0][1].params.page).toBe(2);
    });

    it('returns query, results, totalResults, page, and hasMore fields', async () => {
      const products = [makeProduct()];
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products, 1)));

      const service = new AudibleService();
      const result = await service.search('test', 1);

      expect(result).toMatchObject({
        query: 'test',
        page: 1,
        totalResults: 1,
        hasMore: false,
      });
      expect(result.results).toHaveLength(1);
    });

    it('sets hasMore=true when totalResults exceeds page * pageSize', async () => {
      const products = Array.from({ length: 50 }, (_, i) =>
        makeProduct({ asin: `B${String(i).padStart(9, '0')}`, title: `Book ${i}` }),
      );
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products, 150)));

      const service = new AudibleService();
      const result = await service.search('test', 1);

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

    it('sets hasMore=false when all results fit on the current page', async () => {
      const products = [makeProduct()];
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products, 1)));

      const service = new AudibleService();
      const result = await service.search('test', 1);

      expect(result.hasMore).toBe(false);
    });

    it('returns empty results on error without throwing', async () => {
      const error: Error & { response?: { status: number } } = new Error('Not Found');
      error.response = { status: 404 };
      apiClientMock.get.mockRejectedValue(error);

      const service = new AudibleService();
      const result = await service.search('oops', 1);

      expect(result.results).toEqual([]);
      expect(result.hasMore).toBe(false);
      expect(result.totalResults).toBe(0);
    });

    it('uses apiClient (not htmlClient) for catalog requests', async () => {
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([])));

      const service = new AudibleService();
      await service.search('test', 1);

      expect(apiClientMock.get).toHaveBeenCalled();
      expect(htmlClientMock.get).not.toHaveBeenCalled();
    });
  });

  // -------------------------------------------------------------------------
  // mapCatalogProduct correctness (tested via search())
  // -------------------------------------------------------------------------

  describe('mapCatalogProduct field mapping', () => {
    it('maps asin and title from catalog product', async () => {
      const products = [makeProduct({ asin: 'B000AAABBB', title: 'My Great Book' })];
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products)));

      const service = new AudibleService();
      const { results } = await service.search('test', 1);

      expect(results[0].asin).toBe('B000AAABBB');
      expect(results[0].title).toBe('My Great Book');
    });

    it('joins multiple author names with a comma and maps first author asin', async () => {
      const products = [
        makeProduct({
          authors: [
            { asin: 'A111', name: 'First Author' },
            { asin: 'A222', name: 'Second Author' },
          ],
        }),
      ];
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products)));

      const service = new AudibleService();
      const { results } = await service.search('test', 1);

      expect(results[0].author).toBe('First Author, Second Author');
      expect(results[0].authorAsin).toBe('A111');
    });

    it('joins multiple narrator names with a comma', async () => {
      const products = [
        makeProduct({
          narrators: [{ name: 'Narrator A' }, { name: 'Narrator B' }],
        }),
      ];
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products)));

      const service = new AudibleService();
      const { results } = await service.search('test', 1);

      expect(results[0].narrator).toBe('Narrator A, Narrator B');
    });

    it('sets narrator to undefined when narrators array is absent', async () => {
      const { narrators: _n, ...base } = makeProduct();
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([base])));

      const service = new AudibleService();
      const { results } = await service.search('test', 1);

      expect(results[0].narrator).toBeUndefined();
    });

    it('strips HTML tags and entities from publisher_summary to produce plain text description', async () => {
      const products = [
        makeProduct({
          // Use a space before <br/> so whitespace is preserved after tag removal.
          publisher_summary:
            '<p>A &amp; B book with&nbsp;smart text. <br/>More here.</p>',
        }),
      ];
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products)));

      const service = new AudibleService();
      const { results } = await service.search('test', 1);

      expect(results[0].description).toBe('A & B book with smart text. More here.');
    });

    it('falls back to merchandising_summary when publisher_summary is absent', async () => {
      const { publisher_summary: _p, ...base } = makeProduct();
      const products = [{ ...base, merchandising_summary: 'Merchandising text.' }];
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products)));

      const service = new AudibleService();
      const { results } = await service.search('test', 1);

      expect(results[0].description).toBe('Merchandising text.');
    });

    it('sets description to undefined when both summary fields are absent', async () => {
      const { publisher_summary: _p, ...base } = makeProduct();
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([base])));

      const service = new AudibleService();
      const { results } = await service.search('test', 1);

      expect(results[0].description).toBeUndefined();
    });

    it('maps coverArtUrl from product_images["500"]', async () => {
      const products = [
        makeProduct({ product_images: { '500': 'https://images.example.com/cover500.jpg' } }),
      ];
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products)));

      const service = new AudibleService();
      const { results } = await service.search('test', 1);

      expect(results[0].coverArtUrl).toBe('https://images.example.com/cover500.jpg');
    });

    it('sets coverArtUrl to undefined when product_images is absent', async () => {
      const { product_images: _pi, ...base } = makeProduct();
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([base])));

      const service = new AudibleService();
      const { results } = await service.search('test', 1);

      expect(results[0].coverArtUrl).toBeUndefined();
    });

    it('maps durationMinutes from runtime_length_min', async () => {
      const products = [makeProduct({ runtime_length_min: 480 })];
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products)));

      const service = new AudibleService();
      const { results } = await service.search('test', 1);

      expect(results[0].durationMinutes).toBe(480);
    });

    it('maps releaseDate from release_date', async () => {
      const products = [makeProduct({ release_date: '2023-06-15' })];
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products)));

      const service = new AudibleService();
      const { results } = await service.search('test', 1);

      expect(results[0].releaseDate).toBe('2023-06-15');
    });

    it('maps rating from rating.overall_distribution.display_stars', async () => {
      const products = [
        makeProduct({ rating: { overall_distribution: { display_stars: 4.7 } } }),
      ];
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products)));

      const service = new AudibleService();
      const { results } = await service.search('test', 1);

      expect(results[0].rating).toBe(4.7);
    });

    it('sets rating to undefined when rating field is absent', async () => {
      const { rating: _r, ...base } = makeProduct();
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([base])));

      const service = new AudibleService();
      const { results } = await service.search('test', 1);

      expect(results[0].rating).toBeUndefined();
    });

    it('flattens, deduplicates, and caps genres at 5 from category_ladders', async () => {
      const products = [
        makeProduct({
          category_ladders: [
            { ladder: [{ name: 'Fiction' }, { name: 'Fantasy' }, { name: 'Epic Fantasy' }] },
            { ladder: [{ name: 'Fiction' }, { name: 'Adventure' }] }, // "Fiction" is a duplicate
            { ladder: [{ name: 'Young Adult' }, { name: 'Coming of Age' }] },
          ],
        }),
      ];
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products)));

      const service = new AudibleService();
      const { results } = await service.search('test', 1);

      // After dedupe: Fiction, Fantasy, Epic Fantasy, Adventure, Young Adult, Coming of Age = 6 → capped at 5
      expect(results[0].genres).toHaveLength(5);
      expect(results[0].genres).not.toContain('Coming of Age');
      // Duplicates removed
      const genreSet = new Set(results[0].genres);
      expect(genreSet.size).toBe(5);
    });

    it('maps language from catalog product', async () => {
      const products = [makeProduct({ language: 'english' })];
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products)));

      const service = new AudibleService();
      const { results } = await service.search('test', 1);

      expect(results[0].language).toBe('english');
    });

    it('maps format_type to formatType from catalog product', async () => {
      const products = [makeProduct({ format_type: 'unabridged' })];
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products)));

      const service = new AudibleService();
      const { results } = await service.search('test', 1);

      expect(results[0].formatType).toBe('unabridged');
    });

    it('maps publisher_name to publisherName from catalog product', async () => {
      const products = [makeProduct({ publisher_name: 'Penguin Random House Audio' })];
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products)));

      const service = new AudibleService();
      const { results } = await service.search('test', 1);

      expect(results[0].publisherName).toBe('Penguin Random House Audio');
    });

    it('leaves formatType and publisherName undefined when catalog product omits them', async () => {
      const products = [makeProduct()];
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products)));

      const service = new AudibleService();
      const { results } = await service.search('test', 1);

      expect(results[0].formatType).toBeUndefined();
      expect(results[0].publisherName).toBeUndefined();
    });
  });

  // -------------------------------------------------------------------------
  // Series selection rules
  // -------------------------------------------------------------------------

  describe('series selection', () => {
    it('picks the series entry that has a non-empty sequence (even if not first)', async () => {
      const products = [
        makeProduct({
          series: [
            { asin: 'S000', title: 'Wrong Series', sequence: '' },
            { asin: 'S001', title: 'Right Series', sequence: '3' },
          ],
        }),
      ];
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products)));

      const service = new AudibleService();
      const { results } = await service.search('test', 1);

      expect(results[0].series).toBe('Right Series');
      expect(results[0].seriesAsin).toBe('S001');
      expect(results[0].seriesPart).toBe('3');
    });

    it('falls back to series[0] when all sequence values are empty', async () => {
      const products = [
        makeProduct({
          series: [
            { asin: 'S010', title: 'Fallback Series', sequence: '' },
            { asin: 'S011', title: 'Other Series', sequence: '' },
          ],
        }),
      ];
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products)));

      const service = new AudibleService();
      const { results } = await service.search('test', 1);

      expect(results[0].series).toBe('Fallback Series');
      expect(results[0].seriesPart).toBeUndefined();
    });

    it('leaves all series fields undefined when series array is absent', async () => {
      const { series: _s, ...base } = makeProduct();
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([base])));

      const service = new AudibleService();
      const { results } = await service.search('test', 1);

      expect(results[0].series).toBeUndefined();
      expect(results[0].seriesPart).toBeUndefined();
      expect(results[0].seriesAsin).toBeUndefined();
    });

    it('extracts leading numeric part from a compound sequence string like "2, Dramatized Adaptation"', async () => {
      const products = [
        makeProduct({
          series: [{ asin: 'S020', title: 'Drama Series', sequence: '2, Dramatized Adaptation' }],
        }),
      ];
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products)));

      const service = new AudibleService();
      const { results } = await service.search('test', 1);

      expect(results[0].seriesPart).toBe('2');
    });

    it('preserves decimal sequence values like "1.5"', async () => {
      const products = [
        makeProduct({
          series: [{ asin: 'S021', title: 'Decimal Series', sequence: '1.5' }],
        }),
      ];
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products)));

      const service = new AudibleService();
      const { results } = await service.search('test', 1);

      expect(results[0].seriesPart).toBe('1.5');
    });

    it('keeps non-numeric sequence text as-is when there are no digits (e.g. "Prequel")', async () => {
      const products = [
        makeProduct({
          series: [{ asin: 'S022', title: 'Prequel Series', sequence: 'Prequel' }],
        }),
      ];
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products)));

      const service = new AudibleService();
      const { results } = await service.search('test', 1);

      expect(results[0].seriesPart).toBe('Prequel');
    });
  });

  // -------------------------------------------------------------------------
  // searchByAuthorAsin()
  // -------------------------------------------------------------------------

  describe('searchByAuthorAsin()', () => {
    it('sends author name (not ASIN) as the author param', async () => {
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([])));

      const service = new AudibleService();
      await service.searchByAuthorAsin('Brandon Sanderson', 'A000AUTHOR', 1);

      expect(apiClientMock.get.mock.calls[0][1].params.author).toBe('Brandon Sanderson');
    });

    it('subtracts 1 from public page=1 before calling the API', async () => {
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([])));
      const service = new AudibleService();

      await service.searchByAuthorAsin('Test Author', 'AASIN', 1);
      expect(apiClientMock.get.mock.calls[0][1].params.page).toBe(0);
    });

    it('subtracts 1 from public page=2 before calling the API', async () => {
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([])));
      const service = new AudibleService();

      await service.searchByAuthorAsin('Test Author', 'AASIN', 2);
      expect(apiClientMock.get.mock.calls[0][1].params.page).toBe(1);
    });

    it('filters out products whose authors array does not contain the target ASIN', async () => {
      const matchingAsin = 'A000AUTHOR';
      const products = [
        makeProduct({ asin: 'B001', authors: [{ asin: matchingAsin, name: 'Author' }], language: 'english' }),
        makeProduct({ asin: 'B002', authors: [{ asin: 'A999OTHER', name: 'Other' }], language: 'english' }),
        makeProduct({ asin: 'B003', authors: [{ asin: matchingAsin, name: 'Author' }], language: 'english' }),
      ];
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products, 3)));

      const service = new AudibleService();
      const result = await service.searchByAuthorAsin('Author', matchingAsin, 1);

      expect(result.books).toHaveLength(2);
      expect(result.books.map((b) => b.asin)).toEqual(['B001', 'B003']);
    });

    it('filters out products whose language does not match the region accepted values', async () => {
      const matchingAsin = 'A000AUTHOR';
      const products = [
        makeProduct({ asin: 'B004', authors: [{ asin: matchingAsin, name: 'Author' }], language: 'english' }),
        makeProduct({ asin: 'B005', authors: [{ asin: matchingAsin, name: 'Author' }], language: 'spanish' }),
      ];
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products, 2)));

      const service = new AudibleService();
      // US region only accepts 'english'
      const result = await service.searchByAuthorAsin('Author', matchingAsin, 1);

      expect(result.books).toHaveLength(1);
      expect(result.books[0].asin).toBe('B004');
    });

    it('applies both ASIN and language filters together (AND logic)', async () => {
      const matchingAsin = 'A000AUTHOR';
      const products = [
        // passes both
        makeProduct({ asin: 'B006', authors: [{ asin: matchingAsin, name: 'Author' }], language: 'english' }),
        // wrong ASIN
        makeProduct({ asin: 'B007', authors: [{ asin: 'A999OTHER', name: 'Other' }], language: 'english' }),
        // wrong language
        makeProduct({ asin: 'B008', authors: [{ asin: matchingAsin, name: 'Author' }], language: 'spanish' }),
        // wrong ASIN + wrong language
        makeProduct({ asin: 'B009', authors: [{ asin: 'A999OTHER', name: 'Other' }], language: 'spanish' }),
      ];
      apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products, 4)));

      const service = new AudibleService();
      const result = await service.searchByAuthorAsin('Author', matchingAsin, 1);

      expect(result.books).toHaveLength(1);
      expect(result.books[0].asin).toBe('B006');
    });
  });

  // -------------------------------------------------------------------------
  // getPopularAudiobooks() — HTML scraping of /adblbestsellers
  // -------------------------------------------------------------------------

  describe('getPopularAudiobooks()', () => {
    it('hits /adblbestsellers on the htmlClient with pageSize=50', async () => {
      htmlClientMock.get.mockResolvedValue(htmlResponse(makeHtmlPage([makeProductListItemHtml()])));

      const service = new AudibleService();
      await service.getPopularAudiobooks(1);

      expect(htmlClientMock.get).toHaveBeenCalledWith(
        '/adblbestsellers',
        expect.objectContaining({
          params: expect.objectContaining({ pageSize: 50 }),
        }),
      );
    });

    it('does not include a page param on the first request (only from page 2 onward)', async () => {
      htmlClientMock.get.mockResolvedValue(htmlResponse(makeHtmlPage([makeProductListItemHtml()])));
      const service = new AudibleService();
      const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined);

      await service.getPopularAudiobooks(1);
      expect(htmlClientMock.get.mock.calls[0][1].params.page).toBeUndefined();
      delaySpy.mockRestore();
    });

    it('includes page=2 on the second request when paginating', async () => {
      const page1Items = Array.from({ length: 50 }, (_, i) =>
        makeProductListItemHtml({ asin: `B${String(i).padStart(9, '0')}`, title: `Book ${i}` }),
      );
      const page2Items = Array.from({ length: 25 }, (_, i) =>
        makeProductListItemHtml({ asin: `B${String(i + 50).padStart(9, '0')}`, title: `Book ${i + 50}` }),
      );

      htmlClientMock.get
        .mockResolvedValueOnce(htmlResponse(makeHtmlPage(page1Items)))
        .mockResolvedValueOnce(htmlResponse(makeHtmlPage(page2Items)));

      const service = new AudibleService();
      const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined);

      await service.getPopularAudiobooks(75);

      expect(htmlClientMock.get.mock.calls[1][1].params.page).toBe(2);
      delaySpy.mockRestore();
    });

    it('paginates across pages and returns up to the requested limit', async () => {
      const page1Items = Array.from({ length: 50 }, (_, i) =>
        makeProductListItemHtml({ asin: `B${String(i).padStart(9, '0')}`, title: `Book ${i}` }),
      );
      const page2Items = Array.from({ length: 25 }, (_, i) =>
        makeProductListItemHtml({ asin: `B${String(i + 50).padStart(9, '0')}`, title: `Book ${i + 50}` }),
      );

      htmlClientMock.get
        .mockResolvedValueOnce(htmlResponse(makeHtmlPage(page1Items)))
        .mockResolvedValueOnce(htmlResponse(makeHtmlPage(page2Items)));

      const service = new AudibleService();
      const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined);
      const results = await service.getPopularAudiobooks(75);

      expect(results).toHaveLength(75);
      delaySpy.mockRestore();
    });

    it('stops early when a page returns fewer than half the page size', async () => {
      htmlClientMock.get.mockResolvedValueOnce(
        htmlResponse(makeHtmlPage([makeProductListItemHtml()])),
      );

      const service = new AudibleService();
      const results = await service.getPopularAudiobooks(50);

      expect(results).toHaveLength(1);
      expect(htmlClientMock.get).toHaveBeenCalledTimes(1);
    });

    it('deduplicates by ASIN across pages', async () => {
      const sharedAsin = 'BDUP000001';
      const uniqueAsin = 'BUNIQ000001';

      // Build a "full" first page (50 items, all with the shared ASIN duplicated as filler)
      // so the parser proceeds to page 2.
      const page1Items = [
        makeProductListItemHtml({ asin: sharedAsin, title: 'Duplicated Book' }),
        ...Array.from({ length: 49 }, (_, i) =>
          makeProductListItemHtml({ asin: `BFILL${String(i).padStart(5, '0')}`, title: `Filler ${i}` }),
        ),
      ];
      const page2Items = [
        makeProductListItemHtml({ asin: sharedAsin, title: 'Duplicated Book' }),
        makeProductListItemHtml({ asin: uniqueAsin, title: 'Unique Book' }),
        ...Array.from({ length: 48 }, (_, i) =>
          makeProductListItemHtml({ asin: `BFILL2${String(i).padStart(4, '0')}`, title: `Filler2 ${i}` }),
        ),
      ];

      htmlClientMock.get
        .mockResolvedValueOnce(htmlResponse(makeHtmlPage(page1Items)))
        .mockResolvedValueOnce(htmlResponse(makeHtmlPage(page2Items)));

      const service = new AudibleService();
      const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined);
      const results = await service.getPopularAudiobooks(150);

      const asins = results.map((r) => r.asin);
      expect(asins.filter((a) => a === sharedAsin)).toHaveLength(1);
      expect(asins).toContain(uniqueAsin);
      delaySpy.mockRestore();
    });

    it('returns empty array on error without throwing', async () => {
      const error: Error & { response?: { status: number } } = new Error('Not Found');
      error.response = { status: 404 };
      htmlClientMock.get.mockRejectedValue(error);

      const service = new AudibleService();
      const results = await service.getPopularAudiobooks(5);

      expect(results).toEqual([]);
    });

    it('uses htmlClient (not apiClient) for the request', async () => {
      htmlClientMock.get.mockResolvedValue(htmlResponse(makeHtmlPage([makeProductListItemHtml()])));

      const service = new AudibleService();
      await service.getPopularAudiobooks(1);

      expect(htmlClientMock.get).toHaveBeenCalled();
      expect(apiClientMock.get).not.toHaveBeenCalled();
    });

    it('maps title, author, narrator, and rating from the parsed item', async () => {
      htmlClientMock.get.mockResolvedValue(
        htmlResponse(
          makeHtmlPage([
            makeProductListItemHtml({
              asin: 'B0HTMLMAP1',
              title: 'Mapped Title',
              author: 'Mapped Author',
              authorAsin: 'A00MAPAUTH',
              narrator: 'Mapped Narrator',
              rating: 4.7,
            }),
          ]),
        ),
      );

      const service = new AudibleService();
      const [book] = await service.getPopularAudiobooks(1);

      expect(book.asin).toBe('B0HTMLMAP1');
      expect(book.title).toBe('Mapped Title');
      expect(book.author).toBe('Mapped Author');
      expect(book.authorAsin).toBe('A00MAPAUTH');
      expect(book.narrator).toBe('Mapped Narrator');
      expect(book.rating).toBeCloseTo(4.7);
    });

    it('captures every co-narrator on multi-narrator productions (regression: prior code took only the first link)', async () => {
      htmlClientMock.get.mockResolvedValue(
        htmlResponse(
          makeHtmlPage([
            makeProductListItemHtml({
              asin: 'B0FULLCAST',
              narrators: [
                'Kristin Atherton',
                'Roy McMillan',
                'Clare Corbett',
                'Tom Bateman',
                'Patience Tomlinson',
                'Shaheen Khan',
              ],
            }),
          ]),
        ),
      );

      const service = new AudibleService();
      const [book] = await service.getPopularAudiobooks(1);

      // Every narrator must round-trip — order is not significant downstream,
      // but document order should be preserved for stable cache values.
      expect(book.narrator).toBe(
        'Kristin Atherton, Roy McMillan, Clare Corbett, Tom Bateman, Patience Tomlinson, Shaheen Khan',
      );
    });
  });

  // -------------------------------------------------------------------------
  // getNewReleases() — HTML scraping of /newreleases
  // -------------------------------------------------------------------------

  describe('getNewReleases()', () => {
    it('hits /newreleases on the htmlClient with pageSize=50', async () => {
      htmlClientMock.get.mockResolvedValue(htmlResponse(makeHtmlPage([makeProductListItemHtml()])));

      const service = new AudibleService();
      await service.getNewReleases(1);

      expect(htmlClientMock.get).toHaveBeenCalledWith(
        '/newreleases',
        expect.objectContaining({
          params: expect.objectContaining({ pageSize: 50 }),
        }),
      );
    });

    it('does not include a page param on the first request', async () => {
      htmlClientMock.get.mockResolvedValue(htmlResponse(makeHtmlPage([makeProductListItemHtml()])));
      const service = new AudibleService();
      const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined);

      await service.getNewReleases(1);
      expect(htmlClientMock.get.mock.calls[0][1].params.page).toBeUndefined();
      delaySpy.mockRestore();
    });

    it('includes page=2 on the second request when paginating', async () => {
      const page1Items = Array.from({ length: 50 }, (_, i) =>
        makeProductListItemHtml({ asin: `B${String(i).padStart(9, '0')}` }),
      );
      const page2Items = Array.from({ length: 50 }, (_, i) =>
        makeProductListItemHtml({ asin: `B${String(i + 50).padStart(9, '0')}` }),
      );

      htmlClientMock.get
        .mockResolvedValueOnce(htmlResponse(makeHtmlPage(page1Items)))
        .mockResolvedValueOnce(htmlResponse(makeHtmlPage(page2Items)));

      const service = new AudibleService();
      const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined);

      await service.getNewReleases(100);
      expect(htmlClientMock.get.mock.calls[1][1].params.page).toBe(2);
      delaySpy.mockRestore();
    });

    it('deduplicates by ASIN across pages', async () => {
      const sharedAsin = 'BDUP000002';

      const page1Items = [
        makeProductListItemHtml({ asin: sharedAsin }),
        ...Array.from({ length: 49 }, (_, i) =>
          makeProductListItemHtml({ asin: `BNEW${String(i).padStart(6, '0')}` }),
        ),
      ];
      const page2Items = [
        makeProductListItemHtml({ asin: sharedAsin }),
        ...Array.from({ length: 49 }, (_, i) =>
          makeProductListItemHtml({ asin: `BNEW2${String(i).padStart(5, '0')}` }),
        ),
      ];

      htmlClientMock.get
        .mockResolvedValueOnce(htmlResponse(makeHtmlPage(page1Items)))
        .mockResolvedValueOnce(htmlResponse(makeHtmlPage(page2Items)));

      const service = new AudibleService();
      const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined);
      const results = await service.getNewReleases(150);

      expect(results.filter((r) => r.asin === sharedAsin)).toHaveLength(1);
      delaySpy.mockRestore();
    });

    it('returns empty array on error without throwing', async () => {
      const error: Error & { response?: { status: number } } = new Error('Not Found');
      error.response = { status: 404 };
      htmlClientMock.get.mockRejectedValue(error);

      const service = new AudibleService();
      const results = await service.getNewReleases(5);

      expect(results).toEqual([]);
    });

    it('uses htmlClient (not apiClient) for the request', async () => {
      htmlClientMock.get.mockResolvedValue(htmlResponse(makeHtmlPage([makeProductListItemHtml()])));

      const service = new AudibleService();
      await service.getNewReleases(1);

      expect(htmlClientMock.get).toHaveBeenCalled();
      expect(apiClientMock.get).not.toHaveBeenCalled();
    });
  });

  // -------------------------------------------------------------------------
  // getCategoryBooks() — HTML scraping of /search?node=<categoryId>
  // -------------------------------------------------------------------------

  describe('getCategoryBooks()', () => {
    it('hits /search on the htmlClient with node, pageSize, and popularity-rank sort', async () => {
      htmlClientMock.get.mockResolvedValue(
        htmlResponse(makeHtmlPage([makeSearchResultItemHtml()])),
      );

      const service = new AudibleService();
      await service.getCategoryBooks('18685580011', 1);

      const params = htmlClientMock.get.mock.calls[0][1].params;
      expect(htmlClientMock.get.mock.calls[0][0]).toBe('/search');
      expect(params.node).toBe('18685580011');
      expect(params.pageSize).toBe(50);
      expect(params.sort).toBe('popularity-rank');
    });

    it('does not include a page param on the first request', async () => {
      htmlClientMock.get.mockResolvedValue(
        htmlResponse(makeHtmlPage([makeSearchResultItemHtml()])),
      );
      const service = new AudibleService();
      const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined);

      await service.getCategoryBooks('CAT001', 1);
      expect(htmlClientMock.get.mock.calls[0][1].params.page).toBeUndefined();
      delaySpy.mockRestore();
    });

    it('includes page=2 on the second request when paginating', async () => {
      const page1Items = Array.from({ length: 50 }, (_, i) =>
        makeSearchResultItemHtml({ asin: `B${String(i).padStart(9, '0')}` }),
      );
      const page2Items = Array.from({ length: 50 }, (_, i) =>
        makeSearchResultItemHtml({ asin: `B${String(i + 50).padStart(9, '0')}` }),
      );

      htmlClientMock.get
        .mockResolvedValueOnce(htmlResponse(makeHtmlPage(page1Items)))
        .mockResolvedValueOnce(htmlResponse(makeHtmlPage(page2Items)));

      const service = new AudibleService();
      const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined);

      await service.getCategoryBooks('CAT001', 100);
      expect(htmlClientMock.get.mock.calls[1][1].params.page).toBe(2);
      delaySpy.mockRestore();
    });

    it('deduplicates by ASIN across pages', async () => {
      const sharedAsin = 'BDUP000003';

      const page1Items = [
        makeSearchResultItemHtml({ asin: sharedAsin }),
        ...Array.from({ length: 49 }, (_, i) =>
          makeSearchResultItemHtml({ asin: `BCAT${String(i).padStart(6, '0')}` }),
        ),
      ];
      const page2Items = [
        makeSearchResultItemHtml({ asin: sharedAsin }),
        ...Array.from({ length: 49 }, (_, i) =>
          makeSearchResultItemHtml({ asin: `BCAT2${String(i).padStart(5, '0')}` }),
        ),
      ];

      htmlClientMock.get
        .mockResolvedValueOnce(htmlResponse(makeHtmlPage(page1Items)))
        .mockResolvedValueOnce(htmlResponse(makeHtmlPage(page2Items)));

      const service = new AudibleService();
      const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined);
      const results = await service.getCategoryBooks('CAT001', 150);

      expect(results.filter((r) => r.asin === sharedAsin)).toHaveLength(1);
      delaySpy.mockRestore();
    });

    it('uses htmlClient (not apiClient) for the request', async () => {
      htmlClientMock.get.mockResolvedValue(
        htmlResponse(makeHtmlPage([makeSearchResultItemHtml()])),
      );

      const service = new AudibleService();
      await service.getCategoryBooks('CAT001', 1);

      expect(htmlClientMock.get).toHaveBeenCalled();
      expect(apiClientMock.get).not.toHaveBeenCalled();
    });

    it('captures every co-narrator on multi-narrator productions (regression: prior code took only the first link)', async () => {
      htmlClientMock.get.mockResolvedValue(
        htmlResponse(
          makeHtmlPage([
            makeSearchResultItemHtml({
              asin: 'B0FULLCAST',
              narrators: ['Alice', 'Bob', 'Carol', 'Dan'],
            }),
          ]),
        ),
      );

      const service = new AudibleService();
      const [book] = await service.getCategoryBooks('CAT001', 1);

      expect(book.narrator).toBe('Alice, Bob, Carol, Dan');
    });
  });

  // -------------------------------------------------------------------------
  // getCategories()
  // -------------------------------------------------------------------------

  describe('getCategories()', () => {
    it('hits /1.0/catalog/categories and maps top-level categories to id+name', async () => {
      apiClientMock.get.mockResolvedValue(
        apiResponse({
          categories: [
            { id: '18685580011', name: 'Science Fiction & Fantasy' },
            { id: '18685812011', name: 'Mystery, Thriller & Suspense' },
          ],
        }),
      );

      const service = new AudibleService();
      const categories = await service.getCategories();

      expect(apiClientMock.get).toHaveBeenCalledWith('/1.0/catalog/categories', expect.anything());
      expect(categories).toHaveLength(2);
      expect(categories[0]).toEqual({ id: '18685580011', name: 'Science Fiction & Fantasy' });
    });

    it('returns empty array when categories field is missing', async () => {
      apiClientMock.get.mockResolvedValue(apiResponse({}));

      const service = new AudibleService();
      const categories = await service.getCategories();

      expect(categories).toEqual([]);
    });

    it('returns empty array on error without throwing', async () => {
      const error: Error & { response?: { status: number } } = new Error('Not Found');
      error.response = { status: 404 };
      apiClientMock.get.mockRejectedValue(error);

      const service = new AudibleService();
      const categories = await service.getCategories();

      expect(categories).toEqual([]);
    });
  });

  // -------------------------------------------------------------------------
  // getAudiobookDetails() — Audnexus primary + catalog fallback
  // -------------------------------------------------------------------------

  describe('getAudiobookDetails()', () => {
    it('returns Audnexus data directly when Audnexus succeeds', async () => {
      axiosMock.get.mockResolvedValueOnce({
        data: {
          title: 'Audnexus Book',
          authors: [{ name: 'Author A', asin: 'A111' }],
          narrators: [{ name: 'Narrator A' }],
          description: 'A fine description.',
          image: 'https://images.example.com/cover._SL500_.jpg',
          runtimeLengthMin: '300',
          genres: ['Fiction'],
          rating: '4.7',
          language: 'english',
          formatType: 'unabridged',
          publisherName: 'Test Publisher',
        },
      });

      const service = new AudibleService();
      const details = await service.getAudiobookDetails('B000AAAAAA');

      expect(details?.title).toBe('Audnexus Book');
      expect(details?.author).toBe('Author A');
      expect(details?.durationMinutes).toBe(300);
      expect(details?.language).toBe('english');
      expect(details?.formatType).toBe('unabridged');
      expect(details?.publisherName).toBe('Test Publisher');
      // Catalog API should NOT be called when Audnexus succeeds.
      expect(apiClientMock.get).not.toHaveBeenCalled();
    });

    it('falls back to the catalog API when Audnexus returns 404', async () => {
      axiosMock.get.mockRejectedValueOnce({ response: { status: 404 }, message: 'Not found' });

      const product = makeProduct({ asin: 'B000BBBBBB', title: 'Catalog Book' });
      apiClientMock.get.mockResolvedValue(
        apiResponse({ product }),
      );

      const service = new AudibleService();
      const details = await service.getAudiobookDetails('B000BBBBBB');

      expect(details?.title).toBe('Catalog Book');
      expect(apiClientMock.get).toHaveBeenCalled();
    });

    it('returns null when the catalog API returns a stub body (product.title missing)', async () => {
      axiosMock.get.mockRejectedValueOnce({ response: { status: 404 }, message: 'Not found' });

      // Stub body: asin present but no title
      apiClientMock.get.mockResolvedValue(
        apiResponse({ product: { asin: 'B000STUB01' } }),
      );

      const service = new AudibleService();
      const details = await service.getAudiobookDetails('B000STUB01');

      expect(details).toBeNull();
    });

    it('returns null when both Audnexus and the catalog API fail', async () => {
      axiosMock.get.mockRejectedValueOnce({ response: { status: 404 }, message: 'Not found' });
      // Use a non-retryable 404 so the test does not incur retry delays.
      const error: Error & { response?: { status: number } } = new Error('Not Found');
      error.response = { status: 404 };
      apiClientMock.get.mockRejectedValue(error);

      const service = new AudibleService();
      const details = await service.getAudiobookDetails('B000FAIL01');

      expect(details).toBeNull();
    });

    it('returns null when fetchAudibleDetailsFromApi throws unexpectedly', async () => {
      axiosMock.get.mockRejectedValueOnce({ response: { status: 404 }, message: 'Not found' });

      const service = new AudibleService();
      vi.spyOn(service as any, 'fetchAudibleDetailsFromApi').mockRejectedValue(
        new Error('unexpected boom'),
      );

      const result = await service.getAudiobookDetails('B000TEST');

      expect(result).toBeNull();
    });
  });

  // -------------------------------------------------------------------------
  // getRuntime()
  // -------------------------------------------------------------------------

  describe('getRuntime()', () => {
    it('returns runtime in minutes from Audnexus runtimeLengthMin', async () => {
      axiosMock.get.mockResolvedValue({ data: { runtimeLengthMin: '480' } });

      const service = new AudibleService();
      const runtime = await service.getRuntime('B000123456');

      expect(runtime).toBe(480);
    });

    it('returns null when Audnexus returns 404', async () => {
      axiosMock.get.mockRejectedValue({ response: { status: 404 }, message: 'Not found' });

      const service = new AudibleService();
      const runtime = await service.getRuntime('B000404404');

      expect(runtime).toBeNull();
    });

    it('returns null when Audnexus errors unexpectedly', async () => {
      axiosMock.get.mockRejectedValue({ response: { status: 500 }, message: 'Boom' });

      const service = new AudibleService();
      // Suppress retry delays so the test runs instantly.
      const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined);
      const runtime = await service.getRuntime('B000500500');

      expect(runtime).toBeNull();
      delaySpy.mockRestore();
    });
  });

  // -------------------------------------------------------------------------
  // fetch() public wrapper — must use htmlClient
  // -------------------------------------------------------------------------

  describe('fetch() public wrapper', () => {
    it('routes through htmlClient so audible-series.ts callers continue to work', async () => {
      htmlClientMock.get.mockResolvedValue({ data: '<html>test</html>' });

      const service = new AudibleService();
      await service.fetch('/some-path');

      expect(htmlClientMock.get).toHaveBeenCalledWith('/some-path', expect.anything());
      expect(apiClientMock.get).not.toHaveBeenCalled();
    });
  });
});
