"""CLI subcommand: vaibify reproduce.

Read-only verification of the AICS Level 3 reproducibility envelope
inside a project repository. Walks five tiers in sequence:

* Tier 1 — byte-exact artefact integrity via ``MANIFEST.sha256``.
* Tier 2 — hash-pinned Python dependency install via
  ``requirements.lock``.
* Tier 3 — container image digest pull via
  ``.vaibify/environment.json``.
* Tier 4 — L3 artifact coherence (six readiness verifiers).
* Tier 5 — opt-in rebuild and hash compare via ``--rerun``. Writes
  ``.vaibify/l3_attestation.json`` on completion (pass or fail) and
  archives a copy to ``.vaibify/l3_attestations/``.

The command never modifies files inside the project repo except for
the attestation files (Tier 5) and ``pip install`` updating the
user's Python environment (Tier 2).
"""

import json
import os
import re
import shutil
import subprocess
import sys
import time
from pathlib import Path

import click

from vaibify.reproducibility import manifestWriter
from vaibify.reproducibility.l3Attestation import (
    S_STATUS_FAILED,
    S_STATUS_PASSED,
    fdictBuildAttestation,
    fnWriteAttestation,
    fsCurrentManifestDigest,
)
from vaibify.reproducibility.levelGates import (
    fbVerifyDependencyLock,
    fbVerifyDeterminismDeclared,
    fbVerifyDockerfilePinned,
    fbVerifyEnvironmentSnapshot,
    fbVerifyManifestComplete,
    fbVerifyReproduceScript,
)
from vaibify.reproducibility.manifestWriter import flistVerifyManifest


__all__ = [
    "reproduce",
    "fbVerifyTier1",
    "fbVerifyTier2",
    "fbVerifyTier3",
    "fbVerifyTier4",
    "fbRerunWorkflow",
    "fbIsValidImageDigest",
]


_S_MANIFEST_FILENAME = "MANIFEST.sha256"
_S_LOCK_FILENAME = "requirements.lock"
_S_ENVIRONMENT_RELATIVE = ".vaibify/environment.json"

# Conservative whitelist for OCI image references that may include a
# digest (registry/repo@sha256:<hex>) or a tag (registry/repo:tag).
# Forbids whitespace and shell metacharacters; subprocess uses argv-form
# already, so this is defense-in-depth for log readability and to catch
# malformed environment.json payloads early.
_REGEX_VALID_IMAGE_REFERENCE = re.compile(
    r"^[A-Za-z0-9][A-Za-z0-9._\-/:@]{0,511}$"
)


def fbIsValidImageDigest(sImageDigest):
    """Return True when ``sImageDigest`` matches the conservative whitelist."""
    if not isinstance(sImageDigest, str):
        return False
    if not sImageDigest:
        return False
    return bool(_REGEX_VALID_IMAGE_REFERENCE.match(sImageDigest))


def _fnPrintHeader(sLabel, sDescription):
    """Print a leading ``[N/5] description`` banner without a newline."""
    click.echo(f"[{sLabel}] {sDescription} ", nl=False)


def _fnPrintPass(sDetails):
    """Print a checkmark plus optional summary text after a tier banner."""
    click.echo(f"... {sDetails} OK")


def _fnPrintFail(sDetails):
    """Print a failure marker plus diagnostic text after a tier banner."""
    click.echo(f"... {sDetails} FAIL")


def _fnAbortMissingFile(sFilename, sProjectRepo):
    """Emit a usage-error message naming a missing repo file and exit 2."""
    click.echo(
        f"Error: required file '{sFilename}' not found in '{sProjectRepo}'."
    )
    sys.exit(2)


