# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========

import asyncio
import os
import uuid

import boto3
import pytest

from mirage.core.s3.write import write_bytes
from mirage.resource.s3 import S3Config, S3Resource
from mirage.types import DriftPolicy, MountMode, PathSpec
from mirage.workspace import Workspace
from mirage.workspace.snapshot import ContentDriftError


def _spec(key: str, virtual: str) -> PathSpec:
    """The operand write_bytes expects; it reads only ``mount_path``.

    Passing the bare string was an AttributeError waiting for a live
    versioned bucket -- this test skips without one, so nothing caught it.
    """
    return PathSpec(resource_path=key,
                    virtual=virtual,
                    directory="/s3",
                    pattern=None,
                    resolved=True)


LIVE_BUCKET = os.environ.get("MIRAGE_LIVE_S3_BUCKET") or os.environ.get(
    "AWS_S3_BUCKET")
LIVE_REGION = os.environ.get("AWS_DEFAULT_REGION", "us-east-1")
LIVE_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID")
LIVE_SECRET = os.environ.get("AWS_SECRET_ACCESS_KEY")

_LIVE_READY = bool(LIVE_BUCKET and LIVE_KEY_ID and LIVE_SECRET)

pytestmark = pytest.mark.skipif(
    not _LIVE_READY,
    reason=("set MIRAGE_LIVE_S3_BUCKET (or AWS_S3_BUCKET) plus "
            "AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY to run live S3 tests"),
)


def _config() -> S3Config:
    return S3Config(
        bucket=LIVE_BUCKET,
        region=LIVE_REGION,
        aws_access_key_id=LIVE_KEY_ID,
        aws_secret_access_key=LIVE_SECRET,
    )


def _boto_client():
    return boto3.client(
        "s3",
        region_name=LIVE_REGION,
        aws_access_key_id=LIVE_KEY_ID,
        aws_secret_access_key=LIVE_SECRET,
    )


def _versioning_enabled() -> bool:
    try:
        resp = _boto_client().get_bucket_versioning(Bucket=LIVE_BUCKET)
    except Exception:
        return False
    return resp.get("Status") == "Enabled"


def _probe_key() -> str:
    return f"mirage-drift-probe-{uuid.uuid4().hex[:8]}.txt"


def _mount(prefix: str = "/s3/") -> dict:
    return {prefix: (S3Resource(_config()), MountMode.WRITE)}


def _override(prefix: str = "/s3/") -> dict:
    return {prefix: S3Resource(_config())}


def _cleanup_key(key: str) -> None:
    client = _boto_client()
    try:
        if _versioning_enabled():
            versions = client.list_object_versions(Bucket=LIVE_BUCKET,
                                                   Prefix=key).get(
                                                       "Versions", [])
            for v in versions:
                if v.get("Key") == key:
                    client.delete_object(Bucket=LIVE_BUCKET,
                                         Key=key,
                                         VersionId=v["VersionId"])
            markers = client.list_object_versions(Bucket=LIVE_BUCKET,
                                                  Prefix=key).get(
                                                      "DeleteMarkers", [])
            for m in markers:
                if m.get("Key") == key:
                    client.delete_object(Bucket=LIVE_BUCKET,
                                         Key=key,
                                         VersionId=m["VersionId"])
        else:
            client.delete_object(Bucket=LIVE_BUCKET, Key=key)
    except Exception:
        pass


def test_live_strict_raises_on_etag_drift(tmp_path):
    """Snapshot with a recorded read, mutate the live object via boto so
    its ETag changes, load with STRICT: the eager drift check must raise
    ContentDriftError before any user-visible read.

    Requires versioning OFF: on a versioned bucket, stat() captures a
    revision and STRICT installs a pin (which intentionally bypasses
    drift detection). That path is covered by the version-pin test.
    """
    if _versioning_enabled():
        pytest.skip(f"bucket {LIVE_BUCKET} is versioned; STRICT will pin "
                    "instead of drift-checking. See the version-pin test "
                    "for the versioned path.")
    key = _probe_key()
    probe = f"/s3/{key}"
    client = _boto_client()
    client.put_object(Bucket=LIVE_BUCKET, Key=key, Body=b"v1 bytes\n")
    try:
        src = Workspace(_mount(), mode=MountMode.WRITE)
        result = asyncio.run(src.execute(f"cat {probe}"))
        assert b"v1 bytes" in result.stdout

        snap = tmp_path / "drift.tar"
        asyncio.run(src.snapshot(snap))

        client.put_object(Bucket=LIVE_BUCKET, Key=key, Body=b"v2 mutated\n")

        dst = Workspace.load(snap, resources=_override())
        with pytest.raises(ContentDriftError) as exc_info:
            asyncio.run(dst.execute(f"cat {probe}"))
        assert exc_info.value.path == probe
        assert (exc_info.value.snapshot_fingerprint
                != exc_info.value.live_fingerprint)
    finally:
        _cleanup_key(key)


def test_live_no_drift_passes(tmp_path):
    """Control: snapshot + load with identical bytes on the bucket must
    succeed. Guards against false-positive drift on the live ETag path.
    """
    key = _probe_key()
    probe = f"/s3/{key}"
    client = _boto_client()
    client.put_object(Bucket=LIVE_BUCKET, Key=key, Body=b"stable\n")
    try:
        src = Workspace(_mount(), mode=MountMode.WRITE)
        asyncio.run(src.execute(f"cat {probe}"))

        snap = tmp_path / "stable.tar"
        asyncio.run(src.snapshot(snap))

        dst = Workspace.load(snap, resources=_override())
        result = asyncio.run(dst.execute(f"cat {probe}"))
        assert b"stable" in result.stdout
    finally:
        _cleanup_key(key)


