"""translate_posts sweep (#143) — integration, with a stubbed Interpreter client
and the task's session factory pointed at the test's db_sync."""
import pytest
from sqlalchemy import select

from backend.app.models import Artist, ImportSettings, Post
from backend.app.services import interpreter_client as ic
from backend.app.tasks.translation import (
    _RETRANSLATE_COUNTDOWN,
    retranslate_posts,
    translate_posts,
)

pytestmark = pytest.mark.integration


class _Ctx:
    def __init__(self, s):
        self.s = s

    def __enter__(self):
        return self.s

    def __exit__(self, *a):
        return False


def _sf(db_sync):
    class _SM:
        def __call__(self):
            return _Ctx(db_sync)

    return _SM()


def _artist(db, name="A", slug="a"):
    a = Artist(name=name, slug=slug)
    db.add(a)
    db.flush()
    return a


def _post(db, artist_id, *, title=None, desc=None, ext="p1"):
    p = Post(
        artist_id=artist_id, external_post_id=ext,
        post_title=title, description=desc,
    )
    db.add(p)
    db.flush()
    return p


def _mark_translated(p, *, lang="ja", ver="v1"):
    """Simulate a prior (old-model) translation so retranslate has something to
    reset."""
    p.post_title_translated = "OLD"
    p.description_translated = "OLD"
    p.translated_source_lang = lang
    p.translation_engine_version = ver


def _enable(db, url="http://i.lan"):
    cfg = db.execute(
        select(ImportSettings).where(ImportSettings.id == 1)
    ).scalar_one()
    cfg.translation_enabled = True
    cfg.interpreter_base_url = url
    db.flush()


def _patch(monkeypatch, db_sync):
    monkeypatch.setattr(
        "backend.app.tasks.translation._sync_session_factory",
        lambda: _sf(db_sync),
    )


def test_translate_posts_stores_translation(db_sync, monkeypatch):
    _patch(monkeypatch, db_sync)
    monkeypatch.setattr("backend.app.tasks.translation.ic.health", lambda *a, **k: True)
    monkeypatch.setattr(
        "backend.app.tasks.translation.ic.translate",
        lambda texts, **k: {
            "translations": [f"EN:{t}" for t in texts],
            "detected_lang": "ja", "engine": "llm", "engine_version": "v1",
        },
    )
    a = _artist(db_sync)
    p = _post(db_sync, a.id, title="ねこ", desc="かわいい")
    _enable(db_sync)
    db_sync.commit()

    assert "translated=1" in translate_posts()
    db_sync.refresh(p)
    assert p.post_title_translated == "EN:ねこ"
    assert p.description_translated == "EN:かわいい"
    assert p.translated_source_lang == "ja"
    assert p.translation_engine_version == "v1"


def test_translate_posts_passthrough_english_marks_handled(db_sync, monkeypatch):
    _patch(monkeypatch, db_sync)
    monkeypatch.setattr("backend.app.tasks.translation.ic.health", lambda *a, **k: True)
    monkeypatch.setattr(
        "backend.app.tasks.translation.ic.translate",
        lambda texts, **k: {
            "translations": list(texts), "detected_lang": "en",
            "engine": "none", "engine_version": None,
        },
    )
    a = _artist(db_sync)
    p = _post(db_sync, a.id, title="hello world", ext="p2")
    _enable(db_sync)
    db_sync.commit()

    translate_posts()
    db_sync.refresh(p)
    assert p.translated_source_lang == "en"     # marked handled...
    assert p.post_title_translated is None       # ...but nothing to show


def test_translate_posts_mixed_language_translates_nonenglish_field(db_sync, monkeypatch):
    # English title + Japanese description: the description is still translated
    # even though the title is already English (per-field detection, not the old
    # aggregate first-item bail). Source lang comes from the translated field.
    _patch(monkeypatch, db_sync)
    monkeypatch.setattr("backend.app.tasks.translation.ic.health", lambda *a, **k: True)

    def fake_translate(texts, **k):
        t = texts[0]
        if t.isascii():   # already English → passthrough
            return {"translations": [t], "detected_lang": "en",
                    "engine": "none", "engine_version": None}
        return {"translations": [f"EN:{t}"], "detected_lang": "ja",
                "engine": "llm", "engine_version": "v9"}

    monkeypatch.setattr("backend.app.tasks.translation.ic.translate", fake_translate)
    a = _artist(db_sync)
    p = _post(db_sync, a.id, title="hello", desc="ねこ")
    _enable(db_sync)
    db_sync.commit()

    assert "translated=1" in translate_posts()
    db_sync.refresh(p)
    assert p.post_title_translated is None          # English title untouched
    assert p.description_translated == "EN:ねこ"      # Japanese desc translated
    assert p.translated_source_lang == "ja"          # from the translated field
    assert p.translation_engine_version == "v9"