def fbVerifyTier1(sProjectRepo):
    """Verify ``MANIFEST.sha256`` against artefacts on disk.

    Calls :func:`flistVerifyManifest` and reports the count of clean
    entries vs. mismatches. Returns ``True`` when the manifest is
    fully consistent. Exits with code 2 when the manifest file does
    not exist. When the workflow declares paths the manifest does not
    pin (a legacy manifest written before scripts and standards joined
    the envelope), the count is reported but the tier still passes —
    the user is told their coverage is partial so they can re-run.
    """
    pathManifest = Path(sProjectRepo) / _S_MANIFEST_FILENAME
    if not pathManifest.is_file():
        _fnAbortMissingFile(_S_MANIFEST_FILENAME, sProjectRepo)
    _fnPrintHeader(
        f"1/{_S_TIER_DENOMINATOR}",
        "Verifying file integrity (MANIFEST.sha256)",
    )
    listMismatches = flistVerifyManifest(sProjectRepo)
    iEntries = manifestWriter.fiCountManifestEntries(sProjectRepo)
    if not listMismatches:
        _fnPrintPass(f"{iEntries}/{iEntries}")
        _fnReportIncompleteCoverage(sProjectRepo)
        return True
    _fnPrintFail(f"{len(listMismatches)} mismatch(es)")
    _fnReportMismatches(listMismatches)
    return False


def _fnReportIncompleteCoverage(sProjectRepo):
    """Print an advisory line when any workflow declares paths the manifest omits.

    Aggregates across every workflow.json under ``.vaibify/workflows/``
    so multi-workflow projects are not silently skipped. The manifest
    is one file pinning artefacts across the whole project repo, so
    the completeness check is project-wide.
    """
    dictAggregate = _fdictAggregateAllWorkflows(sProjectRepo)
    if dictAggregate is None:
        return
    listIncomplete = manifestWriter.flistDeclaredButMissingFromManifest(
        sProjectRepo, dictAggregate,
    )
    if listIncomplete:
        click.echo(
            f"  warning: {len(listIncomplete)} workflow path(s) not "
            f"in manifest (first: {listIncomplete[0]}); re-run to "
            "refresh coverage."
        )


def _fdictAggregateAllWorkflows(sProjectRepo):
    """Return a synthetic workflow whose listSteps unions every workflow's steps.

    Carries forward the first non-empty ``dictDeterminism`` block so
    Tier 4's determinism check can read it; the project ladders L3 as
    a whole, not per-workflow. Returns ``None`` when no readable
    workflow.json files exist so the coverage check is skipped rather
    than reported as empty.
    """
    pathWorkflows = Path(sProjectRepo) / ".vaibify" / "workflows"
    if not pathWorkflows.is_dir():
        return None
    listAllSteps = []
    dictDeterminismMerged = {}
    for pathFile in sorted(pathWorkflows.glob("*.json")):
        dictWorkflow = _fdictLoadWorkflowFile(pathFile)
        if dictWorkflow is None:
            continue
        listAllSteps.extend(dictWorkflow.get("listSteps", []) or [])
        dictExtra = dictWorkflow.get("dictDeterminism") or {}
        if dictExtra and not dictDeterminismMerged:
            dictDeterminismMerged = dict(dictExtra)
    if not listAllSteps and not dictDeterminismMerged:
        return None
    dictResult = {"listSteps": listAllSteps}
    if dictDeterminismMerged:
        dictResult["dictDeterminism"] = dictDeterminismMerged
    return dictResult


def _fdictLoadWorkflowFile(pathFile):
    """Parse one workflow.json or return None on read/parse failure."""
    try:
        with open(pathFile, "r", encoding="utf-8") as fileHandle:
            return json.load(fileHandle)
    except (OSError, json.JSONDecodeError):
        return None


def _fnReportMismatches(listMismatches):
    """Print one diagnostic line per manifest mismatch."""
    for dictMismatch in listMismatches:
        sActual = dictMismatch["sActual"] or "<missing>"
        click.echo(
            f"  {dictMismatch['sPath']}: expected "
            f"{dictMismatch['sExpected'][:12]}..., got {sActual[:12]}..."
        )


