import { describe, it, expect, vi } from 'vitest';
import {
    parseSemverTag,
    findNextTag,
    computeSemverBump,
    computeImagePreview,
    buildSummary,
    isMovingTag,
    listAllRegistryTagsBounded,
    isAuthoritativeNegativePreview,
    PREVIEW_TAG_LIST_MAX_PAGES,
    type ComputePreviewDeps,
    type LocalDigestInfo,
} from '../services/UpdatePreviewService';
import { selectLocalRepoDigests, type DigestComparisonResult, type ParsedRef, type TagListResult } from '../services/registry-api';

const PLATFORM = { os: 'linux', architecture: 'amd64' };

function localDigest(
    digest: string | null | string[],
    emptyReason: LocalDigestInfo['emptyReason'] = null,
): LocalDigestInfo {
    if (Array.isArray(digest)) return { digests: digest, platform: PLATFORM, emptyReason };
    if (digest) return { digests: [digest], platform: PLATFORM, emptyReason: null };
    return {
        digests: [],
        platform: PLATFORM,
        emptyReason: emptyReason ?? 'not_checkable',
    };
}

function tagsOk(tags: string[], nextCursor?: string): TagListResult {
    return nextCursor ? { ok: true, tags, nextCursor } : { ok: true, tags };
}

describe('parseSemverTag', () => {
    it('parses bare semver', () => {
        expect(parseSemverTag('1.2.3')).toMatchObject({ prefix: '', major: 1, minor: 2, patch: 3, suffix: '' });
    });
    it('parses v-prefixed semver', () => {
        expect(parseSemverTag('v1.2.3')).toMatchObject({ prefix: 'v', major: 1, minor: 2, patch: 3 });
    });
    it('parses suffixed semver (alpine, slim)', () => {
        expect(parseSemverTag('27.1.4-alpine')).toMatchObject({ major: 27, minor: 1, patch: 4, suffix: 'alpine' });
    });
    it('rejects non-semver', () => {
        expect(parseSemverTag('latest')).toBeNull();
        expect(parseSemverTag('main')).toBeNull();
        expect(parseSemverTag('1.2')).toBeNull();
    });
});

describe('isMovingTag', () => {
    it('treats fully-pinned semver as immutable', () => {
        expect(isMovingTag('1.2.3')).toBe(false);
        expect(isMovingTag('v1.2.3')).toBe(false);
        expect(isMovingTag('27.1.4-alpine')).toBe(false);
    });
    it('treats latest, branches, and unpinned major/minor as moving', () => {
        expect(isMovingTag('latest')).toBe(true);
        expect(isMovingTag('main')).toBe(true);
        expect(isMovingTag('stable')).toBe(true);
        expect(isMovingTag('1.25')).toBe(true);
        expect(isMovingTag('unknown')).toBe(true);
    });
});

describe('findNextTag', () => {
    it('picks highest semver greater than current', () => {
        const tags = ['27.1.3', '27.1.4', '27.1.5', '27.2.0', '27.1.5-alpine'];
        expect(findNextTag('27.1.4', tags)).toBe('27.2.0');
    });
    it('keeps prefix style (v vs bare)', () => {
        const tags = ['1.2.3', '1.2.4', 'v1.2.4', 'v1.3.0'];
        expect(findNextTag('v1.2.3', tags)).toBe('v1.3.0');
        expect(findNextTag('1.2.3', tags)).toBe('1.2.4');
    });
    it('keeps suffix style (alpine)', () => {
        const tags = ['1.2.3', '1.2.4', '1.2.3-alpine', '1.2.4-alpine'];
        expect(findNextTag('1.2.3-alpine', tags)).toBe('1.2.4-alpine');
    });
    it('returns null when current tag is not semver', () => {
        expect(findNextTag('latest', ['latest', '1.2.3'])).toBeNull();
    });
    it('returns null when no higher semver exists', () => {
        expect(findNextTag('1.2.3', ['1.2.0', '1.2.1', '1.2.2'])).toBeNull();
    });
});

