import pytest
from sqlalchemy import func, select

from backend.app.models import ImageRecord, TagKind
from backend.app.models.tag import image_tag
from backend.app.models.tag_suggestion_rejection import TagSuggestionRejection
from backend.app.services.bulk_tag_service import BulkTagService
from backend.app.services.tag_service import TagService

pytestmark = pytest.mark.integration

_SEQ = 0


async def _img(db):
    global _SEQ
    _SEQ += 1
    rec = ImageRecord(
        path=f"/tmp/fc_bulk_{_SEQ}.png",
        sha256=f"{_SEQ:064d}",
        size_bytes=1,
        mime="image/png",
        origin="uploaded",
    )
    db.add(rec)
    await db.flush()
    return rec.id


@pytest.mark.asyncio
async def test_common_tags_only_returns_tags_on_every_image(db):
    tags = TagService(db)
    t_all = await tags.find_or_create("OnAll", TagKind.general)
    t_some = await tags.find_or_create("OnSome", TagKind.general)
    i1, i2 = await _img(db), await _img(db)
    await tags.add_to_image(i1, t_all.id)
    await tags.add_to_image(i2, t_all.id)
    await tags.add_to_image(i1, t_some.id)  # only one image
    svc = BulkTagService(db)
    result = await svc.common_tags([i1, i2])
    assert [t["id"] for t in result] == [t_all.id]
    assert result[0]["name"] == "OnAll"
    assert result[0]["kind"] == "general"


@pytest.mark.asyncio
async def test_common_tags_empty_when_no_images(db):
    svc = BulkTagService(db)
    assert await svc.common_tags([]) == []


@pytest.mark.asyncio
async def test_bulk_add_applies_and_counts_idempotent(db):
    tags = TagService(db)
    tag = await tags.find_or_create("BulkAdd", TagKind.general)
    i1, i2 = await _img(db), await _img(db)
    svc = BulkTagService(db)
    added = await svc.bulk_add([i1, i2], tag.id)
    assert added == 2
    # Idempotent: re-adding adds nothing.
    again = await svc.bulk_add([i1, i2], tag.id)
    assert again == 0
    rows = (
        await db.execute(
            select(image_tag.c.image_record_id, image_tag.c.source).where(
                image_tag.c.tag_id == tag.id
            )
        )
    ).all()
    assert {r.image_record_id for r in rows} == {i1, i2}
    assert all(r.source == "manual" for r in rows)


@pytest.mark.asyncio
async def test_bulk_add_records_explicit_source(db):
    tags = TagService(db)
    tag = await tags.find_or_create("BulkAddML", TagKind.general)
    i1 = await _img(db)
    svc = BulkTagService(db)
    await svc.bulk_add([i1], tag.id, source="ml_accepted")
    src = await db.scalar(
        select(image_tag.c.source).where(image_tag.c.tag_id == tag.id)
    )
    assert src == "ml_accepted"


@pytest.mark.asyncio
async def test_bulk_remove_counts_and_records_rejections(db):
    tags = TagService(db)
    tag = await tags.find_or_create("BulkRem", TagKind.general)
    i1, i2 = await _img(db), await _img(db)
    svc = BulkTagService(db)
    await svc.bulk_add([i1, i2], tag.id)
    removed = await svc.bulk_remove([i1, i2], tag.id)
    assert removed == 2
    remaining = await db.scalar(
        select(func.count())
        .select_from(image_tag)
        .where(image_tag.c.tag_id == tag.id)
    )
    assert remaining == 0
    rej = (
        await db.execute(
            select(TagSuggestionRejection.image_record_id).where(
                TagSuggestionRejection.tag_id == tag.id
            )
        )
    ).scalars().all()
    assert set(rej) == {i1, i2}


@pytest.mark.asyncio
async def test_bulk_remove_rejection_is_idempotent(db):
    tags = TagService(db)
    tag = await tags.find_or_create("BulkRem2", TagKind.general)
    i1 = await _img(db)
    svc = BulkTagService(db)
    await svc.bulk_add([i1], tag.id)
    await svc.bulk_remove([i1], tag.id)
    # Second remove: nothing to delete, rejection already present, no error.
    removed = await svc.bulk_remove([i1], tag.id)
    assert removed == 0


@pytest.mark.asyncio
async def test_tag_gaps_finds_consensus_excludes_common(db):
    tags = TagService(db)
    gap = await tags.find_or_create("Miku", TagKind.character)
    common = await tags.find_or_create("Solo", TagKind.general)
    imgs = [await _img(db) for _ in range(5)]
    for i in imgs:
        await tags.add_to_image(i, common.id)   # on all 5 → not a gap
    for i in imgs[:4]:
        await tags.add_to_image(i, gap.id)       # on 4/5 → a gap
    svc = BulkTagService(db)
    gaps = await svc.tag_gaps(imgs, threshold=0.6)
    assert [g["tag_id"] for g in gaps] == [gap.id]   # the common tag is excluded
    g = gaps[0]
    assert g["present_count"] == 4
    assert g["total"] == 5
    assert g["missing_image_ids"] == [imgs[4]]


@pytest.mark.asyncio
async def test_tag_gaps_excludes_rejected_laggard(db):
    tags = TagService(db)
    gap = await tags.find_or_create("Rin", TagKind.character)
    imgs = [await _img(db) for _ in range(6)]
    for i in imgs[:4]:
        await tags.add_to_image(i, gap.id)       # on 4/6 (min_present=ceil(3.6)=4)
    # imgs[4], imgs[5] lack it; imgs[4] explicitly rejected it.
    db.add(TagSuggestionRejection(image_record_id=imgs[4], tag_id=gap.id))
    await db.flush()
    svc = BulkTagService(db)
    gaps = await svc.tag_gaps(imgs, threshold=0.6)
    g = next(x for x in gaps if x["tag_id"] == gap.id)
    assert g["missing_image_ids"] == [imgs[5]]   # rejected laggard excluded


@pytest.mark.asyncio
async def test_tag_gaps_drops_tag_when_all_laggards_rejected(db):
    tags = TagService(db)
    gap = await tags.find_or_create("Len", TagKind.character)
    imgs = [await _img(db) for _ in range(5)]
    for i in imgs[:4]:
        await tags.add_to_image(i, gap.id)       # on 4/5, sole laggard imgs[4]
    db.add(TagSuggestionRejection(image_record_id=imgs[4], tag_id=gap.id))
    await db.flush()
    svc = BulkTagService(db)
    gaps = await svc.tag_gaps(imgs, threshold=0.6)
    assert all(g["tag_id"] != gap.id for g in gaps)  # nothing to apply → dropped


@pytest.mark.asyncio
async def test_tag_gaps_needs_at_least_two_images(db):
    svc = BulkTagService(db)
    assert await svc.tag_gaps([], 0.6) == []
    assert await svc.tag_gaps([await _img(db)], 0.6) == []