def fbVerifyTier2(sProjectRepo):
    """Install hash-pinned dependencies from ``requirements.lock``.

    Runs ``<python> -m pip install --require-hashes -r
    requirements.lock`` via subprocess and streams the output to the
    user. When ``pip`` exits with a hash-related error and ``uv`` is
    on PATH, retries via ``uv pip install --require-hashes`` so users
    on uv-only environments are not stranded. Returns ``True`` only
    when an install command exits zero. Exits with code 2 when the
    lockfile is absent.
    """
    pathLock = Path(sProjectRepo) / _S_LOCK_FILENAME
    if not pathLock.is_file():
        _fnAbortMissingFile(_S_LOCK_FILENAME, sProjectRepo)
    _fnPrintHeader(
        f"2/{_S_TIER_DENOMINATOR}",
        "Reproducing Python env (requirements.lock)",
    )
    iReturnCode, sStderr = _fiRunPipInstall(pathLock)
    if iReturnCode == 0:
        _fnPrintPass("hashes verified")
        return True
    if _fbShouldFallbackToUv(sStderr):
        return _fbRunUvFallback(pathLock)
    _fnPrintFail("pip install failed")
    click.echo(sStderr.rstrip())
    return False


def _fiRunPipInstall(pathLock):
    """Invoke ``pip install --require-hashes`` and return (returncode, stderr)."""
    saCommand = [
        sys.executable, "-m", "pip", "install",
        "--require-hashes", "-r", str(pathLock),
    ]
    completed = subprocess.run(saCommand, capture_output=True, text=True)
    sys.stdout.write(completed.stdout)
    return completed.returncode, completed.stderr


def _fbShouldFallbackToUv(sStderr):
    """Return True when uv is available and stderr suggests a hash failure."""
    if shutil.which("uv") is None:
        return False
    sLower = sStderr.lower()
    return "hash" in sLower


def _fbRunUvFallback(pathLock):
    """Retry the install through ``uv pip install --require-hashes``."""
    saCommand = [
        "uv", "pip", "install",
        "--require-hashes", "-r", str(pathLock),
    ]
    completed = subprocess.run(saCommand, capture_output=True, text=True)
    sys.stdout.write(completed.stdout)
    if completed.returncode == 0:
        _fnPrintPass("hashes verified (uv)")
        return True
    _fnPrintFail("uv install failed")
    click.echo(completed.stderr.rstrip())
    return False


def fbVerifyTier3(sProjectRepo):
    """Pull the pinned container image recorded in ``environment.json``.

    Reads ``.vaibify/environment.json``, extracts ``sImageDigest``,
    and runs ``docker pull <image_digest>``. Returns ``True`` when
    the pull succeeds. Exits with code 2 when ``environment.json``
    is missing or the digest field is unset.
    """
    pathEnvironment = Path(sProjectRepo) / _S_ENVIRONMENT_RELATIVE
    if not pathEnvironment.is_file():
        _fnAbortMissingFile(_S_ENVIRONMENT_RELATIVE, sProjectRepo)
    _fnPrintHeader(
        f"3/{_S_TIER_DENOMINATOR}",
        "Pulling pinned container image",
    )
    sImageDigest = _fsLoadImageDigest(pathEnvironment, sProjectRepo)
    completed = subprocess.run(
        ["docker", "pull", sImageDigest],
        capture_output=True, text=True,
    )
    sys.stdout.write(completed.stdout)
    if completed.returncode == 0:
        _fnPrintPass(sImageDigest)
        return True
    _fnPrintFail("docker pull failed")
    click.echo(completed.stderr.rstrip())
    return False


def _fsLoadImageDigest(pathEnvironment, sProjectRepo):
    """Return the ``sImageDigest`` recorded in environment.json or exit 2."""
    with open(pathEnvironment, "r", encoding="utf-8") as fileHandle:
        dictEnvironment = json.load(fileHandle)
    sImageDigest = dictEnvironment.get("sImageDigest")
    if not sImageDigest:
        click.echo(
            "Error: 'sImageDigest' is missing from "
            f"'{_S_ENVIRONMENT_RELATIVE}' in '{sProjectRepo}'."
        )
        sys.exit(2)
    if not fbIsValidImageDigest(sImageDigest):
        click.echo(
            "Error: 'sImageDigest' in "
            f"'{_S_ENVIRONMENT_RELATIVE}' is not a valid OCI "
            "image reference."
        )
        sys.exit(2)
    return sImageDigest