def test_translate_posts_disabled_is_noop(db_sync, monkeypatch):
    _patch(monkeypatch, db_sync)
    a = _artist(db_sync)
    p = _post(db_sync, a.id, title="ねこ", ext="p3")
    db_sync.commit()   # translation_enabled defaults False
    assert translate_posts() == "disabled"
    db_sync.refresh(p)
    assert p.translated_source_lang is None


def test_translate_posts_service_down_leaves_untranslated(db_sync, monkeypatch):
    _patch(monkeypatch, db_sync)
    monkeypatch.setattr("backend.app.tasks.translation.ic.health", lambda *a, **k: False)
    a = _artist(db_sync)
    p = _post(db_sync, a.id, title="ねこ", ext="p4")
    _enable(db_sync)
    db_sync.commit()
    assert translate_posts() == "interpreter unavailable"
    db_sync.refresh(p)
    assert p.translated_source_lang is None       # will retry next run


def test_translate_posts_interrupt_reenqueues_after_backoff(db_sync, monkeypatch):
    # A drain mid-sweep (health passed, translate 503s w/ Retry-After) re-enqueues
    # the daily sweep after the backoff instead of waiting for tomorrow's beat.
    _patch(monkeypatch, db_sync)
    monkeypatch.setattr("backend.app.tasks.translation.ic.health", lambda *a, **k: True)

    def _drain(texts, **k):
        raise ic.InterpreterUnavailable("draining", retry_after=30)

    monkeypatch.setattr("backend.app.tasks.translation.ic.translate", _drain)
    calls = []
    monkeypatch.setattr(
        "backend.app.tasks.translation.translate_posts.apply_async",
        lambda *a, **k: calls.append((a, k)),
    )
    a = _artist(db_sync)
    _post(db_sync, a.id, title="ねこ", ext="p1")
    _enable(db_sync)
    db_sync.commit()

    assert "interrupted" in translate_posts()
    assert len(calls) == 1
    assert calls[0][1]["countdown"] == 30


def _mock_ok(monkeypatch):
    monkeypatch.setattr("backend.app.tasks.translation.ic.health", lambda *a, **k: True)
    monkeypatch.setattr(
        "backend.app.tasks.translation.ic.translate",
        lambda texts, **k: {
            "translations": [f"EN:{t}" for t in texts],
            "detected_lang": "ja", "engine": "llm", "engine_version": "v1",
        },
    )


def test_translate_posts_drain_chases_tail(db_sync, monkeypatch):
    # "Translate now" (drain=True): with the chunk capped at 1 and 2 untranslated
    # posts, the first chunk re-enqueues itself — drain preserved — to finish the
    # backlog rather than waiting for the next beat.
    _patch(monkeypatch, db_sync)
    _mock_ok(monkeypatch)
    monkeypatch.setattr("backend.app.tasks.translation._MAX_POSTS_PER_RUN", 1)
    calls = []
    monkeypatch.setattr(
        "backend.app.tasks.translation.translate_posts.apply_async",
        lambda *a, **k: calls.append((a, k)),
    )
    a = _artist(db_sync)
    _post(db_sync, a.id, title="ねこ", ext="p1")
    _post(db_sync, a.id, title="いぬ", ext="p2")
    _enable(db_sync)
    db_sync.commit()

    translate_posts(drain=True)
    assert len(calls) == 1                       # one re-enqueue for the tail
    args, kw = calls[0]
    assert args[1] == {"drain": True}            # drain flag carried forward
    assert kw["countdown"] == _RETRANSLATE_COUNTDOWN