describe('computeSemverBump', () => {
    it('detects major jump', () => {
        expect(computeSemverBump('1.2.3', '2.0.0')).toBe('major');
    });
    it('detects minor jump', () => {
        expect(computeSemverBump('1.2.3', '1.3.0')).toBe('minor');
    });
    it('detects patch jump', () => {
        expect(computeSemverBump('1.2.3', '1.2.4')).toBe('patch');
    });
    it('returns patch when tags are identical (digest rebuild)', () => {
        expect(computeSemverBump('latest', 'latest')).toBe('patch');
    });
    it('returns none when no next tag', () => {
        expect(computeSemverBump('1.2.3', null)).toBe('none');
    });
    it('returns unknown for non-semver pairs', () => {
        expect(computeSemverBump('main', 'stable')).toBe('unknown');
    });
});

function makeDeps(overrides: Partial<ComputePreviewDeps> = {}): ComputePreviewDeps {
    return {
        getCredentials: vi.fn().mockResolvedValue(null),
        getLocalDigest: vi.fn().mockResolvedValue(localDigest(null)),
        compareDigest: vi.fn().mockResolvedValue({ kind: 'error', reason: 'not configured' } satisfies DigestComparisonResult),
        listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk([])),
        ...overrides,
    };
}

describe('computeImagePreview', () => {
    it('reports no update when the comparison resolver matches and no higher tag exists', async () => {
        const deps = makeDeps({
            getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
            compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }),
            listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk(['1.2.3'])),
        });
        const result = await computeImagePreview('web', 'nginx:1.2.3', deps);
        expect(result.has_update).toBe(false);
        expect(result.semver_bump).toBe('none');
        expect(result.next_tag).toBeNull();
    });

    it('reports digest rebuild as patch when tag is unchanged but the resolver classifies an update', async () => {
        const deps = makeDeps({
            getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
            compareDigest: vi.fn().mockResolvedValue({ kind: 'update' }),
            listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk([])),
        });
        const result = await computeImagePreview('web', 'nginx:latest', deps);
        expect(result.has_update).toBe(true);
        expect(result.current_tag).toBe('latest');
        expect(result.next_tag).toBe('latest');
        expect(result.semver_bump).toBe('patch');
    });

    it('reports higher semver tag when available', async () => {
        const deps = makeDeps({
            getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
            compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }),
            listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk(['27.1.4', '27.1.5', '27.2.0'])),
        });
        const result = await computeImagePreview('engine', 'docker.io/library/docker:27.1.4', deps);
        expect(result.has_update).toBe(true);
        expect(result.next_tag).toBe('27.2.0');
        expect(result.semver_bump).toBe('minor');
    });

    it('flags major semver jumps', async () => {
        const deps = makeDeps({
            getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
            compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }),
            listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk(['1.2.3', '2.0.0'])),
        });
        const result = await computeImagePreview('db', 'postgres:1.2.3', deps);
        expect(result.next_tag).toBe('2.0.0');
        expect(result.semver_bump).toBe('major');
    });

    it('fails soft (no digest-based update) when the comparison resolver errors, but a higher tag still surfaces', async () => {
        const deps = makeDeps({
            getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
            compareDigest: vi.fn().mockResolvedValue({ kind: 'error', reason: 'Registry unreachable' }),
            listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk(['1.2.3', '1.2.4'])),
        });
        const result = await computeImagePreview('web', 'nginx:1.2.3', deps);
        expect(result.has_update).toBe(true);
        expect(result.next_tag).toBe('1.2.4');
        expect(result.semver_bump).toBe('patch');
        // A confirmed tag-based update overrides the digest hiccup: the overall
        // check_status resolves 'ok' and check_error is not surfaced (see the
        // "treats digest error + higher tag as a confirmed update" test below).
        expect(result.check_error).toBeNull();
    });

    it('surfaces verification failure when the comparison resolver errors and no higher tag exists', async () => {
        const deps = makeDeps({
            getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
            compareDigest: vi.fn().mockResolvedValue({ kind: 'error', reason: 'Registry unreachable' }),
            listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk([])),
        });
        const result = await computeImagePreview('web', 'nginx:1.2.3', deps);
        expect(result.has_update).toBe(false);
        expect(result.next_tag).toBeNull();
        expect(result.semver_bump).toBe('none');
        expect(result.check_error).toBe('Registry unreachable');
        expect(result.check_status).toBe('partial');
    });

    it('treats digest error + higher tag as a confirmed update (ok), but keeps digest_error unmasked for the collateral-risk gate', async () => {
        const result = await computeImagePreview('web', 'nginx:1.2.3', makeDeps({
            getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
            compareDigest: vi.fn().mockResolvedValue({ kind: 'error', reason: 'Registry unreachable' }),
            listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk(['1.2.3', '1.2.4'])),
        }));
        expect(result.has_update).toBe(true);
        expect(result.next_tag).toBe('1.2.4');
        expect(result.check_status).toBe('ok');
        // The tag compare independently confirmed the update, so check_status
        // masks the digest failure. digest_error must survive that masking: a
        // full-stack apply still re-pulls this image's unverified current tag.
        expect(result.digest_error).toBe('Registry unreachable');
    });

    it('treats empty RepoDigests as not_checkable (no verification failure)', async () => {
        const compareDigest = vi.fn().mockResolvedValue({ kind: 'update' });
        const deps = makeDeps({
            getLocalDigest: vi.fn().mockResolvedValue(localDigest(null, 'not_checkable')),
            compareDigest,
            listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk([])),
        });
        const result = await computeImagePreview('web', 'nginx:1.2.3', deps);
        expect(compareDigest).not.toHaveBeenCalled();
        expect(result.has_update).toBe(false);
        expect(result.check_error).toBeNull();
    });

    it('surfaces unresolved RepoDigests as verification failure without claiming an update', async () => {
        const compareDigest = vi.fn().mockResolvedValue({ kind: 'update' });
        const deps = makeDeps({
            getLocalDigest: vi.fn().mockResolvedValue(localDigest(null, 'unresolved')),
            compareDigest,
            listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk([])),
        });
        const result = await computeImagePreview('web', 'nginx:1.2.3', deps);
        expect(compareDigest).not.toHaveBeenCalled();
        expect(result.has_update).toBe(false);
        expect(result.check_error).toBe('Could not resolve a local registry digest');
    });

    it('wires a real unrelated-repository RepoDigest through selectLocalRepoDigests to an unresolved verification failure', async () => {
        // getLocalDigest here is not a canned stand-in: it runs the real
        // selectLocalRepoDigests against a RepoDigests array whose sole valid
        // entry belongs to a different repository, proving the removed
        // sole-unmatched-digest fallback stays removed through the actual
        // preview-computation path, not just at the registry-api unit level.
        const compareDigest = vi.fn().mockResolvedValue({ kind: 'update' });
        const deps = makeDeps({
            getLocalDigest: async (_imageRef: string, parsed: ParsedRef) => {
                const digests = selectLocalRepoDigests(
                    [`ghcr.io/other/image@sha256:${'b'.repeat(64)}`],
                    parsed,
                );
                return { digests, platform: PLATFORM, emptyReason: digests.length === 0 ? 'unresolved' as const : null };
            },
            compareDigest,
            listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk([])),
        });
        const result = await computeImagePreview('web', 'nginx:1.2.3', deps);
        expect(compareDigest).not.toHaveBeenCalled();
        expect(result.has_update).toBe(false);
        expect(result.check_error).toBe('Could not resolve a local registry digest');
    });

    it('surfaces inspect failure as verification failure', async () => {
        const deps = makeDeps({
            getLocalDigest: vi.fn().mockResolvedValue(localDigest(null, 'inspect_failed')),
            compareDigest: vi.fn(),
            listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk([])),
        });
        const result = await computeImagePreview('web', 'nginx:1.2.3', deps);
        expect(result.has_update).toBe(false);
        expect(result.check_error).toBe('Failed to inspect local image');
    });

    it('passes the local digest, tag, and platform through to the comparison resolver', async () => {
        const compareDigest = vi.fn().mockResolvedValue({ kind: 'match' });
        const deps = makeDeps({
            getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
            compareDigest,
            listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk([])),
        });
        await computeImagePreview('web', 'ghcr.io/linuxserver/radarr:latest', deps);
        expect(compareDigest).toHaveBeenCalledWith(['sha256:aaa'], 'ghcr.io', 'linuxserver/radarr', 'latest', PLATFORM, null);
    });

    it('forwards every local digest candidate and reports no same-tag rebuild on match', async () => {
        const STALE = `sha256:${'f'.repeat(64)}`;
        const CURRENT = `sha256:${'e'.repeat(64)}`;
        const compareDigest = vi.fn().mockResolvedValue({ kind: 'match' });
        const deps = makeDeps({
            getLocalDigest: vi.fn().mockResolvedValue(localDigest([STALE, CURRENT])),
            compareDigest,
            listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk([])),
        });
        const result = await computeImagePreview('broker', 'redis:8.8.0', deps);
        expect(compareDigest).toHaveBeenCalledWith(
            [STALE, CURRENT],
            'registry-1.docker.io',
            'library/redis',
            '8.8.0',
            PLATFORM,
            null,
        );
        expect(result).toMatchObject({
            has_update: false,
            next_tag: null,
            semver_bump: 'none',
        });
    });
});

