"""Click-based CLI for the Cyclone 837P and 835 ERA parsers."""

from __future__ import annotations

import logging
import os
import sys
from pathlib import Path

import click

from cyclone.logging_config import setup_logging
from cyclone.parsers.exceptions import CycloneParseError, CycloneValidationError
from cyclone.parsers.payer import PayerConfig, PayerConfig835
from cyclone.parsers.parse_837 import parse as parse_837_text
from cyclone.parsers.parse_835 import parse as parse_835_text
from cyclone.parsers.validator_835 import validate as validate_835
from cyclone.parsers.writer import write_outputs
from cyclone.parsers.writer_835 import write_outputs_835


_PAYER_FACTORIES = {
    "co_medicaid": PayerConfig.co_medicaid,
    "generic_837p": PayerConfig.generic_837p,
}

_PAYER_FACTORIES_835 = {
    "co_medicaid_835": PayerConfig835.co_medicaid_835,
    "generic_835": PayerConfig835.generic_835,
}


def _payer(name: str) -> PayerConfig:
    if name not in _PAYER_FACTORIES:
        raise click.BadParameter(f"Unknown payer {name!r}. Choose from: {', '.join(_PAYER_FACTORIES)}")
    return _PAYER_FACTORIES[name]()


def _payer_835(name: str) -> PayerConfig835:
    if name not in _PAYER_FACTORIES_835:
        raise click.BadParameter(f"Unknown payer {name!r}. Choose from: {', '.join(_PAYER_FACTORIES_835)}")
    return _PAYER_FACTORIES_835[name]()


@click.group()
@click.option(
    "--log-format",
    default=None,
    type=click.Choice(["json", "dev"]),
    help="Log format (default: json; honors CYCLONE_LOG_JSON).",
)
@click.option(
    "--log-file",
    default=None,
    type=click.Path(dir_okay=False, path_type=Path),
    help="Optional rotating log file (honors CYCLONE_LOG_FILE).",
)
@click.pass_context
def main(ctx: click.Context, log_format: str | None, log_file: Path | None) -> None:
    """Cyclone EDI suite — X12 parser."""
    # SP18: structured JSON logging. Run once per CLI invocation; each
    # subcommand still gets its own --log-level to override.
    json_format = True
    if log_format == "dev":
        json_format = False
    elif os.environ.get("CYCLONE_LOG_JSON", "").lower() in ("false", "0", "no"):
        json_format = False
    setup_logging(
        level=os.environ.get("CYCLONE_LOG_LEVEL", "INFO"),
        log_file=str(log_file) if log_file else None,
        json_format=json_format,
    )
    # Stash on context so subcommands can read it.
    ctx.ensure_object(dict)


