import { describe, expect, it, vi } from 'vitest';
import { getRegistry } from '@jackwener/opencli/registry';
import { __test__ } from './search.js';

const { buildSearchQuery, resolveSearchFParam, resolveSearchProduct, buildSearchTimelineRequest, parseSearchTimeline, HAS_CHOICES, EXCLUDE_CHOICES, PRODUCT_CHOICES, EXCLUDE_TO_OPERATOR, PRODUCT_TO_F_PARAM, FROM_USER_PATTERN } = __test__;
describe('twitter search command', () => {
    // Mocked SearchTimeline operation metadata. The dynamic resolver
    // (resolveTwitterOperationMetadata) is exercised via page.evaluate's first
    // call. Returning a full {queryId, features, fieldToggles} validates the
    // happy path where bundle scan / GitHub fallback succeeds — not the stale
    // hardcoded path that previously masked the regex bug.
    const DYNAMIC_OP = {
        queryId: 'DynamicSearchQid42',
        features: { dynamic_test_feature: true },
        fieldToggles: { dynamic_test_toggle: true },
    };
    function makeSearchPage(data) {
        return {
            getCookies: vi.fn().mockResolvedValue([{ name: 'ct0', value: 'csrf' }]),
            goto: vi.fn().mockResolvedValue(undefined),
            evaluate: vi.fn()
                .mockResolvedValueOnce(DYNAMIC_OP) // resolveTwitterOperationMetadata dynamic result
                .mockResolvedValueOnce(data),
        };
    }

    it('fetches SearchTimeline directly instead of relying on SPA navigation', async () => {
        const command = getRegistry().get('twitter/search');
        expect(command?.func).toBeTypeOf('function');
        const page = makeSearchPage({
            data: {
                search_by_raw_query: {
                    search_timeline: {
                        timeline: {
                            instructions: [
                                {
                                    type: 'TimelineAddEntries',
                                    entries: [
                                        {
                                            entryId: 'tweet-1',
                                            content: {
                                                itemContent: {
                                                    tweet_results: {
                                                        result: {
                                                            rest_id: '1',
                                                            legacy: {
                                                                full_text: 'hello world',
                                                                favorite_count: 7,
                                                                created_at: 'Thu Mar 26 10:30:00 +0000 2026',
                                                            },
                                                            core: {
                                                                user_results: {
                                                                    result: {
                                                                        core: {
                                                                            screen_name: 'alice',
                                                                        },
                                                                        legacy: {
                                                                            description: 'Search author bio',
                                                                        },
                                                                    },
                                                                },
                                                            },
                                                            views: {
                                                                count: '12',
                                                            },
                                                        },
                                                    },
                                                },
                                            },
                                        },
                                    ],
                                },
                            ],
                        },
                    },
                },
            },
        });
        const result = await command.func(page, { query: 'from:alice', filter: 'top', limit: 5 });
        expect(result).toEqual([
            {
                id: '1',
                author: 'alice',
                bio: 'Search author bio',
                text: 'hello world',
                created_at: 'Thu Mar 26 10:30:00 +0000 2026',
                likes: 7,
                views: '12',
                url: 'https://x.com/i/status/1',
                has_media: false,
                media_urls: [],
                media_posters: [],
                card: null,
                quoted_tweet: null,
            },
        ]);
        expect(page.getCookies).toHaveBeenCalledWith({ url: 'https://x.com' });
        expect(page.goto).toHaveBeenCalledWith('https://x.com/home', { waitUntil: 'load', settleMs: 1000 });
        const searchFetch = page.evaluate.mock.calls[1][0];
        expect(searchFetch).toContain('/SearchTimeline');
        expect(searchFetch).toContain("method: 'POST'");
        expect(searchFetch).toContain('\\"rawQuery\\":\\"from:alice\\"');
        // Regression guard: the dynamic queryId from resolveTwitterOperationMetadata
        // must propagate to the actual GraphQL URL. Previously a bug in the bundle
        // parser would return a wrong queryId silently, Twitter would 4xx, and
        // search.js raised "queryId may have expired".
        expect(searchFetch).toContain('/DynamicSearchQid42/SearchTimeline');
    });

    it('uses the requested GraphQL product', async () => {
        const command = getRegistry().get('twitter/search');
        const page = makeSearchPage({ data: { search_by_raw_query: { search_timeline: { timeline: { instructions: [] } } } } });
        await command.func(page, { query: 'cats', product: 'videos', limit: 5 });
        expect(page.evaluate.mock.calls[1][0]).toContain('\\"product\\":\\"Videos\\"');
    });

    it('paginates past the old five-page cap until the requested limit is reached', async () => {
        const command = getRegistry().get('twitter/search');
        let pageIndex = 0;
        const page = {
            getCookies: vi.fn().mockResolvedValue([{ name: 'ct0', value: 'csrf' }]),
            goto: vi.fn().mockResolvedValue(undefined),
            evaluate: vi.fn().mockImplementation(async () => {
                if (pageIndex === 0) {
                    pageIndex += 1;
                    return null;
                }
                const id = String(pageIndex);
                pageIndex += 1;
                return {
                    data: {
                        search_by_raw_query: {
                            search_timeline: {
                                timeline: {
                                    instructions: [
                                        {
                                            entries: [
                                                {
                                                    content: {
                                                        itemContent: {
                                                            tweet_results: {
                                                                result: {
                                                                    rest_id: id,
                                                                    legacy: { full_text: `tweet ${id}`, created_at: 'now' },
                                                                    core: { user_results: { result: { core: { screen_name: 'alice' } } } },
                                                                },
                                                            },
                                                        },
                                                    },
                                                },
                                                {
                                                    content: {
                                                        entryType: 'TimelineTimelineCursor',
                                                        cursorType: 'Bottom',
                                                        value: `cursor-${id}`,
                                                    },
                                                },
                                            ],
                                        },
                                    ],
                                },
                            },
                        },
                    },
                };
            }),
        };
        const result = await command.func(page, { query: 'opencli', limit: 7 });
        expect(result).toHaveLength(7);
        expect(result.map((row) => row.id)).toEqual(['1', '2', '3', '4', '5', '6', '7']);
        expect(page.evaluate).toHaveBeenCalledTimes(8);
    });

    it('surfaces empty author when the tweet has no user screen_name', () => {
        const payload = {
            data: {
                search_by_raw_query: {
                    search_timeline: {
                        timeline: {
                            instructions: [{
                                type: 'TimelineAddEntries',
                                entries: [{
                                    entryId: 'tweet-2',
                                    content: {
                                        itemContent: {
                                            tweet_results: {
                                                result: {
                                                    rest_id: '2',
                                                    legacy: { full_text: 'no author here', favorite_count: 0, created_at: '' },
                                                    core: { user_results: { result: {} } },
                                                },
                                            },
                                        },
                                    },
                                }],
                            }],
                        },
                    },
                },
            },
        };
        const { rows } = parseSearchTimeline(payload, new Set());
        expect(rows).toHaveLength(1);
        expect(rows[0].author).toBe('');
        expect(rows[0].id).toBe('2');
    });
});