describe('buildSummary', () => {
    const baseImage = (partial: Partial<Parameters<typeof buildSummary>[1][number]> = {}) => ({
        service: 'svc',
        image: 'nginx:1.0.0',
        current_tag: '1.0.0',
        next_tag: null,
        has_update: false,
        digest_update: false,
        tag_update: false,
        semver_bump: 'none' as const,
        check_status: 'ok' as const,
        check_error: null as string | null,
        digest_error: null as string | null,
        ...partial,
    });

    it('flags blocked when any image has a major bump', () => {
        const images = [
            baseImage({ service: 'web', has_update: true, semver_bump: 'major', next_tag: '2.0.0' }),
            baseImage({ service: 'cache', has_update: true, semver_bump: 'patch', next_tag: '1.0.1', image: 'redis:1.0.0' }),
        ];
        const preview = buildSummary('stacky', images);
        expect(preview.summary.blocked).toBe(true);
        expect(preview.summary.blocked_reason).toMatch(/major/i);
        expect(preview.summary.semver_bump).toBe('major');
        expect(preview.summary.verification_failed).toBe(false);
        expect(preview.summary.verification_error).toBeNull();
    });

    it('aggregates digest verification failure without claiming a digest rebuild', () => {
        const images = [
            baseImage({
                service: 'broker',
                image: 'redis:8.8.0',
                current_tag: '8.8.0',
                check_error: 'Local image platform is unknown; cannot verify multi-arch membership',
            }),
        ];
        const preview = buildSummary('app', images);
        expect(preview.summary.has_update).toBe(false);
        expect(preview.summary.update_kind).toBe('none');
        expect(preview.summary.verification_failed).toBe(true);
        expect(preview.summary.verification_error).toContain('cannot verify multi-arch membership');
    });

    it('keeps a tag update when digest verification failed on the same image', () => {
        const images = [
            baseImage({
                service: 'web',
                has_update: true,
                semver_bump: 'patch',
                next_tag: '1.0.1',
                check_error: 'Registry unreachable',
            }),
        ];
        const preview = buildSummary('app', images);
        expect(preview.summary.has_update).toBe(true);
        expect(preview.summary.update_kind).toBe('tag');
        expect(preview.summary.verification_failed).toBe(true);
        expect(preview.summary.verification_error).toBe('Registry unreachable');
    });

    it('picks first updated image as primary', () => {
        const images = [
            baseImage({ service: 'clean', has_update: false }),
            baseImage({ service: 'web', has_update: true, semver_bump: 'minor', next_tag: '1.1.0', image: 'nginx:1.0.0' }),
        ];
        const preview = buildSummary('stacky', images);
        expect(preview.summary.primary_image).toBe('nginx:1.0.0');
        expect(preview.summary.next_tag).toBe('1.1.0');
        expect(preview.summary.blocked).toBe(false);
    });

    it('returns has_update=false when no images update', () => {
        const images = [baseImage({ service: 'clean', has_update: false })];
        const preview = buildSummary('stacky', images);
        expect(preview.summary.has_update).toBe(false);
        expect(preview.summary.semver_bump).toBe('none');
    });

    it('handles empty image list', () => {
        const preview = buildSummary('empty', []);
        expect(preview.summary.has_update).toBe(false);
        expect(preview.summary.primary_image).toBeNull();
        expect(preview.rollback_target).toBeNull();
        expect(preview.summary.has_build_services).toBe(false);
        expect(preview.summary.rebuild_available).toBe(false);
    });

    it('flags rebuild_available for build-only stacks', () => {
        const preview = buildSummary('build-stack', [], ['app']);
        expect(preview.build_services).toEqual(['app']);
        expect(preview.summary.has_update).toBe(false);
        expect(preview.summary.has_build_services).toBe(true);
        expect(preview.summary.rebuild_available).toBe(true);
    });

    it('supports mixed image and build services', () => {
        const images = [
            baseImage({ service: 'web', has_update: true, semver_bump: 'patch', next_tag: '1.0.1', current_tag: '1.0.0' }),
        ];
        const preview = buildSummary('mixed', images, ['worker']);
        expect(preview.summary.has_update).toBe(true);
        expect(preview.summary.has_build_services).toBe(true);
        expect(preview.summary.rebuild_available).toBe(true);
    });

    it('computes rollback target from current tag of primary', () => {
        const images = [
            baseImage({ service: 'web', image: 'nginx:1.0.0', has_update: true, semver_bump: 'patch', next_tag: '1.0.1', current_tag: '1.0.0' }),
        ];
        const preview = buildSummary('stacky', images);
        expect(preview.rollback_target).toBe('nginx:1.0.0');
    });

    it('computes rollback target for Docker Hub library image', () => {
        const images = [
            baseImage({ service: 'db', image: 'library/postgres:16', has_update: true, semver_bump: 'patch', next_tag: '16', current_tag: '16' }),
        ];
        expect(buildSummary('stacky', images).rollback_target).toBe('postgres:16');
    });

    it('computes rollback target for an explicit docker.io/ ref the same as the bare form', () => {
        // docker.io is a Docker Hub alias normalized by parseImageRef; the rollback target
        // must render the same shortened form as the bare-name case above, not the literal host.
        const images = [
            baseImage({ service: 'db', image: 'docker.io/library/postgres:16', has_update: true, semver_bump: 'patch', next_tag: '16', current_tag: '16' }),
        ];
        expect(buildSummary('stacky', images).rollback_target).toBe('postgres:16');
    });

    it('computes rollback target for registry with port', () => {
        const images = [
            baseImage({
                service: 'app',
                image: 'registry.example.com:5000/team/image:1.2.3',
                has_update: true,
                semver_bump: 'patch',
                next_tag: '1.2.4',
                current_tag: '1.2.3',
            }),
        ];
        expect(buildSummary('stacky', images).rollback_target).toBe('registry.example.com:5000/team/image:1.2.3');
    });

    it('leaves blocked false for patch/minor only updates', () => {
        const images = [
            baseImage({ service: 'web', has_update: true, semver_bump: 'patch', next_tag: '1.0.1' }),
            baseImage({ service: 'cache', has_update: true, semver_bump: 'minor', next_tag: '1.1.0', image: 'redis:1.0.0' }),
        ];
        const preview = buildSummary('stacky', images);
        expect(preview.summary.blocked).toBe(false);
        expect(preview.summary.blocked_reason).toBeNull();
        expect(preview.summary.semver_bump).toBe('minor');
    });

    it('does not let unknown bumps mask a real major bump', () => {
        const images = [
            baseImage({ service: 'odd', has_update: true, semver_bump: 'unknown', next_tag: 'main', image: 'ghcr.io/org/odd:main' }),
            baseImage({ service: 'db', has_update: true, semver_bump: 'major', next_tag: '2.0.0', image: 'postgres:1.0.0' }),
        ];
        const preview = buildSummary('stacky', images);
        expect(preview.summary.semver_bump).toBe('major');
        expect(preview.summary.blocked).toBe(true);
    });

    it('reports update_kind="tag" when at least one image has a strictly newer tag', () => {
        const images = [
            baseImage({ service: 'web', has_update: true, semver_bump: 'patch', next_tag: '1.0.1', current_tag: '1.0.0' }),
        ];
        expect(buildSummary('stacky', images).summary.update_kind).toBe('tag');
    });

    it('reports update_kind="digest" when only same-tag rebuilds are available', () => {
        const images = [
            baseImage({ service: 'web', has_update: true, semver_bump: 'patch', next_tag: '10.11', current_tag: '10.11', image: 'redis:10.11' }),
        ];
        expect(buildSummary('stacky', images).summary.update_kind).toBe('digest');
    });

    it('reports update_kind="none" when nothing has an update', () => {
        const images = [baseImage({ service: 'clean', has_update: false })];
        expect(buildSummary('stacky', images).summary.update_kind).toBe('none');
    });

    it('sets check_status=ok for empty and all-ok images', () => {
        expect(buildSummary('empty', []).summary.check_status).toBe('ok');
        expect(buildSummary('ok', [baseImage({ check_status: 'ok' })]).summary.check_status).toBe('ok');
    });

    it('rolls up mixed and failed check_status', () => {
        expect(buildSummary('mixed', [
            baseImage({ service: 'a', check_status: 'ok' }),
            baseImage({ service: 'b', check_status: 'partial' }),
        ]).summary.check_status).toBe('partial');
        expect(buildSummary('fail', [
            baseImage({ service: 'a', check_status: 'failed' }),
            baseImage({ service: 'b', check_status: 'failed' }),
        ]).summary.check_status).toBe('failed');
    });
});