def test_translate_posts_beat_single_chunk_no_tail_chase(db_sync, monkeypatch):
    # The periodic beat (drain defaults False) does exactly ONE chunk and never
    # re-enqueues — the 8h cadence drives the rest.
    _patch(monkeypatch, db_sync)
    _mock_ok(monkeypatch)
    monkeypatch.setattr("backend.app.tasks.translation._MAX_POSTS_PER_RUN", 1)
    calls = []
    monkeypatch.setattr(
        "backend.app.tasks.translation.translate_posts.apply_async",
        lambda *a, **k: calls.append((a, k)),
    )
    a = _artist(db_sync)
    _post(db_sync, a.id, title="ねこ", ext="p1")
    _post(db_sync, a.id, title="いぬ", ext="p2")
    _enable(db_sync)
    db_sync.commit()

    translate_posts()                            # drain=False
    assert calls == []                           # no self-re-enqueue


def _mock_lang(monkeypatch, *, lang, conf, ver="v1"):
    # Interpreter is up and returns a translation with the given detected language
    # + confidence, so the acceptance gate can be exercised end-to-end.
    monkeypatch.setattr("backend.app.tasks.translation.ic.health", lambda *a, **k: True)
    monkeypatch.setattr(
        "backend.app.tasks.translation.ic.translate",
        lambda texts, **k: {
            "translations": [f"EN:{t}" for t in texts],
            "detected_lang": lang, "detected_confidence": conf,
            "engine": "llm", "engine_version": ver,
        },
    )


def test_translate_posts_rejects_low_confidence_latin(db_sync, monkeypatch):
    # A short English title Interpreter mis-labels as German with LOW confidence is
    # NOT stored — the original is kept, and the post is marked handled (source ==
    # target) so the sweep won't revisit it.
    _patch(monkeypatch, db_sync)
    _mock_lang(monkeypatch, lang="de", conf=0.42)
    a = _artist(db_sync)
    p = _post(db_sync, a.id, title="Nami Heroines - WIP Part 1", ext="p1")
    _enable(db_sync)
    db_sync.commit()

    translate_posts()
    db_sync.refresh(p)
    assert p.post_title_translated is None            # mis-flag rejected, kept
    assert p.translated_source_lang == "en"           # marked handled (== target)


def test_translate_posts_accepts_high_confidence_latin(db_sync, monkeypatch):
    # A confident latin-script detection (real German) clears the floor → stored.
    _patch(monkeypatch, db_sync)
    _mock_lang(monkeypatch, lang="de", conf=0.98)
    a = _artist(db_sync)
    p = _post(db_sync, a.id, title="Das Mädchen mit dem Perlenohrring", ext="p2")
    _enable(db_sync)
    db_sync.commit()

    translate_posts()
    db_sync.refresh(p)
    assert p.post_title_translated == "EN:Das Mädchen mit dem Perlenohrring"
    assert p.translated_source_lang == "de"


def test_translate_posts_accepts_low_confidence_cjk(db_sync, monkeypatch):
    # CJK is script-detected and trusted regardless of the reported number — a
    # zh 0.75 (e.g. pure-kanji Japanese) is still translated, not rejected.
    _patch(monkeypatch, db_sync)
    _mock_lang(monkeypatch, lang="zh", conf=0.75)
    a = _artist(db_sync)
    p = _post(db_sync, a.id, title="猫が可愛い", ext="p3")
    _enable(db_sync)
    db_sync.commit()

    translate_posts()
    db_sync.refresh(p)
    assert p.post_title_translated == "EN:猫が可愛い"
    assert p.translated_source_lang == "zh"


def test_translate_posts_force_override_translates_below_floor(db_sync, monkeypatch):
    # override='force' stores the translation even below the confidence floor —
    # rescuing a legitimately-foreign title the gate would otherwise skip.
    _patch(monkeypatch, db_sync)
    _mock_lang(monkeypatch, lang="de", conf=0.42)     # below the 0.90 default floor
    a = _artist(db_sync)
    p = _post(db_sync, a.id, title="Kurz", ext="p1")
    p.translation_override = "force"
    _enable(db_sync)
    db_sync.commit()

    translate_posts()
    db_sync.refresh(p)
    assert p.post_title_translated == "EN:Kurz"        # forced through despite 0.42
    assert p.translated_source_lang == "de"