def fbVerifyTier4(sProjectRepo):
    """Verify the six AICS L3 readiness checks against the envelope.

    Reuses the host-side ``levelGates`` verifiers so the dashboard
    and the CLI agree on what "L3-ready" means. Returns True iff every
    verifier passes; on failure prints a per-verifier checklist so
    the user sees which artefacts need regenerating.
    """
    dictWorkflow = _fdictAggregateAllWorkflows(sProjectRepo) or {
        "listSteps": [],
    }
    _fnPrintHeader(
        f"4/{_S_TIER_DENOMINATOR}",
        "Verifying L3 artifact coherence",
    )
    listResults = _flistRunReadinessVerifiers(
        sProjectRepo, dictWorkflow,
    )
    iPassed = sum(1 for tEntry in listResults if tEntry[1])
    iTotal = len(listResults)
    if iPassed == iTotal:
        _fnPrintPass(f"{iPassed}/{iTotal}")
        for sLabel, _bPassed in listResults:
            click.echo(f"       - {sLabel}: OK")
        return True
    _fnPrintFail(f"{iPassed}/{iTotal}")
    for sLabel, bPassed in listResults:
        sStatus = "OK" if bPassed else "FAIL"
        click.echo(f"       - {sLabel}: {sStatus}")
    return False


def _flistRunReadinessVerifiers(sProjectRepo, dictWorkflow):
    """Return ``[(label, bool)]`` for each L3 readiness verifier in order."""
    return [
        ("Manifest complete",
         fbVerifyManifestComplete(sProjectRepo, dictWorkflow)),
        ("Dependency lock",
         fbVerifyDependencyLock(sProjectRepo)),
        ("Environment snapshot digest-form",
         fbVerifyEnvironmentSnapshot(sProjectRepo)),
        ("Dockerfile pinned",
         fbVerifyDockerfilePinned(sProjectRepo)),
        ("reproduce.sh present + in manifest",
         fbVerifyReproduceScript(sProjectRepo, dictWorkflow)),
        ("Determinism declared",
         fbVerifyDeterminismDeclared(sProjectRepo, dictWorkflow)),
    ]


def fbRerunWorkflow(sProjectRepo):
    """Re-run the workflow end to end against a running container.

    The project is resolved from ``sProjectRepo`` (the value of
    ``--repo`` or the current working directory when the flag is
    absent), requires a running container, and invokes the same
    pipeline runner that ``vaibify run`` uses. Returns True on
    success, False on any failure (configuration, missing container,
    non-zero pipeline exit). Both ``Exception`` and ``SystemExit``
    are caught so a registry miss inside ``fconfigResolveProject``
    (which calls ``sys.exit(1)``) does not short-circuit the
    surrounding ``vaibify reproduce`` exit-code logic.
    """
    _fnPrintHeader(
        f"5/{_S_TIER_DENOMINATOR}",
        "Re-running workflow",
    )
    try:
        return _fbInvokePipelineRunner(sProjectRepo)
    except (Exception, SystemExit) as error:
        click.echo(f"... failed to invoke pipeline runner: {error}")
        return False


def _fbInvokePipelineRunner(sProjectRepo):
    """Invoke commandRun's pipeline machinery against the resolved project."""
    from .configLoader import fconfigResolveProject
    from .commandUtilsDocker import (
        fconnectionRequireDocker,
        fsRequireRunningContainer,
    )
    from .commandRun import _fiRunPipeline
    configProject = _fconfigResolveProjectAtRepo(
        sProjectRepo, fconfigResolveProject,
    )
    connectionDocker = fconnectionRequireDocker()
    sContainerName = fsRequireRunningContainer(configProject)
    iExitCode = _fiRunPipeline(
        connectionDocker, sContainerName, None, None,
    )
    if iExitCode != 0:
        click.echo(f"... pipeline runner exited with code {iExitCode}")
        return False
    click.echo("... workflow re-ran successfully ✓")
    return True