@main.command("parse-837")
@click.argument("input_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--output-dir", required=True, type=click.Path(file_okay=False, path_type=Path))
@click.option("--payer", default="co_medicaid", show_default=True, help="Payer configuration preset")
@click.option("--strict", is_flag=True, help="Promote warnings to errors")
@click.option("--max-retries", default=0, show_default=True, type=int, help="Re-validate failed claims (no fix in v1)")
@click.option("--include-raw-segments / --no-raw-segments", default=True, help="Embed raw segments in output JSON")
@click.option("--log-level", default="INFO", show_default=True, type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"]))
def parse_837(
    input_file: Path,
    output_dir: Path,
    payer: str,
    strict: bool,
    max_retries: int,
    include_raw_segments: bool,
    log_level: str,
) -> None:
    """Parse an X12 837P file into one JSON per claim."""
    # SP18: re-run setup so per-command --log-level overrides the
    # group default. ``setup_logging`` is idempotent.
    setup_logging(level=log_level)

    text = input_file.read_text()
    config = _payer(payer)

    try:
        result = parse_837_text(text, config, input_file=str(input_file))
    except CycloneParseError as exc:
        click.echo(f"PARSE ERROR: {exc}", err=True)
        sys.exit(2)

    if not result.claims:
        raise click.UsageError("No claims found in file") from None

    # Strict mode: rewrite reports
    if strict:
        for claim in result.claims:
            promoted = [
                issue.model_copy(update={"severity": "error"})
                for issue in claim.validation.warnings
            ]
            new_errors = claim.validation.errors + promoted
            claim.validation = claim.validation.model_copy(
                update={"errors": new_errors, "passed": not new_errors}
            )
        # Recompute summary
        passed = sum(1 for c in result.claims if c.validation.passed)
        result.summary = result.summary.model_copy(update={
            "passed": passed,
            "failed": len(result.claims) - passed,
            "failed_claim_ids": [c.claim_id for c in result.claims if not c.validation.passed],
        })

    # Drop raw_segments if disabled
    if not include_raw_segments:
        for claim in result.claims:
            claim.raw_segments = []

    # v1: max_retries is a no-op stub. Surface a log line so users know it's wired.
    if max_retries:
        logging.info("max-retries=%d (no auto-fix in v1; would re-validate here)", max_retries)

    paths = write_outputs(result, output_dir)
    result.summary = result.summary.model_copy(update={"output_dir": str(output_dir)})
    (output_dir / "summary.json").write_text(result.summary.model_dump_json(indent=2))

    click.echo(
        f"parsed={result.summary.total_claims}  "
        f"passed={result.summary.passed}  "
        f"failed={result.summary.failed}  "
        f"output={output_dir}"
    )


@main.command("parse-835")
@click.argument("input_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--output-dir", required=True, type=click.Path(file_okay=False, path_type=Path))
@click.option("--payer", default="co_medicaid_835", show_default=True, help="Payer configuration preset")
@click.option("--strict", is_flag=True, help="Promote warnings to errors")
@click.option("--include-raw-segments / --no-raw-segments", default=True, help="Embed raw segments in output JSON")
@click.option("--log-level", default="INFO", show_default=True, type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"]))
def parse_835(
    input_file: Path,
    output_dir: Path,
    payer: str,
    strict: bool,
    include_raw_segments: bool,
    log_level: str,
) -> None:
    """Parse an X12 835 ERA file into one JSON per claim payment."""
    # SP18: re-run setup so per-command --log-level overrides the
    # group default. ``setup_logging`` is idempotent.
    setup_logging(level=log_level)

    text = input_file.read_text()
    config = _payer_835(payer)

    try:
        result = parse_835_text(text, config, input_file=str(input_file))
    except CycloneParseError as exc:
        click.echo(f"PARSE ERROR: {exc}", err=True)
        sys.exit(2)

    if not result.claims:
        raise click.UsageError("No claim payments found in file") from None

    # Run validation; populate summary.passed/failed from the report.
    # 835 validation is batch-level (BPR/CLP/SVC balancing is cross-cutting),
    # so pass/fail applies uniformly to every claim payment in the batch:
    # when the batch passes, all N claims "passed" (passed=N, failed=0);
    # when the batch fails, all N claims "failed" (passed=0, failed=N).
    report = validate_835(result, config)
    if strict:
        promoted = [i.model_copy(update={"severity": "error"}) for i in report.warnings]
        new_errors = report.errors + promoted
        report = report.model_copy(update={"errors": new_errors, "passed": not new_errors})
    n = len(result.claims)
    claim_ids = [c.payer_claim_control_number for c in result.claims]
    if report.passed:
        passed, failed, failed_claim_ids = n, 0, []
    else:
        passed, failed, failed_claim_ids = 0, n, claim_ids
    result = result.model_copy(update={"validation": report})
    result.summary = result.summary.model_copy(update={
        "passed": passed,
        "failed": failed,
        "failed_claim_ids": failed_claim_ids,
        "issues_by_rule": _count_issues(report),
    })

    # Drop raw_segments if disabled
    if not include_raw_segments:
        result = result.model_copy(update={"claims": [c.model_copy(update={"raw_segments": []}) for c in result.claims]})

    write_outputs_835(result, output_dir)
    result.summary = result.summary.model_copy(update={"output_dir": str(output_dir)})
    (output_dir / "summary.json").write_text(result.summary.model_dump_json(indent=2))

    click.echo(
        f"parsed={result.summary.total_claims}  "
        f"passed={result.summary.passed}  "
        f"failed={result.summary.failed}  "
        f"output={output_dir}"
    )


def _count_issues(report) -> dict[str, int]:
    """Build an issues_by_rule dict from a ValidationReport (for the summary)."""
    counts: dict[str, int] = {}
    for issue in report.errors + report.warnings:
        counts[issue.rule] = counts.get(issue.rule, 0) + 1
    return counts


# ---------------------------------------------------------------------------
# SP20: `cyclone validate-npi` + `cyclone validate-tax-id`
#
# Pure local validators. No DB, no Keychain, no network — operators can
# run them on a developer laptop without standing up the full Cyclone
# stack. Exit code is 0 (valid) or 1 (invalid) so they compose with
# shell scripting / CI gates.
# ---------------------------------------------------------------------------


@main.command("validate-npi")
@click.argument("npi")
@click.option("--log-level", default="WARNING", show_default=True, type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"]))
def validate_npi_cmd(npi: str, log_level: str) -> None:
    """Validate a 10-digit NPI's Luhn checksum locally (SP20).

    Exit 0 if valid, 1 if not. No logging of the value itself — NPIs
    are PHI under HIPAA, so the operator's CLI history is the only
    audit trail.
    """
    # SP18: re-run so --log-level overrides the group default.
    setup_logging(level=log_level)
    from cyclone.npi import is_valid_npi
    if is_valid_npi(npi):
        click.echo(f"OK: {len(npi)}-digit NPI passes Luhn checksum")
        return
    click.echo(f"INVALID: {npi!r} fails NPI Luhn checksum", err=True)
    sys.exit(1)


@main.command("validate-tax-id")
@click.argument("tax_id")
@click.option("--log-level", default="WARNING", show_default=True, type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"]))
def validate_tax_id_cmd(tax_id: str, log_level: str) -> None:
    """Validate a 9-digit EIN's format + prefix locally (SP20).

    Accepts both ``XX-XXXXXXX`` and ``XXXXXXXXX``. Exit 0 if valid,
    1 if not. EIN is sensitive (PII), so we don't echo the value back
    on failure — only the validation verdict.
    """
    # SP18: re-run so --log-level overrides the group default.
    setup_logging(level=log_level)
    from cyclone.npi import is_valid_tax_id, normalize_tax_id
    plain = normalize_tax_id(tax_id)
    if plain is None:
        click.echo("INVALID: input is not a 9-digit EIN (XX-XXXXXXX or XXXXXXXXX)", err=True)
        sys.exit(1)
    if is_valid_tax_id(tax_id):
        click.echo(f"OK: 9-digit EIN (normalized={plain})")
        return
    click.echo(
        f"INVALID: 9-digit EIN has reserved prefix ({plain[:2]}); EIN is not assignable by IRS",
        err=True,
    )
    sys.exit(1)


if __name__ == "__main__":
    main()


# ---------------------------------------------------------------------------
# SP17: `cyclone backup` subcommands
#
# Operator-facing backup management. Mirrors the API surface but runs
# standalone (no FastAPI app needed) for cron / scripting / DR drills.
# Each subcommand initializes the DB + BackupService; if the
# service isn't configured (no Keychain passphrase etc.) the operator
# gets a clear error.
# ---------------------------------------------------------------------------


@main.group()
def backup() -> None:
    """Encrypted DB backup management (SP17)."""


@backup.command("init-passphrase")
@click.option("--passphrase", required=True, help="The passphrase to set (will prompt if omitted)")
@click.option("--from-stdin", is_flag=True, help="Read passphrase from stdin instead of the argument")
def backup_init_passphrase(passphrase: str, from_stdin: bool) -> None:
    """Set the backup encryption passphrase in the macOS Keychain.

    Generates a fresh salt and stores both the passphrase (account
    ``backup.passphrase``) and the salt (account ``backup.salt``)
    under service ``cyclone``. Cyclone's BackupService reads them
    at startup. If the passphrase account is missing, the service
    falls back to deriving a key from the SQLCipher DB key
    (degraded posture, logged at WARNING).
    """
    from cyclone import backup_service as svc_mod
    from cyclone import secrets as secrets_mod
    from getpass import getpass
    import os as _os

    if from_stdin:
        pp = getpass("Backup passphrase: ").strip()
        pp2 = getpass("Confirm: ").strip()
        if not pp or pp != pp2:
            click.echo("passphrase empty or mismatch", err=True)
            sys.exit(2)
    else:
        pp = passphrase

    if not pp or len(pp) < 12:
        click.echo("passphrase must be at least 12 characters", err=True)
        sys.exit(2)

    if not secrets_mod.set_secret(svc_mod.KEYCHAIN_BACKUP_PASSPHRASE_ACCOUNT, pp):
        click.echo("failed to store passphrase in Keychain", err=True)
        sys.exit(1)

    # Generate + persist a fresh salt. Same value must be used by
    # every subsequent invocation that uses this passphrase.
    salt = _os.urandom(16)
    if not secrets_mod.set_secret(svc_mod.KEYCHAIN_BACKUP_SALT_ACCOUNT, salt.hex()):
        click.echo(
            "WARN: passphrase stored but salt write failed; backups may be unrecoverable",
            err=True,
        )
        sys.exit(1)

    click.echo(
        f"passphrase stored in Keychain account {svc_mod.KEYCHAIN_BACKUP_PASSPHRASE_ACCOUNT!r}\n"
        f"salt stored in Keychain account {svc_mod.KEYCHAIN_BACKUP_SALT_ACCOUNT!r}"
    )


def _resolve_backup_dir(cli_override: str | None) -> "Path":
    """Resolve the backup directory: --backup-dir > $CYCLONE_BACKUP_DIR > default."""
    import os as _os
    from pathlib import Path as _Path
    from cyclone import db as db_mod
    if cli_override:
        return _Path(cli_override)
    env = _os.environ.get("CYCLONE_BACKUP_DIR")
    if env:
        return _Path(env)
    return _Path(db_mod.DEFAULT_DB_PATH.parent / "backups")


@backup.command("create")
@click.option("--backup-dir", default=None, help="Override CYCLONE_BACKUP_DIR (default: ~/.local/share/cyclone/backups)")
@click.option("--retention-days", default=None, type=int, help="Override CYCLONE_BACKUP_RETENTION_DAYS for this run's prune")
def backup_create(backup_dir: str | None, retention_days: int | None) -> None:
    """Take an encrypted backup right now."""
    from cyclone import db as db_mod
    from cyclone import backup_service as svc_mod
    from cyclone import secrets as secrets_mod

    db_mod.init_db()

    passphrase = secrets_mod.get_secret(svc_mod.KEYCHAIN_BACKUP_PASSPHRASE_ACCOUNT)
    salt_hex = secrets_mod.get_secret(svc_mod.KEYCHAIN_BACKUP_SALT_ACCOUNT)
    salt = bytes.fromhex(salt_hex) if salt_hex else None
    target_dir = _resolve_backup_dir(backup_dir)
    svc_mod.reset_backup_service_for_tests()
    svc = svc_mod.configure_backup_service(
        backup_dir=target_dir,
        passphrase=passphrase,
        salt=salt,
        retention_days=retention_days or 30,
    )
    result = svc.create_now()
    click.echo(
        f"created backup id={result.backup.id} filename={result.backup.filename} "
        f"size={result.backup.size_bytes}B fp={result.backup.db_fingerprint[:24]}..."
    )


@backup.command("list")
@click.option("--limit", default=50, show_default=True)
@click.option("--status", default=None, help="Filter: ok|error|pending|pruned")
def backup_list(limit: int, status: str | None) -> None:
    """List existing backups (newest first)."""
    from cyclone import db as db_mod
    from cyclone import backup_service as svc_mod
    from cyclone import secrets as secrets_mod

    db_mod.init_db()
    passphrase = secrets_mod.get_secret(svc_mod.KEYCHAIN_BACKUP_PASSPHRASE_ACCOUNT)
    salt_hex = secrets_mod.get_secret(svc_mod.KEYCHAIN_BACKUP_SALT_ACCOUNT)
    salt = bytes.fromhex(salt_hex) if salt_hex else None
    svc_mod.reset_backup_service_for_tests()
    svc = svc_mod.configure_backup_service(
        backup_dir=_resolve_backup_dir(None),
        passphrase=passphrase,
        salt=salt,
    )
    rows = svc.list_backups(limit=limit, status=status)
    if not rows:
        click.echo("(no backups)")
        return
    for r in rows:
        click.echo(
            f"{r.id:4d}  {r.status:7s}  {r.created_at.isoformat() if r.created_at else '-'}  "
            f"{r.size_bytes:>10d}B  {r.filename}  fp={r.db_fingerprint[:24] or '-':<24}"
        )


@backup.command("verify")
@click.argument("backup_id", type=int)
def backup_verify(backup_id: int) -> None:
    """Decrypt + checksum-verify a backup."""
    from cyclone import db as db_mod
    from cyclone import backup_service as svc_mod
    from cyclone import secrets as secrets_mod

    db_mod.init_db()
    passphrase = secrets_mod.get_secret(svc_mod.KEYCHAIN_BACKUP_PASSPHRASE_ACCOUNT)
    salt_hex = secrets_mod.get_secret(svc_mod.KEYCHAIN_BACKUP_SALT_ACCOUNT)
    salt = bytes.fromhex(salt_hex) if salt_hex else None
    svc_mod.reset_backup_service_for_tests()
    svc = svc_mod.configure_backup_service(
        backup_dir=_resolve_backup_dir(None),
        passphrase=passphrase,
        salt=salt,
    )
    v = svc.verify(backup_id)
    if v.ok:
        click.echo(f"OK: id={v.backup_id} fp={v.actual_fingerprint[:24]}... table_count={v.table_count}")
        return
    click.echo(
        f"FAIL: id={v.backup_id} reason={v.reason} "
        f"expected={v.expected_fingerprint[:24] if v.expected_fingerprint else '-'}... "
        f"actual={v.actual_fingerprint[:24] if v.actual_fingerprint else '-'}...",
        err=True,
    )
    sys.exit(1)


@backup.command("restore")
@click.argument("backup_id", type=int)
@click.option("--yes", is_flag=True, help="Skip the interactive confirm prompt")
@click.option("--actor", default="operator-cli", show_default=True)
def backup_restore(backup_id: int, yes: bool, actor: str) -> None:
    """Restore the live DB from a backup (two-step, requires --yes)."""
    from cyclone import db as db_mod
    from cyclone import backup_service as svc_mod
    from cyclone import secrets as secrets_mod

    db_mod.init_db()
    passphrase = secrets_mod.get_secret(svc_mod.KEYCHAIN_BACKUP_PASSPHRASE_ACCOUNT)
    salt_hex = secrets_mod.get_secret(svc_mod.KEYCHAIN_BACKUP_SALT_ACCOUNT)
    salt = bytes.fromhex(salt_hex) if salt_hex else None
    svc_mod.reset_backup_service_for_tests()
    svc = svc_mod.configure_backup_service(
        backup_dir=_resolve_backup_dir(None),
        passphrase=passphrase,
        salt=salt,
    )

    click.echo(f"Initiating restore from backup {backup_id}...")
    init = svc.restore_initiate(backup_id)
    click.echo(
        f"  backup:    {init.filename} ({init.size_bytes} bytes)\n"
        f"  fp:        {init.db_fingerprint[:24]}...\n"
        f"  tables:    {init.table_count}\n"
        f"  current:   fp={init.current_db_fingerprint[:24] if init.current_db_fingerprint else '-'}... "
        f"tables={init.current_table_count}\n"
        f"  token ttl: {(init.expires_at - __import__('datetime').datetime.now(__import__('datetime').timezone.utc)).total_seconds():.0f}s"
    )

    if not yes:
        click.confirm(
            "Replace the live DB with this backup? "
            "This will dispose the engine and rebuild it.",
            abort=True,
        )

    click.echo("Confirming restore...")
    result = svc.restore_confirm(backup_id, init.restore_token, actor=actor)
    click.echo(
        f"OK: restored from fp={result.restored_from_fingerprint[:24]}... "
        f"to fp={result.new_db_fingerprint[:24]}... at {result.restored_at.isoformat()}"
    )


@backup.command("prune")
@click.option("--retention-days", default=None, type=int)
@click.option("--yes", is_flag=True, help="Skip the confirm prompt")
def backup_prune(retention_days: int | None, yes: bool) -> None:
    """Apply the retention policy (delete old backups)."""
    from cyclone import db as db_mod
    from cyclone import backup_service as svc_mod
    from cyclone import secrets as secrets_mod

    db_mod.init_db()
    passphrase = secrets_mod.get_secret(svc_mod.KEYCHAIN_BACKUP_PASSPHRASE_ACCOUNT)
    salt_hex = secrets_mod.get_secret(svc_mod.KEYCHAIN_BACKUP_SALT_ACCOUNT)
    salt = bytes.fromhex(salt_hex) if salt_hex else None
    svc_mod.reset_backup_service_for_tests()
    svc = svc_mod.configure_backup_service(
        backup_dir=_resolve_backup_dir(None),
        passphrase=passphrase,
        salt=salt,
        retention_days=retention_days or 30,
    )
    if not yes:
        click.confirm(
            f"Delete all backups older than {svc._retention_days} days?",
            abort=True,
        )
    deleted = svc.prune()
    click.echo(f"Deleted {len(deleted)} file(s):")
    for p in deleted:
        click.echo(f"  {p}")


@backup.command("status")
def backup_status() -> None:
    """Print the backup subsystem status snapshot."""
    from cyclone import db as db_mod
    from cyclone import backup_service as svc_mod
    from cyclone import secrets as secrets_mod

    db_mod.init_db()
    passphrase = secrets_mod.get_secret(svc_mod.KEYCHAIN_BACKUP_PASSPHRASE_ACCOUNT)
    salt_hex = secrets_mod.get_secret(svc_mod.KEYCHAIN_BACKUP_SALT_ACCOUNT)
    salt = bytes.fromhex(salt_hex) if salt_hex else None
    svc_mod.reset_backup_service_for_tests()
    svc = svc_mod.configure_backup_service(
        backup_dir=_resolve_backup_dir(None),
        passphrase=passphrase,
        salt=salt,
    )
    snap = svc.status()
    import json
    click.echo(json.dumps(snap, indent=2, default=str))