def test_translate_posts_original_override_keeps_original(db_sync, monkeypatch):
    # override='original' never translates — even a confident (0.98) detection is
    # kept as the original. The sweep marks it handled (== target) and stores
    # nothing; Interpreter isn't consulted for the decision.
    _patch(monkeypatch, db_sync)
    _mock_lang(monkeypatch, lang="de", conf=0.98)     # would be accepted under auto
    a = _artist(db_sync)
    p = _post(db_sync, a.id, title="Das Mädchen", ext="p1")
    p.translation_override = "original"
    _enable(db_sync)
    db_sync.commit()

    translate_posts()
    db_sync.refresh(p)
    assert p.post_title_translated is None             # kept original
    assert p.translated_source_lang == "en"            # marked handled


def _mock_new_model(monkeypatch, *, ver="v2"):
    """Interpreter is up and translates with a NEW engine version."""
    monkeypatch.setattr("backend.app.tasks.translation.ic.health", lambda *a, **k: True)
    monkeypatch.setattr(
        "backend.app.tasks.translation.ic.translate",
        lambda texts, **k: {
            "translations": [f"NEW:{t}" for t in texts],
            "detected_lang": "ja", "engine": "llm", "engine_version": ver,
        },
    )


def test_retranslate_resets_and_reruns(db_sync, monkeypatch):
    # A post translated by the old model gets cleared + re-run through the new
    # one (new engine_version proves the reset happened, not a cache short-circuit).
    _patch(monkeypatch, db_sync)
    _mock_new_model(monkeypatch)
    a = _artist(db_sync)
    p = _post(db_sync, a.id, title="ねこ", desc="かわいい")
    _mark_translated(p)
    _enable(db_sync)
    db_sync.commit()

    retranslate_posts(artist_ids=[a.id])
    db_sync.refresh(p)
    assert p.post_title_translated == "NEW:ねこ"
    assert p.description_translated == "NEW:かわいい"
    assert p.translation_engine_version == "v2"


def test_retranslate_leaves_keep_original_posts(db_sync, monkeypatch):
    # A 'keep original' post survives a Re-translate-all: _reset_translations skips
    # override='original', so it stays handled + untranslated instead of being
    # re-run (which would re-introduce the mis-flag the operator killed).
    _patch(monkeypatch, db_sync)
    _mock_new_model(monkeypatch)
    a = _artist(db_sync)
    p = _post(db_sync, a.id, title="ねこ", ext="p1")
    p.translation_override = "original"
    p.translated_source_lang = "en"       # handled by a prior 'keep original' apply
    _enable(db_sync)
    db_sync.commit()

    retranslate_posts(artist_ids=[a.id])
    db_sync.refresh(p)
    assert p.translated_source_lang == "en"     # not reset
    assert p.post_title_translated is None       # not re-translated


def test_retranslate_artist_scope_leaves_others(db_sync, monkeypatch):
    # Scoping to one artist must not touch another artist's translations.
    _patch(monkeypatch, db_sync)
    _mock_new_model(monkeypatch)
    a1 = _artist(db_sync)
    a2 = _artist(db_sync, name="B", slug="b")
    p1 = _post(db_sync, a1.id, title="ねこ", ext="p1")
    p2 = _post(db_sync, a2.id, title="いぬ", ext="p2")
    _mark_translated(p1)
    _mark_translated(p2)
    _enable(db_sync)
    db_sync.commit()

    retranslate_posts(artist_ids=[a1.id])
    db_sync.refresh(p1)
    db_sync.refresh(p2)
    assert p1.post_title_translated == "NEW:ねこ"     # re-run
    assert p1.translation_engine_version == "v2"
    assert p2.post_title_translated == "OLD"          # untouched
    assert p2.translation_engine_version == "v1"


def test_retranslate_runs_until_done(db_sync, monkeypatch):
    # With the chunk capped at 1 and 2 posts to redo, the first chunk re-enqueues
    # itself (with _reset_done=True so it doesn't wipe the fresh work) to finish.
    _patch(monkeypatch, db_sync)
    _mock_new_model(monkeypatch)
    monkeypatch.setattr("backend.app.tasks.translation._MAX_POSTS_PER_RUN", 1)
    calls = []
    monkeypatch.setattr(
        "backend.app.tasks.translation.retranslate_posts.apply_async",
        lambda *a, **k: calls.append((a, k)),
    )
    a = _artist(db_sync)
    p1 = _post(db_sync, a.id, title="ねこ", ext="p1")
    p2 = _post(db_sync, a.id, title="いぬ", ext="p2")
    _mark_translated(p1)
    _mark_translated(p2)
    _enable(db_sync)
    db_sync.commit()

    retranslate_posts(artist_ids=[a.id])
    assert len(calls) == 1                             # one re-enqueue for the tail
    args, _kw = calls[0]
    assert args[1]["_reset_done"] is True
    assert args[1]["artist_ids"] == [a.id]