describe('preview authority', () => {
    it('marks digest match + exhausted empty tags as authoritative ok with no update', async () => {
        const result = await computeImagePreview('web', 'nginx:1.2.3', makeDeps({
            getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
            compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }),
            listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk(['1.2.3'])),
        }));
        expect(result.has_update).toBe(false);
        expect(result.check_status).toBe('ok');
        expect(isAuthoritativeNegativePreview(buildSummary('s', [result]))).toBe(true);
    });

    it('marks digest error + successful tag list with no next as partial (not authoritative-negative)', async () => {
        const result = await computeImagePreview('web', 'nginx:1.2.3', makeDeps({
            getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
            compareDigest: vi.fn().mockResolvedValue({ kind: 'error', reason: 'boom' }),
            listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk(['1.2.3'])),
        }));
        expect(result.has_update).toBe(false);
        expect(result.check_status).toBe('partial');
        expect(isAuthoritativeNegativePreview(buildSummary('s', [result]))).toBe(false);
    });

    it('does not treat empty or not_checkable-only previews as authoritative-negative', () => {
        expect(isAuthoritativeNegativePreview(buildSummary('empty', []))).toBe(false);
        expect(isAuthoritativeNegativePreview(buildSummary('build', [
            {
                service: 'app',
                image: 'sha256:dead',
                current_tag: 'unknown',
                next_tag: null,
                has_update: false,
                digest_update: false,
                tag_update: false,
                semver_bump: 'none',
                check_status: 'not_checkable',
                check_error: null,
                digest_error: null,
            },
        ]))).toBe(false);
    });

    it('marks digest match + tag list failure as partial for semver tags', async () => {
        const result = await computeImagePreview('web', 'nginx:1.2.3', makeDeps({
            getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
            compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }),
            listRegistryTagsResult: vi.fn().mockResolvedValue({
                ok: false,
                code: 'REGISTRY_UPSTREAM',
                message: 'Registry unreachable',
            }),
        }));
        expect(result.has_update).toBe(false);
        expect(result.check_status).toBe('partial');
    });

    it('allows moving/non-semver tags to be authoritative-negative on digest match without tag enum', async () => {
        const listFn = vi.fn();
        const result = await computeImagePreview('web', 'nginx:latest', makeDeps({
            getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
            compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }),
            listRegistryTagsResult: listFn,
        }));
        expect(listFn).not.toHaveBeenCalled();
        expect(result.has_update).toBe(false);
        expect(result.check_status).toBe('ok');
        expect(isAuthoritativeNegativePreview(buildSummary('s', [result]))).toBe(true);
    });

    it('detects a newer tag found on a later page', async () => {
        const listFn = vi.fn()
            .mockResolvedValueOnce(tagsOk(['1.0.0', '1.0.1'], 'cursor-1'))
            .mockResolvedValueOnce(tagsOk(['1.1.0']));
        const result = await computeImagePreview('web', 'nginx:1.0.0', makeDeps({
            getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
            compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }),
            listRegistryTagsResult: listFn,
        }));
        expect(listFn).toHaveBeenCalledTimes(2);
        expect(result.has_update).toBe(true);
        expect(result.next_tag).toBe('1.1.0');
        expect(result.check_status).toBe('ok');
    });

    it('treats page-cap with remaining cursor as non-authoritative for semver negatives', async () => {
        let page = 0;
        const listFn = vi.fn().mockImplementation(async () => {
            page += 1;
            return tagsOk([`1.0.${page}`], `cursor-${page}`);
        });
        const result = await computeImagePreview('web', 'nginx:2.0.0', makeDeps({
            getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
            compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }),
            listRegistryTagsResult: listFn,
        }));
        expect(listFn).toHaveBeenCalledTimes(PREVIEW_TAG_LIST_MAX_PAGES);
        expect(result.has_update).toBe(false);
        expect(result.check_status).toBe('partial');
        expect(isAuthoritativeNegativePreview(buildSummary('s', [result]))).toBe(false);
    });

    it('marks invalid refs as not_checkable', async () => {
        const result = await computeImagePreview('web', 'sha256:deadbeef', makeDeps());
        expect(result.check_status).toBe('not_checkable');
        expect(result.has_update).toBe(false);
    });
});

describe('listAllRegistryTagsBounded', () => {
    it('returns incomplete when nextCursor remains after the page cap', async () => {
        const listFn = vi.fn().mockResolvedValue(tagsOk(['a'], 'more'));
        const outcome = await listAllRegistryTagsBounded(listFn, 'ghcr.io', 'acme/app', null, { maxPages: 2 });
        expect(outcome.kind).toBe('incomplete');
        expect(listFn).toHaveBeenCalledTimes(2);
    });

    it('returns complete when pagination exhausts', async () => {
        const listFn = vi.fn()
            .mockResolvedValueOnce(tagsOk(['a'], 'c1'))
            .mockResolvedValueOnce(tagsOk(['b']));
        const outcome = await listAllRegistryTagsBounded(listFn, 'ghcr.io', 'acme/app', null);
        expect(outcome).toEqual({ kind: 'complete', tags: ['a', 'b'] });
    });
});