describe('twitter search filter helpers', () => {
    describe('buildSearchQuery', () => {
        it('returns the trimmed raw query when no filters are set', () => {
            expect(buildSearchQuery('  hello world  ', {})).toBe('hello world');
        });
        it('appends from: with leading @ stripped', () => {
            expect(buildSearchQuery('hello', { from: '@alice' })).toBe('hello from:alice');
        });
        it('preserves from: when caller passes a bare username', () => {
            expect(buildSearchQuery('hello', { from: 'alice' })).toBe('hello from:alice');
        });
        it('strips multiple leading @ characters from --from', () => {
            expect(buildSearchQuery('hi', { from: '@@bob' })).toBe('hi from:bob');
        });
        it('drops --from when it is whitespace-only', () => {
            expect(buildSearchQuery('hi', { from: '   ' })).toBe('hi');
        });
        it('rejects invalid --from usernames instead of injecting raw operators', () => {
            expect(() => buildSearchQuery('hi', { from: 'alice filter:links' })).toThrow(/Invalid --from/);
            expect(() => buildSearchQuery('hi', { from: 'alice/bob' })).toThrow(/Invalid --from/);
            expect(() => buildSearchQuery('hi', { from: '@' + 'a'.repeat(16) })).toThrow(/Invalid --from/);
        });
        it('appends filter:<has> for --has', () => {
            expect(buildSearchQuery('q', { has: 'images' })).toBe('q filter:images');
        });
        it('maps --exclude retweets to -filter:nativeretweets', () => {
            expect(buildSearchQuery('q', { exclude: 'retweets' })).toBe('q -filter:nativeretweets');
        });
        it('maps --exclude replies/media/links to their -filter operators', () => {
            expect(buildSearchQuery('q', { exclude: 'replies' })).toBe('q -filter:replies');
            expect(buildSearchQuery('q', { exclude: 'media' })).toBe('q -filter:media');
            expect(buildSearchQuery('q', { exclude: 'links' })).toBe('q -filter:links');
        });
        it('silently ignores unknown --exclude values', () => {
            // choices: ['replies','retweets','media','links'] — unknowns shouldn't appear
            // in real CLI use because the validator rejects them, but the helper still
            // guards via the EXCLUDE_TO_OPERATOR map lookup.
            expect(buildSearchQuery('q', { exclude: 'bogus' })).toBe('q');
        });
        it('composes multiple filter clauses in stable order: query → from → has → exclude', () => {
            expect(buildSearchQuery('hot take', {
                from: '@alice',
                has: 'media',
                exclude: 'retweets',
            })).toBe('hot take from:alice filter:media -filter:nativeretweets');
        });
        it('allows an empty raw query when filters are present', () => {
            expect(buildSearchQuery('', { from: 'alice' })).toBe('from:alice');
        });
        it('returns empty string when nothing useful is supplied', () => {
            expect(buildSearchQuery('', {})).toBe('');
            expect(buildSearchQuery('   ', {})).toBe('');
        });
        it('coerces nullish raw query into empty string', () => {
            expect(buildSearchQuery(null, { from: 'alice' })).toBe('from:alice');
            expect(buildSearchQuery(undefined, { from: 'alice' })).toBe('from:alice');
        });
    });

    describe('resolveSearchFParam', () => {
        it('defaults to top when neither product nor filter is set', () => {
            expect(resolveSearchFParam({})).toBe('top');
        });
        it('returns top when filter=top', () => {
            expect(resolveSearchFParam({ filter: 'top' })).toBe('top');
        });
        it('returns live when filter=live', () => {
            expect(resolveSearchFParam({ filter: 'live' })).toBe('live');
        });
        it('maps --product photos to image (Twitter URL singular form)', () => {
            expect(resolveSearchFParam({ product: 'photos' })).toBe('image');
        });
        it('maps --product videos to video (Twitter URL singular form)', () => {
            expect(resolveSearchFParam({ product: 'videos' })).toBe('video');
        });
        it('maps --product top|live straight through', () => {
            expect(resolveSearchFParam({ product: 'top' })).toBe('top');
            expect(resolveSearchFParam({ product: 'live' })).toBe('live');
        });
        it('lets --product win when both --product and --filter are set', () => {
            expect(resolveSearchFParam({ product: 'photos', filter: 'live' })).toBe('image');
            expect(resolveSearchFParam({ product: 'top', filter: 'live' })).toBe('top');
        });
        it('falls back to filter when --product is unknown', () => {
            // unknowns are blocked at the CLI validator layer; this is just defence
            expect(resolveSearchFParam({ product: 'bogus', filter: 'live' })).toBe('live');
            expect(resolveSearchFParam({ product: 'bogus' })).toBe('top');
        });
    });

    describe('choice surface', () => {
        it('exposes the documented HAS_CHOICES set', () => {
            expect(HAS_CHOICES).toEqual(['media', 'images', 'videos', 'links', 'replies']);
        });
        it('exposes the documented EXCLUDE_CHOICES set', () => {
            expect(EXCLUDE_CHOICES).toEqual(['replies', 'retweets', 'media', 'links']);
        });
        it('exposes the documented PRODUCT_CHOICES set', () => {
            expect(PRODUCT_CHOICES).toEqual(['top', 'live', 'photos', 'videos']);
        });
        it('keeps PRODUCT_TO_F_PARAM domain a strict subset of PRODUCT_CHOICES', () => {
            for (const choice of PRODUCT_CHOICES) {
                expect(PRODUCT_TO_F_PARAM[choice]).toBeTypeOf('string');
            }
        });
        it('keeps EXCLUDE_TO_OPERATOR domain a strict subset of EXCLUDE_CHOICES', () => {
            for (const choice of EXCLUDE_CHOICES) {
                expect(EXCLUDE_TO_OPERATOR[choice]).toMatch(/^-filter:/);
            }
        });
        it('keeps FROM_USER_PATTERN aligned with X handle syntax', () => {
            expect(FROM_USER_PATTERN.test('alice_123')).toBe(true);
            expect(FROM_USER_PATTERN.test('a'.repeat(15))).toBe(true);
            expect(FROM_USER_PATTERN.test('a'.repeat(16))).toBe(false);
            expect(FROM_USER_PATTERN.test('alice/bob')).toBe(false);
        });
    });
});

