"""SP37 Task 4: parse → DB write → SFTP upload, per file.

Mirrors ``resubmit_rejected_claims`` but writes to the DB before
uploading. Same idempotency check (``stat().st_size == local_size``)
skips already-uploaded files without re-emitting audit events.

DB-first, upload-second (per spec decision §2): if the DB write fails,
no SFTP call is made. If the SFTP call fails after a successful DB
write, the row exists but the file never landed — re-running is safe
(idempotent DB write via add_record's s.get check; idempotent SFTP
upload via stat-then-put).

The default SFTP factory uses paramiko directly (not the
``SftpClient`` wrapper) because the wrapper exposes ``write_file``,
``list_inbound``, and ``read_file`` but no ``stat()`` — which makes
the SKIPPED outcome unreachable in production.
"""
from __future__ import annotations

import logging
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable

from cyclone import db as db_mod
from cyclone.audit_log import AuditEvent, append_event
from cyclone.parsers.parse_837 import parse as parse_837_text
from cyclone.parsers.payer import PayerConfig
from cyclone.providers import SftpBlock
from cyclone.store import store as cycl_store
from cyclone.store.exceptions import DuplicateClaimError
from cyclone.store.records import BatchRecord837
from cyclone.store.submission_dedup import check_duplicate

from .result import SubmitOutcome, SubmitResult

log = logging.getLogger(__name__)

# The companion-guide payer id we enforce for CO Medicaid submits.
# Same value the legacy ``resubmit-rejected-claims`` CLI gates on.
# TODO(sp39-followup): trace where ClaimOutput.payer.id gets set to
# "SKCO0" upstream of submit. SP39 hard-fixes the serializer via
# _normalize_payer_id (serialize_837.py) so the byte defect cannot
# escape the 837 emit, but the root-cause setter is still live in the
# raw_json pipeline. Find and patch in a future SP.
EXPECTED_PAYER_ID = "CO_TXIX"


def _default_sftp_factory(sftp_block: SftpBlock) -> Any:
    """Open a paramiko SFTP session to the real MFT. Mirrors
    resubmit_rejected_claims._open_session (cli.py:620-640).
    Caller is responsible for closing the session.

    The returned ``sftp`` is a ``paramiko.SFTPClient`` — it exposes
    ``stat(remote_path)`` (used for the idempotency check) and
    ``put(local_path, remote_path)`` (used for the upload). The
    underlying SSH handle is stashed on ``sftp._cyclone_ssh`` so the
    caller can close it cleanly via ``getattr(sftp, "_cyclone_ssh",
    None).close()`` after the upload finishes.

    Raises:
        RuntimeError: if the SFTP block is in stub mode (the CLI/HTTP
            layer should already guard against this, but we re-check
            here because paramiko will try to connect even when
            ``stub=True``).
    """
    if sftp_block.stub:
        # Same posture as resubmit_rejected_claims in cli.py:592-595:
        # the operator refuses to upload in stub mode, and the helper
        # surfaces that as SFTP_FAILED with an explicit error.
        raise RuntimeError("SFTP block is in stub mode")

    from paramiko import AutoAddPolicy, SSHClient

    from cyclone.secrets import get_secret

    pw = get_secret(sftp_block.auth.get("password_keychain_account", ""))
    ssh = SSHClient()
    ssh.set_missing_host_key_policy(AutoAddPolicy())
    ssh.connect(
        sftp_block.host, port=sftp_block.port,
        username=sftp_block.username, password=pw,
        timeout=15, banner_timeout=15, auth_timeout=15,
    )
    sftp = ssh.open_sftp()
    # Attach ssh to sftp so the caller can close it cleanly.
    sftp._cyclone_ssh = ssh
    return sftp