def _fconfigResolveProjectAtRepo(sProjectRepo, fconfigResolveProject):
    """Run ``fconfigResolveProject(None)`` with cwd pinned to ``sProjectRepo``.

    ``fconfigResolveProject`` resolves from ``Path.cwd()`` when no
    project name is given, so without this guard the runner would
    silently ignore ``--repo``. The original cwd is always restored.
    The repo path is resolved and validated as an existing directory
    before chdir to avoid surprising behavior when ``--repo`` points at
    a non-directory (e.g., a regular file or a missing path).
    """
    pathResolved = Path(sProjectRepo).resolve()
    if not pathResolved.is_dir():
        raise FileNotFoundError(
            f"--repo target is not an existing directory: {sProjectRepo}"
        )
    sOriginalCwd = os.getcwd()
    try:
        os.chdir(str(pathResolved))
        return fconfigResolveProject(None)
    finally:
        os.chdir(sOriginalCwd)


# Tier registry: single source of truth for the 5-tier reproduce ladder.
# Each entry is (sLabel, sDescription, fnVerify, bRequiresRerun). A future
# Tier 6 (e.g. external attestor co-sign) is added by appending one tuple;
# every label and print site is derived from this list so the denominator
# stays in sync.
_LIST_TIERS = (
    ("1", "manifest present + clean", fbVerifyTier1, False),
    ("2", "lockfile parity",          fbVerifyTier2, False),
    ("3", "image digest pinned",      fbVerifyTier3, False),
    ("4", "L3 artifact coherence",    fbVerifyTier4, False),
    ("5", "byte-identical rerun",     fbRerunWorkflow, True),
)
_T_TIER_CHOICES = tuple(
    sLabel for sLabel, _sDescription, _fnVerify, bRequiresRerun in _LIST_TIERS
    if not bRequiresRerun
)
_S_TIER_DENOMINATOR = str(len(_LIST_TIERS))


def _fbDispatchTier(sTier, sProjectRepo, setSkipTiers):
    """Run a single tier and return its True/False outcome (or True if skipped)."""
    if sTier in setSkipTiers:
        click.echo(f"[{sTier}/{_S_TIER_DENOMINATOR}] skipped")
        return True
    for sLabel, _sDescription, fnVerify, bRequiresRerun in _LIST_TIERS:
        if sLabel == sTier and not bRequiresRerun:
            return fnVerify(sProjectRepo)
    return True


def _fnEmitFinalSummary(bAllPassed, bRerun, bAttestationWritten):
    """Print the trailing success or advisory line.

    The four-state matrix is: rerun=yes/no × all-passed=yes/no.
    Without ``--rerun`` we never write attestation, so the "ready"
    success line tells the user what to do next.
    """
    click.echo("")
    if bAllPassed and bRerun and bAttestationWritten:
        click.echo("L3 reproduction confirmed and attested.")
        return
    if bAllPassed and not bRerun:
        click.echo(
            "L3 reproduction ready "
            "(no attestation on file — run --rerun to attest)."
        )
        return
    click.echo("L3 reproduction failed; see tier output above.")


def _fdictBuildRerunAttestation(sProjectRepo, bRerunPassed, fDuration):
    """Return the attestation dict describing a rerun outcome."""
    iTotalEntries = _fiManifestEntryCount(sProjectRepo)
    return fdictBuildAttestation(
        sStatus=S_STATUS_PASSED if bRerunPassed else S_STATUS_FAILED,
        sManifestDigest=fsCurrentManifestDigest(sProjectRepo),
        sImageDigest=_fsRecordedImageDigest(sProjectRepo),
        fDurationSeconds=fDuration,
        iOutputHashesMatched=iTotalEntries if bRerunPassed else 0,
        iOutputHashesTotal=iTotalEntries,
        listDivergedHashes=[] if bRerunPassed else [
            "rerun pipeline exited non-zero"
        ],
        sRunLogPath="",
    )