describe('twitter search end-to-end with new filters', () => {
    it('encodes the composed query and product=live into the GraphQL request', async () => {
        const command = getRegistry().get('twitter/search');
        const evaluate = vi.fn()
            .mockResolvedValueOnce(null)
            .mockResolvedValueOnce({ data: { search_by_raw_query: { search_timeline: { timeline: { instructions: [] } } } } });
        const page = {
            getCookies: vi.fn().mockResolvedValue([{ name: 'ct0', value: 'csrf' }]),
            goto: vi.fn().mockResolvedValue(undefined),
            evaluate,
        };
        await command.func(page, {
            query: 'breaking news',
            from: '@alice',
            has: 'images',
            exclude: 'retweets',
            product: 'live',
            limit: 5,
        });
        const searchFetch = evaluate.mock.calls[1][0];
        expect(searchFetch).toContain('\\"product\\":\\"Latest\\"');
        expect(searchFetch).toContain('\\"rawQuery\\":\\"breaking news from:alice filter:images -filter:nativeretweets\\"');
    });
    it('throws ArgumentError when query and all filters are empty', async () => {
        const command = getRegistry().get('twitter/search');
        const page = {
            goto: vi.fn().mockResolvedValue(undefined),
            evaluate: vi.fn(),
        };
        await expect(command.func(page, { query: '   ', limit: 5 }))
            .rejects
            .toThrow(/empty/i);
        expect(page.goto).not.toHaveBeenCalled();
    });
    it('throws ArgumentError for invalid --from before navigation', async () => {
        const command = getRegistry().get('twitter/search');
        const page = {
            goto: vi.fn(),
            evaluate: vi.fn(),
        };
        await expect(command.func(page, { query: 'hi', from: 'alice filter:links', limit: 5 }))
            .rejects
            .toThrow(/Invalid --from/);
        expect(page.goto).not.toHaveBeenCalled();
    });
    it('throws ArgumentError for invalid --limit before navigation', async () => {
        const command = getRegistry().get('twitter/search');
        const page = {
            goto: vi.fn(),
            evaluate: vi.fn(),
        };
        await expect(command.func(page, { query: 'hi', limit: 0 }))
            .rejects
            .toThrow(/--limit/);
        expect(page.goto).not.toHaveBeenCalled();
    });
    it('runs with only filters set (empty <query>)', async () => {
        const command = getRegistry().get('twitter/search');
        const evaluate = vi.fn()
            .mockResolvedValueOnce(null)
            .mockResolvedValueOnce({ data: { search_by_raw_query: { search_timeline: { timeline: { instructions: [] } } } } });
        const page = {
            getCookies: vi.fn().mockResolvedValue([{ name: 'ct0', value: 'csrf' }]),
            goto: vi.fn().mockResolvedValue(undefined),
            evaluate,
        };
        const result = await command.func(page, { query: '', from: 'alice', limit: 5 });
        expect(result).toEqual([]);
        const searchFetch = evaluate.mock.calls[1][0];
        expect(searchFetch).toContain('\\"rawQuery\\":\\"from:alice\\"');
    });

});
