"""SP37 Task 4: submit_file owns parse → DB write → SFTP upload.

Tests use a fake SFTP client to avoid hitting the real clearhouse.
The DB path is real (autouse ``_auto_init_db`` fixture in conftest.py)
so the join-key wiring is exercised end-to-end.
"""
from __future__ import annotations

from pathlib import Path
from unittest.mock import MagicMock

import pytest

from cyclone import db as db_mod
from cyclone.submission.core import submit_file
from cyclone.submission.result import SubmitOutcome


_FIXTURE = Path(__file__).parent / "fixtures" / "submit-batch" / "single-claim.x12"


class _FakeSftp:
    """Implements just enough of SftpClient for submit_file's idempotency check.

    After every successful ``write_file``, the file is added to
    ``existing_files`` keyed by remote_path with the byte size — so a
    subsequent ``stat`` returns the right size and the idempotency
    check in ``submit_file`` short-circuits to ``SKIPPED``. Without
    this bookkeeping, every re-run would look like the file isn't on
    the remote and would re-upload.
    """

    def __init__(self, existing_files: dict[str, int] | None = None):
        self.existing_files = existing_files or {}
        self.put_calls: list[tuple[str, bytes]] = []

    def stat(self, path: str):
        if path not in self.existing_files:
            raise IOError(f"no such file: {path}")
        m = MagicMock()
        m.st_size = self.existing_files[path]
        return m

    def write_file(self, remote_path: str, content: bytes):
        self.put_calls.append((remote_path, content))
        # Mirror the upload so a subsequent stat() sees it.
        self.existing_files[remote_path] = len(content)


def test_submit_file_happy_path():
    """parse → DB write → SFTP upload, all steps succeed."""
    fake = _FakeSftp()
    sftp_block = MagicMock()
    sftp_block.stub = False
    sftp_block.paths = {"outbound": "/ToHPE"}

    result = submit_file(
        _FIXTURE,
        sftp_block=sftp_block,
        actor="test",
        validate=True,
        sftp_client_factory=lambda _block: fake,
    )

    assert result.outcome == SubmitOutcome.SUBMITTED
    assert result.batch_id is not None
    assert len(fake.put_calls) == 1
    # DB row landed.
    with db_mod.SessionLocal()() as s:
        assert s.get(db_mod.Batch, result.batch_id) is not None


def test_submit_file_skipped_when_remote_size_matches():
    """Re-running submit_file on an already-uploaded file is SKIPPED.

    The ``SKIPPED`` outcome is reached via the SFTP ``stat()`` check
    (not via the DB) — same-size remote file ⇒ idempotency short-circuit.
    Re-runs therefore don't emit a duplicate ``clearhouse.submitted``
    audit event.
    """
    fake = _FakeSftp()
    sftp_block = MagicMock(stub=False, paths={"outbound": "/ToHPE"})
    factory = lambda _b: fake

    r1 = submit_file(_FIXTURE, sftp_block=sftp_block, actor="t", sftp_client_factory=factory)
    r2 = submit_file(_FIXTURE, sftp_block=sftp_block, actor="t", sftp_client_factory=factory)

    # First call SUBMITTED, second call SKIPPED (size match on stat).
    assert r1.outcome == SubmitOutcome.SUBMITTED
    assert r2.outcome == SubmitOutcome.SKIPPED


def test_submit_file_parse_fail(tmp_path):
    """Bad 837 → PARSE_FAILED, no DB write, no SFTP call."""
    bad = tmp_path / "bad.x12"
    bad.write_bytes(b"not x12")
    sftp_block = MagicMock(stub=False, paths={"outbound": "/ToHPE"})
    fake = _FakeSftp()
    factory = lambda _b: fake

    result = submit_file(bad, sftp_block=sftp_block, actor="t", sftp_client_factory=factory)
    assert result.outcome == SubmitOutcome.PARSE_FAILED
    assert len(fake.put_calls) == 0