def submit_file(
    path: Path,
    *,
    sftp_block: SftpBlock,
    actor: str,
    validate: bool = True,
    sftp_client_factory: Callable[[SftpBlock], Any] | None = None,
) -> SubmitResult:
    """Submit one 837P file: parse → DB write → SFTP upload.

    Args:
        path: local 837P file.
        sftp_block: the clearhouse SftpBlock (for paths + auth).
        actor: audit-log actor tag (e.g. "api-submit-batch").
        validate: parse the file before upload (default True). Catches
            bad byte-fixes early.
        sftp_client_factory: optional callable that returns an
            SFTP-client-compatible object (must expose ``stat()`` and
            ``write_file()``). Defaults to :func:`_default_sftp_factory`
            which opens a real paramiko session. Tests inject a fake.

    Returns:
        SubmitResult with file, outcome, batch_id (when written),
        error (when failed).
    """
    file_label = path.name
    try:
        content = path.read_bytes()
    except OSError as exc:
        return SubmitResult(file_label, SubmitOutcome.PARSE_FAILED, error=str(exc))

    # 1. Validate via parse (optional but recommended).
    parsed: Any = None
    if validate:
        try:
            parsed = parse_837_text(content.decode(), PayerConfig.co_medicaid())
        except Exception as exc:  # noqa: BLE001
            log.warning("submit_file %s: parse failed: %s", file_label, exc)
            return SubmitResult(file_label, SubmitOutcome.PARSE_FAILED, error=str(exc))
        mismatch = next(
            (c for c in parsed.claims if c.payer.id != EXPECTED_PAYER_ID),
            None,
        )
        if mismatch is not None:
            return SubmitResult(
                file_label,
                SubmitOutcome.PAYER_MISMATCH,
                error=f"payer.id={mismatch.payer.id!r} (expected {EXPECTED_PAYER_ID!r})",
            )

        # 1b. SP41 Task 9 — claim-id dedup pre-flight. Walks every
        # CLM01 in the parsed file and asks ``check_duplicate`` whether
        # any of them were submitted within the 30-day window. If so,
        # raise ``DuplicateClaimError`` — this is a 409-class domain
        # exception (mirrors the posture of ``AlreadyMatchedError`` /
        # ``NotMatchedError`` / ``InvalidStateError`` per their docstrings)
        # and is caught by ``api_routers/submission.submit_batch`` so
        # the per-file 200-with-results contract is preserved.
        #
        # ``check_duplicate`` reads the configured ``cycl_db.SessionLocal()``
        # — the same engine the BatchRecord837 DB write below uses — so
        # we don't need to thread a session through here. The helper's
        # optional ``db_url`` kwarg is ignored (signature-preserving
        # shim for back-compat with the original Task 8 callers).
        #
        # We deliberately RAISE here instead of returning a SubmitResult:
        # the exception carries ``claim_id`` + ``original_submission_at``
        # so the caller can surface structured detail to the operator
        # without re-querying. Approach A per the Task 9 spec.
        #
        # Symmetry with the sibling failure paths (parse / DB / SFTP /
        # audit-event — each logs ``submit_file %s: <kind>: %s`` before
        # returning or re-raising). The router's special-case handler
        # also logs this exception at the api_routers boundary, but
        # the helper has its own log line so the operator tracing a
        # failure through stdout sees the dedup trip even when
        # submit_file is invoked directly (e.g. from the CLI or a
        # future call site that does not go through the router).
        try:
            for claim in parsed.claims:
                check_duplicate(claim.claim_id)
        except DuplicateClaimError as exc:
            log.warning(
                "submit_file %s: duplicate claim %s (originally submitted %s)",
                file_label, exc.claim_id, exc.original_submission_at.isoformat(),
            )
            raise

    # 2. DB write — DB-first, upload-second invariant.
    # ``parsed`` is required to construct a BatchRecord837 (it embeds the
    # full ParseResult). validate=False therefore isn't a real path —
    # the CLI/HTTP always pass validate=True — so reject it loudly
    # rather than silently building an empty row.
    if parsed is None:
        return SubmitResult(
            file_label,
            SubmitOutcome.PARSE_FAILED,
            error="validate=False is not supported; must parse to construct a BatchRecord",
        )

    # Use the same uuid4().hex id the existing /api/parse-837 path uses.
    batch_id = uuid.uuid4().hex
    record = BatchRecord837(
        id=batch_id,
        input_filename=file_label,
        parsed_at=datetime.now(timezone.utc),
        result=parsed,
    )

    try:
        # ``cycl_store.add`` is the public facade method (per the
        # CycloneStore class in store/__init__.py:158). It delegates
        # to ``store.write.add_record``, which is the underlying
        # SQLAlchemy write path.
        cycl_store.add(record)
    except Exception as exc:  # noqa: BLE001
        log.warning("submit_file %s: DB write failed: %s", file_label, exc)
        return SubmitResult(file_label, SubmitOutcome.DB_FAILED, error=str(exc))

    # 3. SFTP upload. ``_default_sftp_factory`` opens a paramiko
    # session directly because the SftpClient wrapper has no ``stat()``
    # method — that omission made the SKIPPED outcome unreachable in
    # production. Mirror cli.py:620-650.
    remote_path = f"{sftp_block.paths['outbound']}/{file_label}"
    factory = sftp_client_factory or _default_sftp_factory
    sftp = factory(sftp_block)
    local_size = len(content)
    try:
        try:
            stat = sftp.stat(remote_path)
            if stat.st_size == local_size:
                # Already on remote at the right size — re-run is safe;
                # do NOT emit a duplicate audit event.
                return SubmitResult(file_label, SubmitOutcome.SKIPPED, batch_id=batch_id)
        except (IOError, OSError):
            pass  # not on remote yet
        sftp.write_file(remote_path, content)
    except Exception as exc:  # noqa: BLE001
        log.warning("submit_file %s: SFTP failed: %s", file_label, exc)
        return SubmitResult(
            file_label, SubmitOutcome.SFTP_FAILED,
            batch_id=batch_id, error=str(exc),
        )
    finally:
        # Close the paramiko SSH handle the helper stashed on the
        # SFTP client (paramiko does not auto-close on GC and a leak
        # here costs a slot in MOVEit's per-IP session table).
        ssh_handle = getattr(sftp, "_cyclone_ssh", None)
        if ssh_handle is not None:
            try:
                ssh_handle.close()
            except Exception:  # noqa: BLE001
                pass

    # 4. Audit event — same shape the legacy resubmit_rejected_claims CLI
    # uses (event_type="clearhouse.submitted", entity_type="claim_file",
    # entity_id=filename, payload has remote_path + source + size).
    # Best-effort: an audit failure must not roll back the upload.
    try:
        with db_mod.SessionLocal()() as session:
            append_event(session, AuditEvent(
                event_type="clearhouse.submitted",
                entity_type="claim_file",
                entity_id=file_label,
                payload={
                    "remote_path": remote_path,
                    "source": "submit-batch",
                    "size": local_size,
                    "batch_id": batch_id,
                },
                actor=actor,
            ))
            session.commit()
    except Exception as exc:  # noqa: BLE001
        log.warning(
            "submit_file %s: audit event failed: %s", file_label, exc,
        )

    return SubmitResult(file_label, SubmitOutcome.SUBMITTED, batch_id=batch_id)
