"""OSAPI v1.0 jis-side ops — claim / bind / fira (spec §4).

Reference: docs/specs/osapi-protocol-v1.md §4
"""
from __future__ import annotations

import time

import pytest

from jis_core.osapi import (
    OSAPIServer,
    bootstrap,
    OSAPIError,
)


@pytest.fixture
def server():
    s = OSAPIServer(bind_url="tcp://127.0.0.1:0")
    s.start()
    time.sleep(0.05)
    yield s
    s.stop()


@pytest.fixture
def session(server):
    sess = bootstrap(actor="ops-test", actor_claim=b"JIS:fake", url=server.url)
    yield sess
    sess.close()


# ── claim ───────────────────────────────────────────────────────────────────

def test_claim_allowed_with_sufficient_clearance(session):
    result = session.claim({"actor": "partner@x.com", "clearance": 3, "role": "partner"})
    assert result["ok"] is True
    assert result["verdict"] == "allowed"
    assert result["denials"] == []


def test_claim_denied_with_low_clearance(session):
    result = session.claim({"actor": "intern@x.com", "clearance": 0, "role": "intern"})
    assert result["verdict"] == "denied"
    assert "clearance_too_low" in result["denials"]


def test_claim_denied_with_missing_actor(session):
    result = session.claim({"clearance": 5})
    assert result["verdict"] == "denied"
    assert "missing_actor" in result["denials"]


def test_claim_object_required(session):
    """Non-dict claim payload must error cleanly."""
    with pytest.raises(OSAPIError) as exc:
        # call _call_op directly with bad shape
        from jis_core.osapi import _call_op
        _call_op(session, "claim", claim="not-a-dict")
    assert "OP_INVALID" in str(exc.value)


# ── bind ────────────────────────────────────────────────────────────────────

def test_bind_returns_token_and_expiry(session):
    result = session.bind(actor="user_x", intent="upload_document", ttl_seconds=300)
    assert result["ok"] is True
    assert result["binding_token"].startswith("bind_")
    assert result["expires_at"] > time.time()
    assert result["expires_at"] < time.time() + 400  # within ttl + slack


def test_bind_default_ttl_is_1h(session):
    result = session.bind(actor="user_x", intent="anything")
    expires_in = result["expires_at"] - time.time()
    assert 3500 < expires_in < 3700      # ~1 hour ± slack


def test_bind_unique_tokens_per_call(session):
    a = session.bind(actor="x", intent="op_a")
    b = session.bind(actor="x", intent="op_b")
    assert a["binding_token"] != b["binding_token"]


def test_bind_requires_actor_and_intent(session):
    with pytest.raises(OSAPIError) as exc:
        session.bind(actor="", intent="something")
    assert "OP_INVALID" in str(exc.value)


# ── fira ────────────────────────────────────────────────────────────────────

def test_fira_first_init_returns_intermediate_trust(session):
    result = session.fira(peer="alice", phase="F")
    assert result["ok"] is True
    assert result["trust"] == 0.5
    assert result["next_phase"] == "A"


def test_fira_accept_returns_full_trust(session):
    result = session.fira(peer="alice", phase="A")
    assert result["trust"] == 0.7
    assert result["next_phase"] is None


def test_fira_revoke_resets_trust(session):
    result = session.fira(peer="alice", phase="R")
    assert result["trust"] == 0.0
    assert result["next_phase"] is None


def test_fira_invalid_phase_raises(session):
    with pytest.raises(OSAPIError) as exc:
        session.fira(peer="alice", phase="Z")
    assert "OP_INVALID" in str(exc.value)


def test_fira_requires_peer(session):
    with pytest.raises(OSAPIError) as exc:
        session.fira(peer="", phase="F")
    assert "OP_INVALID" in str(exc.value)


def test_fira_state_recorded_server_side(session, server):
    session.fira(peer="bob", phase="F")
    # find the session by token and check fira state
    sess_state = server._active_sessions.get(session.session_token)
    assert sess_state is not None
    assert "bob" in sess_state["fira"]
    assert sess_state["fira"]["bob"]["trust"] == 0.5


# ── Session-not-found error ─────────────────────────────────────────────────

def test_op_with_invalid_session_token_raises(server):
    from jis_core.osapi import Session
    fake = Session(
        actor="ghost",
        session_token="jis_doesnotexist_00000000",
        chain_handle="ch_x",
        identity_binding="id_x",
        server_url=server.url,
    )
    with pytest.raises(OSAPIError) as exc:
        fake.claim({"actor": "x", "clearance": 5})
    assert "SESSION" in str(exc.value)


# ── Soft-bootstrap ops fallback ─────────────────────────────────────────────

def test_soft_bootstrap_ops_work_locally(monkeypatch):
    monkeypatch.setenv("TIBET_SOFT_BOOTSTRAP", "1")
    sess = bootstrap(actor="local", actor_claim=b"c",
                     url="tcp://127.0.0.1:1", timeout=0.3)
    assert sess.is_soft_bootstrap is True

    c = sess.claim({"actor": "x", "clearance": 3})
    assert c["ok"] is True and c["verdict"] == "allowed"
    assert c.get("_local") is True

    b = sess.bind(actor="x", intent="local_op")
    assert b["ok"] is True and b["binding_token"].startswith("bind_")
    assert b.get("_local") is True

    f = sess.fira(peer="alice", phase="A")
    assert f["trust"] == 0.7
    assert f.get("_local") is True

    # local state preserved on session
    assert "bindings" in sess._local_state
    assert "fira" in sess._local_state
    assert "alice" in sess._local_state["fira"]
