"""SP38: CLI tests for ``cyclone ack-orphans {status,reconcile}``.

Uses ``click.testing.CliRunner`` (matching the SP37-followup #5
pattern that replaced ``subprocess.run`` with the in-process
runner). The CLI commands are thin wrappers over the store helpers
tested in ``test_ack_orphan_summary.py`` — these tests pin the
shell-facing shape (table format, exit codes, --dry-run flag).

The autouse ``_auto_init_db`` fixture in ``conftest.py`` provides
a fresh ``tmp_path/test.db`` for every test; the CLI commands pick
it up via the ``CYCLONE_DB_URL`` env var the fixture sets.
"""
from __future__ import annotations

import json
from datetime import datetime, timezone

import pytest
from click.testing import CliRunner

from cyclone import db
from cyclone.cli import main
from cyclone.db import Ack, Batch


def _now():
    return datetime.now(timezone.utc)


def _ingest_orphan(s, st02: str) -> int:
    """Insert a 999 ack row whose set_responses[0].set_control_number == st02.

    Returns the new ack id. The 999 is an orphan (no claim_acks row)
    so it surfaces in the summary.
    """
    raw = {
        "envelope": {"sender_id": "S", "receiver_id": "R",
                     "control_number": "000000099", "transaction_date": "2024-01-01",
                     "implementation_guide": "005010X231A1"},
        "functional_group_acks": [],
        "set_responses": [
            {"set_control_number": st02, "transaction_set_identifier": "837",
             "ak2": {"functional_id_code": "837"}, "segment_errors": [],
             "set_accept_reject": {"code": "A"}}
        ],
        "summary": {"accepted_count": 1, "rejected_count": 0},
    }
    ack = Ack(
        source_batch_id=f"999-{st02}-cli-test",
        accepted_count=1, rejected_count=0, received_count=1,
        ack_code="A", parsed_at=_now(), raw_json=json.dumps(raw),
    )
    s.add(ack)
    s.flush()
    return ack.id


# ---- cyclone ack-orphans status ------------------------------------------ #


def test_status_prints_table_with_orphans():
    """``cyclone ack-orphans status`` prints a table with ST02 +
    ack count + has-batch flag, plus a TOTAL line at the bottom.

    Mirrors the spec's intent: operators want a one-line-per-orphan
    view that ranks the heaviest backlog first.
    """
    with db.SessionLocal()() as s:
        _ingest_orphan(s, "991102989")
        _ingest_orphan(s, "991102988")
        s.commit()

    runner = CliRunner()
    result = runner.invoke(main, ["ack-orphans", "status"])
    assert result.exit_code == 0, result.output
    # Table header + two data rows + total line.
    assert "ST02" in result.output
    assert "ACK COUNT" in result.output
    assert "991102989" in result.output
    assert "991102988" in result.output
    assert "TOTAL" in result.output


def test_status_exits_zero_on_empty_db():
    """With no orphans, status exits 0 and prints a 'no orphans' note."""
    runner = CliRunner()
    result = runner.invoke(main, ["ack-orphans", "status"])
    assert result.exit_code == 0, result.output
    # The empty case should be informative, not silent.
    assert "no orphans" in result.output.lower() or "0" in result.output


def test_status_table_includes_has_batch_column():
    """When a ST02 already has a batches row, the table marks it
    with ``yes`` (or similar) so operators can see which orphans
    are unbacked vs already-covered.
    """
    with db.SessionLocal()() as s:
        # Orphan with NO batch.
        _ingest_orphan(s, "991102988")
        # Orphan WITH a pre-existing batch row.
        import uuid
        bid = uuid.uuid4().hex
        s.add(Batch(
            id=bid, kind="837p", input_filename="test.837p",
            transaction_set_control_number="991102977", parsed_at=_now(),
        ))
        _ingest_orphan(s, "991102977")
        s.commit()

    runner = CliRunner()
    result = runner.invoke(main, ["ack-orphans", "status"])
    assert result.exit_code == 0, result.output
    # Look for "yes" / "no" markers in the table.
    lines = result.output.splitlines()
    has_yes = any("yes" in ln.lower() for ln in lines)
    has_no = any("no" in ln.lower() for ln in lines)
    assert has_yes and has_no, (
        f"Expected table to mark 'yes' for ST02s with batches and "
        f"'no' for ST02s without. Got:\n{result.output}"
    )


# ---- cyclone ack-orphans reconcile --------------------------------------- #


def test_reconcile_creates_synthetic_batches():
    """``cyclone ack-orphans reconcile`` inserts a synthetic batch
    row for each orphan ST02 without one. Confirms the count + the
    sentinel filename.
    """
    with db.SessionLocal()() as s:
        _ingest_orphan(s, "991102989")
        _ingest_orphan(s, "991102988")
        s.commit()

    runner = CliRunner()
    result = runner.invoke(main, ["ack-orphans", "reconcile"])
    assert result.exit_code == 0, result.output

    # The CLI should print the created/skipped counts.
    assert "Created" in result.output or "created" in result.output
    assert "Skipped" in result.output or "skipped" in result.output

    with db.SessionLocal()() as s:
        synthetic = (
            s.query(Batch)
            .filter(Batch.input_filename == "<synthetic:orphan-reconcile>")
            .all()
        )
        assert len(synthetic) == 2
        st02s = {r.transaction_set_control_number for r in synthetic}
        assert st02s == {"991102989", "991102988"}


def test_reconcile_dry_run_does_not_write():
    """``--dry-run`` returns the plan shape but does NOT insert rows.
    Operators use this to preview before committing.
    """
    with db.SessionLocal()() as s:
        _ingest_orphan(s, "991102989")
        s.commit()

    runner = CliRunner()
    result = runner.invoke(main, ["ack-orphans", "reconcile", "--dry-run"])
    assert result.exit_code == 0, result.output
    # The plan reports the would-create count.
    assert "1" in result.output  # would-create 1 row

    # But no synthetic batch row was inserted.
    with db.SessionLocal()() as s:
        n = (
            s.query(Batch)
            .filter(Batch.input_filename == "<synthetic:orphan-reconcile>")
            .count()
        )
        assert n == 0


def test_reconcile_is_idempotent():
    """A second reconcile after a successful pass is a no-op:
    created=0, skipped=N. Tested at the CLI shell level so the
    user-visible output also pins this invariant.
    """
    with db.SessionLocal()() as s:
        _ingest_orphan(s, "991102989")
        s.commit()

    runner = CliRunner()
    first = runner.invoke(main, ["ack-orphans", "reconcile"])
    second = runner.invoke(main, ["ack-orphans", "reconcile"])
    assert first.exit_code == 0
    assert second.exit_code == 0

    with db.SessionLocal()() as s:
        n = (
            s.query(Batch)
            .filter(Batch.input_filename == "<synthetic:orphan-reconcile>")
            .count()
        )
        assert n == 1  # still exactly one — second call was a no-op


def test_reconcile_handles_no_orphans_gracefully():
    """With zero orphans, reconcile exits 0 and prints an informative
    message (no synthetic rows created, no error)."""
    runner = CliRunner()
    result = runner.invoke(main, ["ack-orphans", "reconcile"])
    assert result.exit_code == 0, result.output
    assert "0" in result.output  # created=0, skipped=0