def test_live_pin_records_agent_version_not_snapshot_time_version(tmp_path):
    """Race-fix regression test.

    Agent reads V1 at T1. Between T1 and snapshot at T3, an external
    actor mutates the object to V2. Old design (live stat at snapshot
    time) would capture V2's VersionId and pin replay to V2, serving
    bytes the agent never saw. New design records VersionId at READ
    time (from the GET response headers), so the snapshot pins to V1
    and serves V1 bytes on cache miss.

    Skipped on non-versioned buckets (no revision = no pin).
    """
    if not _versioning_enabled():
        pytest.skip("requires versioning")

    key = _probe_key()
    probe = f"/s3/{key}"
    client = _boto_client()
    client.put_object(Bucket=LIVE_BUCKET, Key=key, Body=b"v1\n")
    try:
        src = Workspace(_mount(), mode=MountMode.WRITE)
        result = asyncio.run(src.execute(f"cat {probe}"))
        assert b"v1" in result.stdout

        # Race: upstream changes BEFORE snapshot fires
        client.put_object(Bucket=LIVE_BUCKET, Key=key, Body=b"v2-racy\n")

        snap = tmp_path / "racy.tar"
        asyncio.run(src.snapshot(snap))

        dst = Workspace.load(snap, resources=_override())
        dst._cache.evict_paths([probe])
        result = asyncio.run(dst.execute(f"cat {probe}"))
        assert result.stdout == b"v1\n", (
            f"snapshot pinned the wrong VersionId; served {result.stdout!r} "
            "instead of the V1 the agent actually saw")
    finally:
        _cleanup_key(key)


def test_live_version_pin_serves_original_on_versioned_bucket(tmp_path):
    """End-to-end pin: with bucket versioning enabled, snapshot captures
    the live VersionId. After the bucket head moves on, STRICT load
    serves the original recorded bytes via the pin, not the current
    head, and does not raise.

    Skipped (xfail) when the bucket is not versioned; the pin path
    requires real S3 versioning to exercise.
    """
    if not _versioning_enabled():
        pytest.skip(
            f"bucket {LIVE_BUCKET} does not have versioning enabled; "
            "run `aws s3api put-bucket-versioning --bucket "
            f"{LIVE_BUCKET} --versioning-configuration Status=Enabled` "
            "to exercise the pin path")

    key = _probe_key()
    probe = f"/s3/{key}"
    client = _boto_client()
    client.put_object(Bucket=LIVE_BUCKET, Key=key, Body=b"original\n")
    try:
        src = Workspace(_mount(), mode=MountMode.WRITE)
        result = asyncio.run(src.execute(f"cat {probe}"))
        assert b"original" in result.stdout

        snap = tmp_path / "pin.tar"
        asyncio.run(src.snapshot(snap))

        client.put_object(Bucket=LIVE_BUCKET, Key=key, Body=b"mutated\n")

        dst = Workspace.load(snap, resources=_override())
        dst._cache.evict_paths([probe])
        result = asyncio.run(dst.execute(f"cat {probe}"))
        assert result.stdout == b"original\n", (
            "pinned read should serve the recorded version, "
            f"got {result.stdout!r}")
    finally:
        _cleanup_key(key)


def test_live_off_policy_serves_current(tmp_path):
    """drift_policy=OFF must skip both the drift check and any pin
    installation, evict the snapshot cache, and serve the live bytes.
    Smokes the regression where the pin was installed even when the
    caller explicitly asked for live state.
    """
    key = _probe_key()
    probe = f"/s3/{key}"
    client = _boto_client()
    client.put_object(Bucket=LIVE_BUCKET, Key=key, Body=b"original\n")
    try:
        src = Workspace(_mount(), mode=MountMode.WRITE)
        asyncio.run(src.execute(f"cat {probe}"))

        snap = tmp_path / "off.tar"
        asyncio.run(src.snapshot(snap))

        client.put_object(Bucket=LIVE_BUCKET, Key=key, Body=b"mutated\n")

        dst = Workspace.load(snap,
                             resources=_override(),
                             drift_policy=DriftPolicy.OFF)
        assert dst.revisions == {}
        result = asyncio.run(dst.execute(f"cat {probe}"))
        assert b"mutated" in result.stdout
    finally:
        _cleanup_key(key)


def test_live_stat_populates_revision_when_versioned(tmp_path):
    """Smoke: on a versioned bucket, S3Resource.stat must populate
    FileStat.revision so capture_fingerprints records it. Tightens the
    contract that downstream pin tests rely on.
    """
    if not _versioning_enabled():
        pytest.skip("bucket not versioned")
    key = _probe_key()
    probe = f"/s3/{key}"
    client = _boto_client()
    client.put_object(Bucket=LIVE_BUCKET, Key=key, Body=b"x\n")
    try:
        resource = S3Resource(_config())
        asyncio.run(write_bytes(resource.accessor, _spec(key, probe), b"x\n"))
        stat = asyncio.run(resource._ops["stat"](resource.accessor, probe))
        assert stat.fingerprint is not None
        assert stat.revision is not None, (
            "versioned bucket head should carry a VersionId")
    finally:
        _cleanup_key(key)