def test_retranslate_disabled_does_not_reset(db_sync, monkeypatch):
    # Never wipe translations we can't rebuild — a disabled service leaves them.
    _patch(monkeypatch, db_sync)
    a = _artist(db_sync)
    p = _post(db_sync, a.id, title="ねこ", ext="p1")
    _mark_translated(p)
    db_sync.commit()   # translation_enabled defaults False
    assert retranslate_posts(artist_ids=[a.id]) == "disabled"
    db_sync.refresh(p)
    assert p.translated_source_lang == "ja"
    assert p.post_title_translated == "OLD"


def test_retranslate_unhealthy_does_not_reset(db_sync, monkeypatch):
    _patch(monkeypatch, db_sync)
    monkeypatch.setattr("backend.app.tasks.translation.ic.health", lambda *a, **k: False)
    a = _artist(db_sync)
    p = _post(db_sync, a.id, title="ねこ", ext="p1")
    _mark_translated(p)
    _enable(db_sync)
    db_sync.commit()
    assert retranslate_posts(artist_ids=[a.id]) == "interpreter unavailable"
    db_sync.refresh(p)
    assert p.translated_source_lang == "ja"
    assert p.post_title_translated == "OLD"


def test_retranslate_interrupt_resumes_after_retry_after(db_sync, monkeypatch):
    # Interpreter drains mid-chunk (health passed, then translate 503s with a
    # Retry-After): the bulk re-translate re-enqueues itself after the hinted
    # backoff, with _reset_done=True so it never re-wipes fresh work — instead of
    # stalling until the daily beat.
    _patch(monkeypatch, db_sync)
    monkeypatch.setattr("backend.app.tasks.translation.ic.health", lambda *a, **k: True)

    def _drain(texts, **k):
        raise ic.InterpreterUnavailable("draining", retry_after=42)

    monkeypatch.setattr("backend.app.tasks.translation.ic.translate", _drain)
    calls = []
    monkeypatch.setattr(
        "backend.app.tasks.translation.retranslate_posts.apply_async",
        lambda *a, **k: calls.append((a, k)),
    )
    a = _artist(db_sync)
    p = _post(db_sync, a.id, title="ねこ", ext="p1")
    _mark_translated(p)
    _enable(db_sync)
    db_sync.commit()

    assert "interrupted" in retranslate_posts(artist_ids=[a.id])
    assert len(calls) == 1                             # resume re-enqueued
    args, kw = calls[0]
    assert args[1]["_reset_done"] is True
    assert args[1]["artist_ids"] == [a.id]
    assert kw["countdown"] == 42                       # honoured the Retry-After
    db_sync.refresh(p)
    assert p.translated_source_lang is None            # reset happened, not re-run


def test_retranslate_interrupt_default_backoff_without_hint(db_sync, monkeypatch):
    # No Retry-After header → fall back to the default backoff (60s).
    _patch(monkeypatch, db_sync)
    monkeypatch.setattr("backend.app.tasks.translation.ic.health", lambda *a, **k: True)

    def _drain(texts, **k):
        raise ic.InterpreterUnavailable("draining")

    monkeypatch.setattr("backend.app.tasks.translation.ic.translate", _drain)
    calls = []
    monkeypatch.setattr(
        "backend.app.tasks.translation.retranslate_posts.apply_async",
        lambda *a, **k: calls.append((a, k)),
    )
    a = _artist(db_sync)
    p = _post(db_sync, a.id, title="ねこ", ext="p1")
    _mark_translated(p)
    _enable(db_sync)
    db_sync.commit()

    retranslate_posts(artist_ids=[a.id])
    assert len(calls) == 1
    assert calls[0][1]["countdown"] == 60