def _fbWriteAttestationFromRun(sProjectRepo, bRerunPassed, fDuration):
    """Persist an L3 attestation reflecting the rerun outcome.

    Called only when ``--rerun`` ran end-to-end so the attestation
    records an actual rebuild attempt (not just envelope coherence).
    Failures are recorded with the same schema as passes so the
    history table can show "last rebuild failed".
    """
    dictAttestation = _fdictBuildRerunAttestation(
        sProjectRepo, bRerunPassed, fDuration,
    )
    try:
        fnWriteAttestation(sProjectRepo, dictAttestation)
    except OSError as error:
        click.echo(f"  warning: could not persist attestation: {error}")
        return False
    return True


def _fsRecordedImageDigest(sProjectRepo):
    """Return the image digest recorded in environment.json, or empty."""
    pathEnvironment = Path(sProjectRepo) / _S_ENVIRONMENT_RELATIVE
    if not pathEnvironment.is_file():
        return ""
    try:
        with open(pathEnvironment, "r", encoding="utf-8") as fileHandle:
            dictPayload = json.load(fileHandle)
    except (OSError, json.JSONDecodeError):
        return ""
    dictContainer = dictPayload.get("dictContainer")
    if isinstance(dictContainer, dict):
        sNested = dictContainer.get("sImageDigest") or ""
        if sNested:
            return sNested
    return dictPayload.get("sImageDigest") or ""


def _fiManifestEntryCount(sProjectRepo):
    """Return the manifest entry count, treating absence as zero."""
    try:
        return manifestWriter.fiCountManifestEntries(sProjectRepo)
    except (FileNotFoundError, OSError, ValueError):
        return 0


def _ftRunRerunTier(sProjectRepo):
    """Execute tier 5 (rerun) and return ``(bPassed, bAttestationWritten)``."""
    fStarted = time.monotonic()
    bRerunPassed = fbRerunWorkflow(sProjectRepo)
    bAttestationWritten = _fbWriteAttestationFromRun(
        sProjectRepo, bRerunPassed,
        time.monotonic() - fStarted,
    )
    return bRerunPassed, bAttestationWritten


@click.command("reproduce")
@click.option(
    "--repo", "sRepo", default=None,
    type=click.Path(file_okay=False, dir_okay=True),
    help="Path to the project repo (defaults to the current directory).",
)
@click.option(
    "--rerun/--no-rerun", "bRerun", default=False,
    help="Also re-run the workflow (step 5) and write an L3 "
         "attestation. Off by default.",
)
@click.option(
    "--skip-tier", "saSkipTier", multiple=True,
    type=click.Choice(_T_TIER_CHOICES),
    help="Skip the given tier (1, 2, 3, or 4). May be repeated.",
)
def reproduce(sRepo, bRerun, saSkipTier):
    """Verify a project's AICS L3 reproducibility envelope."""
    sProjectRepo = sRepo or str(Path.cwd())
    setSkipTiers = set(saSkipTier)
    bAllPassed = True
    for sTier in _T_TIER_CHOICES:
        if not _fbDispatchTier(sTier, sProjectRepo, setSkipTiers):
            bAllPassed = False
    bAttestationWritten = False
    if bRerun:
        bRerunPassed, bAttestationWritten = _ftRunRerunTier(sProjectRepo)
        if not bRerunPassed:
            bAllPassed = False
    else:
        click.echo(
            f"[5/{_S_TIER_DENOMINATOR}] "
            "Re-running workflow ... skipped (use --rerun)"
        )
    _fnEmitFinalSummary(bAllPassed, bRerun, bAttestationWritten)
    if not bAllPassed:
        sys.exit(1)
