import pytest
from sqlalchemy import func, select

from backend.app.models import Tag, TagKind, image_tag
from backend.app.services.tag_service import (
    MergeResult,
    TagMergeConflict,
    TagService,
    TagValidationError,
)

pytestmark = pytest.mark.integration

_IMG_SEQ = 0


async def _img(db):
    """Insert a minimal valid ImageRecord (all NOT NULL cols satisfied:
    path, sha256, size_bytes, mime, origin) and return its id."""
    global _IMG_SEQ
    _IMG_SEQ += 1
    from backend.app.models import ImageRecord

    rec = ImageRecord(
        path=f"/tmp/fc_merge_{_IMG_SEQ}.png",
        sha256=f"{_IMG_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_rename_collision_raises_merge_conflict_with_payload(db):
    svc = TagService(db)
    target = await svc.find_or_create("Existing", TagKind.character)
    source = await svc.find_or_create("Dupe", TagKind.character)
    img = await _img(db)
    await svc.add_to_image(img, source.id, source="manual")

    with pytest.raises(TagMergeConflict) as ei:
        await svc.rename(source.id, "Existing")
    exc = ei.value
    assert exc.target_id == target.id
    assert exc.target_name == "Existing"
    assert exc.source_image_count == 1
    assert exc.will_alias is False  # purely manual, ML-unknown


@pytest.mark.asyncio
async def test_merge_conflict_is_a_tag_validation_error(db):
    assert issubclass(TagMergeConflict, TagValidationError)


@pytest.mark.asyncio
async def test_merge_preview_matches_apply(db):
    # #8 rule-93 parity: the preview's moving-count is exactly what the apply
    # moves, and already-on-target links are the ones dropped.
    svc = TagService(db)
    target = await svc.find_or_create("MTarget", TagKind.general)
    source = await svc.find_or_create("MSource", TagKind.general)
    a, b, c = await _img(db), await _img(db), await _img(db)
    for img in (a, b, c):
        await svc.add_to_image(img, source.id, source="manual")
    await svc.add_to_image(a, target.id, source="manual")  # a already on target

    preview = await svc.merge_preview(source.id, target.id)
    assert preview.compatible is True
    assert preview.images_moving == 2              # b, c
    assert preview.images_already_on_target == 1   # a
    assert preview.source_total == 3
    assert preview.will_alias is False             # purely manual

    result = await svc.merge(source.id, target.id)
    assert result.merged_count == preview.images_moving  # parity


@pytest.mark.asyncio
async def test_merge_preview_flags_incompatible_without_raising(db):
    svc = TagService(db)
    target = await svc.find_or_create("CharT", TagKind.character)
    source = await svc.find_or_create("GenS", TagKind.general)
    preview = await svc.merge_preview(source.id, target.id)
    assert preview.compatible is False  # kind mismatch surfaced, not raised


@pytest.mark.asyncio
async def test_merge_preview_self_and_missing_raise(db):
    svc = TagService(db)
    t = await svc.find_or_create("Solo", TagKind.general)
    with pytest.raises(TagValidationError):
        await svc.merge_preview(t.id, t.id)
    with pytest.raises(TagValidationError):
        await svc.merge_preview(t.id, 999999)


@pytest.mark.asyncio
async def test_will_alias_true_when_machine_sourced(db):
    svc = TagService(db)
    await svc.find_or_create("Canon", TagKind.general)
    source = await svc.find_or_create("Auto", TagKind.general)
    img = await _img(db)
    await svc.add_to_image(img, source.id, source="ml_auto")
    with pytest.raises(TagMergeConflict) as ei:
        await svc.rename(source.id, "Canon")
    assert ei.value.will_alias is True


@pytest.mark.asyncio
async def test_merge_rejects_self_merge(db):
    svc = TagService(db)
    t = await svc.find_or_create("Solo", TagKind.general)
    with pytest.raises(TagValidationError, match="into itself"):
        await svc.merge(t.id, t.id)


@pytest.mark.asyncio
async def test_merge_rejects_missing_tag(db):
    svc = TagService(db)
    t = await svc.find_or_create("Real", TagKind.general)
    with pytest.raises(TagValidationError, match="not found"):
        await svc.merge(t.id, 999999)


@pytest.mark.asyncio
async def test_merge_rejects_cross_kind(db):
    svc = TagService(db)
    a = await svc.find_or_create("NameA", TagKind.general)
    b = await svc.find_or_create("NameB", TagKind.artist)
    with pytest.raises(TagValidationError, match="same kind"):
        await svc.merge(a.id, b.id)


@pytest.mark.asyncio
async def test_merge_rejects_cross_fandom(db):
    svc = TagService(db)
    f1 = await svc.find_or_create("Fandom1", TagKind.fandom)
    f2 = await svc.find_or_create("Fandom2", TagKind.fandom)
    c1 = await svc.find_or_create("Char", TagKind.character, fandom_id=f1.id)
    c2 = await svc.find_or_create("Char2", TagKind.character, fandom_id=f2.id)
    with pytest.raises(TagValidationError, match="same kind and fandom"):
        await svc.merge(c1.id, c2.id)


@pytest.mark.asyncio
async def test_merge_empty_tags_returns_result(db):
    svc = TagService(db)
    a = await svc.find_or_create("EmptyA", TagKind.general)
    b = await svc.find_or_create("EmptyB", TagKind.general)
    result = await svc.merge(a.id, b.id)
    assert isinstance(result, MergeResult)
    assert result.target_id == b.id
    assert result.target_name == "EmptyB"
    assert result.target_kind == "general"
    assert result.merged_count == 0
    assert result.alias_created is False
    assert result.source_deleted is True
    assert await db.get(Tag, a.id) is None


@pytest.mark.asyncio
async def test_merge_moves_image_tags_and_counts(db):
    svc = TagService(db)
    a = await svc.find_or_create("SrcIT", TagKind.general)
    b = await svc.find_or_create("TgtIT", TagKind.general)
    i1, i2 = await _img(db), await _img(db)
    await svc.add_to_image(i1, a.id, source="manual")
    await svc.add_to_image(i2, a.id, source="manual")
    result = await svc.merge(a.id, b.id)
    assert result.merged_count == 2
    rows = (
        await db.execute(
            image_tag.select().where(image_tag.c.tag_id == b.id)
        )
    ).all()
    assert {r.image_record_id for r in rows} == {i1, i2}
    assert (
        await db.execute(
            image_tag.select().where(image_tag.c.tag_id == a.id)
        )
    ).first() is None


@pytest.mark.asyncio
async def test_merge_dedups_image_tag_when_image_has_both(db):
    svc = TagService(db)
    a = await svc.find_or_create("SrcDup", TagKind.general)
    b = await svc.find_or_create("TgtDup", TagKind.general)
    img = await _img(db)
    await svc.add_to_image(img, a.id, source="manual")
    await svc.add_to_image(img, b.id, source="manual")
    result = await svc.merge(a.id, b.id)
    # The image already had the target → that source row is dropped, not moved.
    assert result.merged_count == 0
    rows = (
        await db.execute(
            image_tag.select().where(
                image_tag.c.image_record_id == img
            )
        )
    ).all()
    assert [r.tag_id for r in rows] == [b.id]


@pytest.mark.asyncio
async def test_merge_dedups_suggestion_rejections(db):
    from backend.app.models.tag_suggestion_rejection import (
        TagSuggestionRejection,
    )

    svc = TagService(db)
    a = await svc.find_or_create("SrcRej", TagKind.general)
    b = await svc.find_or_create("TgtRej", TagKind.general)
    i1, i2 = await _img(db), await _img(db)
    db.add(TagSuggestionRejection(image_record_id=i1, tag_id=a.id))
    db.add(TagSuggestionRejection(image_record_id=i2, tag_id=a.id))
    db.add(TagSuggestionRejection(image_record_id=i1, tag_id=b.id))
    await db.flush()
    await svc.merge(a.id, b.id)
    rej = (
        await db.execute(
            select(TagSuggestionRejection).where(
                TagSuggestionRejection.tag_id == b.id
            )
        )
    ).scalars().all()
    assert {r.image_record_id for r in rej} == {i1, i2}
    assert (
        await db.execute(
            select(TagSuggestionRejection).where(
                TagSuggestionRejection.tag_id == a.id
            )
        )
    ).first() is None


@pytest.mark.asyncio
async def test_merge_repoints_existing_aliases(db):
    from backend.app.models.tag_alias import TagAlias

    svc = TagService(db)
    a = await svc.find_or_create("SrcAlias", TagKind.general)
    b = await svc.find_or_create("TgtAlias", TagKind.general)
    db.add(
        TagAlias(
            alias_string="oldpred",
            alias_category="general",
            canonical_tag_id=a.id,
        )
    )
    await db.flush()
    await svc.merge(a.id, b.id)
    # Read the column value, not the entity: merge() uses Core DML, so an
    # ORM instance would be stale and a lazy reload here would emit sync IO
    # outside the async greenlet (MissingGreenlet).
    cid = await db.scalar(
        select(TagAlias.canonical_tag_id).where(
            TagAlias.alias_string == "oldpred"
        )
    )
    assert cid == b.id


@pytest.mark.asyncio
async def test_merge_fandom_reparents_children(db):
    svc = TagService(db)
    f_src = await svc.find_or_create("FandomSrc", TagKind.fandom)
    f_tgt = await svc.find_or_create("FandomTgt", TagKind.fandom)
    child = await svc.find_or_create(
        "ChildChar", TagKind.character, fandom_id=f_src.id
    )
    await svc.merge(f_src.id, f_tgt.id)
    fid = await db.scalar(select(Tag.fandom_id).where(Tag.id == child.id))
    assert fid == f_tgt.id


@pytest.mark.asyncio
async def test_no_alias_when_purely_manual(db):
    from backend.app.models.tag_alias import TagAlias

    svc = TagService(db)
    a = await svc.find_or_create("ManualOnly", TagKind.general)
    b = await svc.find_or_create("CanonM", TagKind.general)
    img = await _img(db)
    await svc.add_to_image(img, a.id, source="manual")
    result = await svc.merge(a.id, b.id)
    assert result.alias_created is False
    assert (await db.execute(select(TagAlias))).first() is None


@pytest.mark.asyncio
async def test_protective_alias_uses_tag_kind(db):
    # The protective alias category is the tag's KIND — the tagger maps each name
    # to exactly one category and a tag's kind is set from it, so kind already IS
    # the tagger's category. The merge no longer scans image_record's predictions
    # to rediscover it — it writes a single (name, kind) alias from the tag kind.
    from backend.app.models.tag_alias import TagAlias

    svc = TagService(db)
    a = await svc.find_or_create("predname", TagKind.general)
    b = await svc.find_or_create("CanonP", TagKind.general)
    img = await _img(db)
    # mark source machine-known so keep_as_alias is True
    await svc.add_to_image(img, a.id, source="ml_auto")
    await db.flush()
    result = await svc.merge(a.id, b.id)
    assert result.alias_created is True
    rows = (
        await db.execute(
            select(
                TagAlias.alias_string,
                TagAlias.alias_category,
                TagAlias.canonical_tag_id,
            )
        )
    ).all()
    assert {(r.alias_string, r.alias_category) for r in rows} == {
        ("predname", "general"),
    }
    assert all(r.canonical_tag_id == b.id for r in rows)


@pytest.mark.asyncio
async def test_alias_fallback_to_kind_when_no_predictions(db):
    from backend.app.models.tag_alias import TagAlias

    svc = TagService(db)
    a = await svc.find_or_create("AllowNoPred", TagKind.character)
    b = await svc.find_or_create("CanonF", TagKind.character)
    # Machine-known via a prior accept (source='ml_accepted') → kept as alias.
    img = await _img(db)
    await svc.add_to_image(img, a.id, source="ml_accepted")
    await db.flush()
    result = await svc.merge(a.id, b.id)
    assert result.alias_created is True
    row = (
        await db.execute(
            select(
                TagAlias.alias_category, TagAlias.canonical_tag_id
            ).where(TagAlias.alias_string == "AllowNoPred")
        )
    ).one()
    assert row.alias_category == "character"
    assert row.canonical_tag_id == b.id


@pytest.mark.asyncio
async def test_alias_create_does_not_clobber_existing(db):
    from backend.app.models.tag_alias import TagAlias

    svc = TagService(db)
    a = await svc.find_or_create("dupalias", TagKind.general)
    b = await svc.find_or_create("CanonD", TagKind.general)
    other = await svc.find_or_create("OtherCanon", TagKind.general)
    db.add(
        TagAlias(
            alias_string="dupalias",
            alias_category="general",
            canonical_tag_id=other.id,
        )
    )
    img = await _img(db)
    await svc.add_to_image(img, a.id, source="ml_auto")
    await db.flush()
    await svc.merge(a.id, b.id)
    cid = await db.scalar(
        select(TagAlias.canonical_tag_id).where(
            TagAlias.alias_string == "dupalias"
        )
    )
    assert cid == other.id  # pre-existing alias untouched


@pytest.mark.asyncio
async def test_merge_repoints_series_pages(db):
    from backend.app.models.series_chapter import SeriesChapter
    from backend.app.models.series_page import SeriesPage

    svc = TagService(db)
    a = await svc.find_or_create("SerA", TagKind.series)
    b = await svc.find_or_create("SerB", TagKind.series)
    img = await _img(db)
    p = SeriesPage(series_tag_id=a.id, image_id=img, page_number=1)
    db.add(p)
    await db.flush()
    cha = SeriesChapter(series_tag_id=a.id, anchor_page_id=p.id)
    db.add(cha)
    await db.flush()
    await svc.merge(a.id, b.id)
    owner = await db.scalar(
        select(SeriesPage.series_tag_id).where(SeriesPage.image_id == img)
    )
    assert owner == b.id
    # The chapter divider moved to the survivor too (its anchor page moved).
    ch_owner = await db.scalar(
        select(SeriesChapter.series_tag_id).where(SeriesChapter.id == cha.id)
    )
    assert ch_owner == b.id