def test_submit_file_db_fail(monkeypatch):
    """If add raises, no SFTP call is made (DB-first invariant)."""
    from cyclone.store import store as cycl_store
    monkeypatch.setattr(cycl_store, "add", MagicMock(side_effect=RuntimeError("db down")))

    sftp_block = MagicMock(stub=False, paths={"outbound": "/ToHPE"})
    fake = _FakeSftp()
    factory = lambda _b: fake

    result = submit_file(_FIXTURE, sftp_block=sftp_block, actor="t", sftp_client_factory=factory)
    assert result.outcome == SubmitOutcome.DB_FAILED
    assert len(fake.put_calls) == 0


def test_submit_file_sftp_fail():
    """SFTP failure after DB write → SFTP_FAILED, DB row still present."""

    class _Boom:
        def stat(self, _p):
            raise IOError("nope")
        def write_file(self, _p, _c):
            raise RuntimeError("sftp down")

    sftp_block = MagicMock(stub=False, paths={"outbound": "/ToHPE"})
    factory = lambda _b: _Boom()

    result = submit_file(_FIXTURE, sftp_block=sftp_block, actor="t", sftp_client_factory=factory)
    assert result.outcome == SubmitOutcome.SFTP_FAILED
    # DB row was written before the SFTP call.
    assert result.batch_id is not None
    with db_mod.SessionLocal()() as s:
        assert s.get(db_mod.Batch, result.batch_id) is not None


def test_submit_file_payer_mismatch():
    """PAYER_MISMATCH → no DB write, no SFTP call.

    We monkeypatch ``parse_837_text`` to return a single claim whose
    payer.id is ``OTHER_PAYER`` (the helper gates on the first
    claim's payer — same posture as the legacy ``resubmit_rejected_claims``
    CLI at cli.py:657-668).
    """
    from unittest.mock import patch

    bad_claim = MagicMock()
    bad_claim.payer.id = "OTHER_PAYER"
    parsed_with_bad_payer = MagicMock()
    parsed_with_bad_payer.claims = [bad_claim]
    parsed_with_bad_payer.parsed_at = None
    parsed_with_bad_payer.envelope.transaction_set_control_number = "ST123"

    fake = _FakeSftp()
    sftp_block = MagicMock(stub=False, paths={"outbound": "/ToHPE"})

    with patch("cyclone.submission.core.parse_837_text", return_value=parsed_with_bad_payer):
        result = submit_file(
            _FIXTURE,
            sftp_block=sftp_block,
            actor="t",
            validate=True,
            sftp_client_factory=lambda _b: fake,
        )
    assert result.outcome == SubmitOutcome.PAYER_MISMATCH
    assert "OTHER_PAYER" in (result.error or "")
    assert len(fake.put_calls) == 0


def test_submit_file_uses_default_paramiko_factory(monkeypatch):
    """When no factory is supplied, helper should use paramiko directly
    (not SftpClient wrapper, which lacks stat()).
    """
    fake_paramiko_sftp = _FakeSftp()
    monkeypatch.setattr(
        "cyclone.submission.core._default_sftp_factory",
        lambda _block: fake_paramiko_sftp,
    )

    sftp_block = MagicMock()
    sftp_block.stub = False
    sftp_block.paths = {"outbound": "/ToHPE"}

    result = submit_file(
        _FIXTURE,
        sftp_block=sftp_block,
        actor="t",
        validate=True,
        # NO sftp_client_factory — should use the paramiko default
    )
    assert result.outcome == SubmitOutcome.SUBMITTED
    assert len(fake_paramiko_sftp.put_calls) == 1


def test_submit_batch_cli_help():
    """`cyclone submit-batch --help` exits 0 and shows the flags.

    Uses ``click.testing.CliRunner`` instead of ``subprocess.run`` for
    ~10ms vs ~200ms per call. Same assertions — the CliRunner
    ``result.exit_code`` matches the subprocess returncode, and
    ``result.output`` is the Click-rendered help text (subprocess's
    stdout).
    """
    from click.testing import CliRunner

    from cyclone.cli import main

    runner = CliRunner()
    result = runner.invoke(main, ["submit-batch", "--help"])
    assert result.exit_code == 0, result.output
    assert "--ingest-dir" in result.output
