#!/usr/bin/env python3
"""A stand-in for the `opencode` CLI, so fleet's API layer can be tested offline.

Behaviour is driven entirely by a JSON fixture named by FLEET_STUB_FIXTURE:

    {
      "sessions": {"ses_x": {...session info or null...}},
      "messages": {"ses_x": [...]},
      "inbox":    {"ses_x": [...]}
    }

It reproduces the two things about the real command that fleet has to cope
with: a failing call prints an error object on stdout and a status line on
stderr with a non-zero exit, and a successful DELETE prints nothing at all.
"""

import json
import os
import sys


def die(tag, message, status, session=None):
    body = {"_tag": tag, "message": message}
    if session:
        body["sessionID"] = session
    print(json.dumps(body))
    print(f"HTTP {status} {tag}", file=sys.stderr)
    sys.exit(1)


def parse(argv):
    if len(argv) < 2 or argv[0] != "api":
        print(f"stub opencode: unsupported invocation {argv!r}", file=sys.stderr)
        sys.exit(2)
    method, path = argv[1].upper(), argv[2]
    data, params = None, {}
    rest = argv[3:]
    index = 0
    while index < len(rest):
        token = rest[index]
        if token in ("--data", "-d") and index + 1 < len(rest):
            data = json.loads(rest[index + 1])
            index += 2
        elif token in ("--param",) and index + 1 < len(rest):
            key, _, value = rest[index + 1].partition("=")
            params[key] = value
            index += 2
        elif token in ("--header", "-H") and index + 1 < len(rest):
            index += 2
        else:
            index += 1
    return method, path, data, params


def emit(payload):
    print(json.dumps(payload))


def save(fixture_path, fixture):
    """Persist a mutation, so the stub behaves like a real server across calls."""
    with open(fixture_path, "w") as handle:
        json.dump(fixture, handle)


def _next_stamp(*groups, explicit=None):
    """A timestamp later than anything already logged.

    The real server stamps in wall-clock milliseconds, so a new message is
    always newer than the turn before it. Tests write fixtures with small
    hand-picked timestamps, so the stub has to find the high-water mark rather
    than use a fixed constant, or `fleet ask` filters out its own turn.
    """
    if explicit is not None:
        return explicit
    highest = 0
    for group in groups:
        for message in group or []:
            highest = max(highest, message.get("time", {}).get("created", 0))
    return highest + 1


def _auto_reply(log, spec, created):
    """Append one assistant turn, the way the agent loop would.

    `spec` is a plain string for a plain reply, or an object with any of:
      text        what the agent says
      stuck_tool  a tool that starts and never returns, as a blocked permission
                  prompt does. No settle marker follows, so the turn never ends.
      outcome     the settle marker's outcome, "succeeded" or "failed"
    """
    if not spec:
        return
    if isinstance(spec, str):
        spec = {"text": spec}

    content = []
    if spec.get("stuck_tool"):
        content.append({
            "type": "tool",
            "name": spec["stuck_tool"],
            "executed": False,
            "state": {"status": "running", "input": {}},
        })
    if spec.get("text"):
        content.append({"type": "text", "text": spec["text"]})

    # A stuck tool leaves the turn streaming, so no `completed` stamp.
    stamp = {"created": created + 1}
    if not spec.get("stuck_tool"):
        stamp["completed"] = created + 1
    log.append({"id": f"msg_out{created}", "type": "assistant", "time": stamp, "content": content})

    if not spec.get("stuck_tool"):
        log.append({
            "id": f"msg_idle{created}",
            "type": "idle",
            "time": {"created": created + 2},
            "outcome": spec.get("outcome", "succeeded"),
        })


def main():
    fixture_path = os.environ.get("FLEET_STUB_FIXTURE")
    if not fixture_path:
        print("stub opencode: FLEET_STUB_FIXTURE is not set", file=sys.stderr)
        sys.exit(2)
    with open(fixture_path) as handle:
        fixture = json.load(handle)

    method, path, data, params = parse(sys.argv[1:])
    sessions = fixture.setdefault("sessions", {})
    messages = fixture.setdefault("messages", {})
    inbox = fixture.setdefault("inbox", {})

    parts = [p for p in path.split("/") if p]

    if path == "/api/info":
        emit({"data": {"version": fixture.get("version", "stub")}})
        return

    if path == "/api/session" and method == "GET":
        emit({"data": list(sessions.values())})
        return

    if path == "/api/session" and method == "POST":
        # The real server validates the location shape, and requires
        # location.directory rather than location.path.
        location = (data or {}).get("location")
        if not isinstance(location, dict) or "directory" not in location:
            die("InvalidRequestError", 'Missing key\n  at ["location"]["directory"]', 400)
        new_id = f"ses_stub{len(sessions)}"
        sessions[new_id] = {
            "id": new_id,
            "title": (data or {}).get("title"),
            "agent": (data or {}).get("agent"),
            "cost": 0,
        }
        save(fixture_path, fixture)
        emit({"data": sessions[new_id]})
        return

    if len(parts) >= 3 and parts[0] == "api" and parts[1] == "session":
        session = parts[2]
        tail = parts[3:]

        if session not in sessions:
            die("SessionNotFoundError", f"Session not found: {session}", 404, session)

        if not tail:
            if method == "GET":
                emit({"data": sessions[session]})
                return
            if method == "DELETE":
                # A 204 with no body, exactly like the real server.
                del sessions[session]
                messages.pop(session, None)
                inbox.pop(session, None)
                save(fixture_path, fixture)
                return

        if tail == ["synthetic"] and method == "POST":
            created = _next_stamp(messages.get(session, []), inbox.get(session, []),
                                  (data or {}).get("_now"))
            record = {
                "id": f"msg_in{created}",
                "sessionID": session,
                "time": {"created": created},
                "type": "synthetic",
                "payload": {"text": (data or {}).get("text", "")},
                "delivery": (data or {}).get("delivery", "steer"),
            }
            if not (data or {}).get("resume", True):
                # Matches the server: undelivered work never reaches the log.
                inbox.setdefault(session, []).append(record)
            else:
                # A resumed message gets picked up, so run the stub's miniature
                # agent loop and let the test observe a whole turn.
                log = messages.setdefault(session, [])
                log.append(record)
                _auto_reply(log, fixture.get("auto_reply"), created)
            save(fixture_path, fixture)
            emit({"data": record})
            return

        if tail == ["message"] and method == "GET":
            found = list(messages.get(session, []))
            # The real server honours order, and `desc` means newest first.
            if params.get("order") == "desc":
                found = list(reversed(found))
            limit = int(params.get("limit", 60))
            emit({"data": found[:limit]})
            return

        if tail == ["inbox"] and method == "GET":
            emit({"data": inbox.get(session, [])})
            return

    die("BadRequestError", f"stub opencode has no route for {method} {path}", 400)


if __name__ == "__main__":
    main()
