#!/usr/bin/env python3
"""CLI wrapper around superpos_agent_core.SuperposClient issue methods.

Subcommands mirror the client API:
  list, show, create, update, transition, close,
  link-task, link-channel, request-approval,
  add-dependency, remove-dependency,
  types,
  attach, attachments, detach,
  comment, discussion

Prints JSON to stdout, errors to stderr.  Designed for the LLM to call
via Bash and pipe through ``jq`` for follow-up queries.
"""

from __future__ import annotations

import argparse
import asyncio
import json
import os
import sys
from typing import Any

import httpx

# ``superpos_agent_core`` is pip-installed via the container's requirements.txt.
from superpos_agent_core import BaseConfig, SuperposClient


def _config_from_env() -> BaseConfig:
    base_url = os.environ.get("SUPERPOS_BASE_URL", "").rstrip("/")
    hive_id = os.environ.get("SUPERPOS_HIVE_ID", "")
    agent_id = os.environ.get("SUPERPOS_AGENT_ID", "")
    token = os.environ.get("SUPERPOS_API_TOKEN", "")
    refresh = os.environ.get("SUPERPOS_REFRESH_TOKEN", "")

    if not (base_url and hive_id and token):
        print(
            "Error: SUPERPOS_BASE_URL, SUPERPOS_HIVE_ID, and "
            "SUPERPOS_API_TOKEN must be set in the environment.",
            file=sys.stderr,
        )
        sys.exit(2)

    return BaseConfig(
        superpos_base_url=base_url,
        superpos_hive_id=hive_id,
        superpos_agent_id=agent_id,
        superpos_api_token=token,
        superpos_refresh_token=refresh,
    )


def _print(value: Any) -> None:
    """Print a JSON-serialisable value, indented for human reading."""
    print(json.dumps(value, indent=2, default=str))


def _parse_json_arg(label: str, raw: str | None) -> dict[str, Any] | None:
    """Parse a CLI flag value that should be a JSON object, with a clear error."""
    if raw is None:
        return None
    try:
        parsed = json.loads(raw)
    except json.JSONDecodeError as e:
        print(f"Error: --{label} must be valid JSON ({e})", file=sys.stderr)
        sys.exit(2)
    if not isinstance(parsed, dict):
        print(f"Error: --{label} must be a JSON object, got {type(parsed).__name__}",
              file=sys.stderr)
        sys.exit(2)
    return parsed


# Bounded so a pathological flip-flop on the issue's ``thread_id`` can never
# spin forever; the real later-overwrite race converges in two iterations.
_MAX_COMMENT_THREAD_RETRIES = 4


async def _resolve_or_create_thread(client: SuperposClient, issue_id: str) -> str:
    """Return the issue's discussion thread id, creating + linking one on demand.

    Issues carry an optional ``thread_id``.  The first ``comment`` on an issue
    with no thread transparently creates a thread and links it back to the
    issue (``update_issue(thread_id=...)``) before the message is appended —
    mirroring the dashboard's lazy ``start-discussion`` behaviour.

    Two agents can race here: both read ``thread_id=None``, each create a
    thread, and the later ``update_issue`` overwrites the earlier link. Two
    defences keep the message out of an orphaned thread:

    1. If the backend atomically rejects replacing an already-claimed
       ``thread_id`` (409/422 — the start-discussion claim contract), the
       loser catches the conflict and defers to the issue's winning thread.
    2. Otherwise (no backend claim yet) the link is a last-write-wins PATCH,
       so we re-read the issue afterwards and return whichever thread it now
       authoritatively points to rather than our local one.
    """
    issue = await client.get_issue(issue_id)
    thread_id = issue.get("thread_id")
    if thread_id:
        return thread_id

    thread = await client.create_thread(title=f"Discussion: {issue.get('title', issue_id)}")
    new_thread_id = thread["id"]
    try:
        await client.update_issue(issue_id, thread_id=new_thread_id)
    except httpx.HTTPStatusError as exc:
        response = exc.response
        if response is not None and response.status_code in (409, 422):
            # Backend refused to overwrite an existing claim: a concurrent
            # first-comment writer won. Use the issue's authoritative thread.
            issue = await client.get_issue(issue_id)
            winner = issue.get("thread_id")
            if winner:
                return winner
        raise

    # Re-read the issue so a concurrent first-comment writer that won the link
    # is respected: append into the issue's current thread, not our local one.
    issue = await client.get_issue(issue_id)
    return issue.get("thread_id") or new_thread_id


async def _post_comment(
    client: SuperposClient, issue_id: str, message: str,
) -> dict[str, Any]:
    """Append a discussion comment so it stays visible in ``discussion``.

    Resolving the thread closes the race where a concurrent writer's link is
    visible *before* we append. It cannot close the later-overwrite window: a
    second writer can replace the issue's ``thread_id`` *after* we resolved and
    appended, stranding our message in a now-orphaned thread. Without a backend
    atomic claim the only client-side guard is to verify the issue still points
    at the thread we wrote to and, when it does not, append again into the new
    winner. The extra copy lands in the orphaned thread (never shown), so the
    message is preserved exactly once in the issue's discussion.
    """
    result: dict[str, Any] = {}
    for _ in range(_MAX_COMMENT_THREAD_RETRIES):
        thread_id = await _resolve_or_create_thread(client, issue_id)
        result = await client.append_thread_message(thread_id, message)
        issue = await client.get_issue(issue_id)
        if issue.get("thread_id") == thread_id:
            return result
    return result


async def _run(args: argparse.Namespace) -> int:
    config = _config_from_env()
    client = SuperposClient(config)
    try:
        if args.cmd == "list":
            _print(await client.list_issues(
                state=args.state,
                issue_type_id=args.issue_type_id,
                assignee_id=args.assignee_id,
                q=args.q,
                page=args.page,
                per_page=args.per_page,
            ))

        elif args.cmd == "show":
            _print(await client.get_issue(args.issue_id))

        elif args.cmd == "create":
            _print(await client.create_issue(
                title=args.title,
                issue_type_id=args.issue_type_id,
                description=args.description,
                assignee_type=args.assignee_type,
                assignee_id=args.assignee_id,
                metadata=_parse_json_arg("metadata", args.metadata),
                channel_id=args.channel_id,
                thread_id=args.thread_id,
            ))

        elif args.cmd == "update":
            try:
                _print(await client.update_issue(
                    args.issue_id,
                    title=args.title,
                    description=args.description,
                    assignee_type=args.assignee_type,
                    assignee_id=args.assignee_id,
                    metadata=_parse_json_arg("metadata", args.metadata),
                    issue_type_id=args.issue_type_id,
                    channel_id=args.channel_id,
                    thread_id=args.thread_id,
                ))
            except ValueError as e:
                print(f"Error: {e}", file=sys.stderr)
                return 2

        elif args.cmd == "transition":
            _print(await client.transition_issue(
                args.issue_id, to=args.to, reason=args.reason,
            ))

        elif args.cmd == "close":
            _print(await client.close_issue(args.issue_id, reason=args.reason))

        elif args.cmd == "link-task":
            _print(await client.link_task_to_issue(
                args.issue_id, task_id=args.task_id,
            ))

        elif args.cmd == "link-channel":
            _print(await client.link_channel_to_issue(
                args.issue_id, channel_id=args.channel_id,
            ))

        elif args.cmd == "request-approval":
            _print(await client.request_issue_approval(
                args.issue_id,
                summary=args.summary,
                recommended_action=args.recommended_action,
                risks=args.risks,
                linked_issue_ids=args.linked_issue_id or None,
            ))

        elif args.cmd == "add-dependency":
            _print(await client.create_issue_dependency(
                args.issue_id,
                depends_on_issue_id=args.depends_on,
                kind=args.kind,
            ))

        elif args.cmd == "remove-dependency":
            await client.delete_issue_dependency(args.issue_id, args.dependency_id)
            _print({"ok": True})

        elif args.cmd == "types":
            _print(await client.list_issue_types())

        elif args.cmd == "attach":
            _print(await client.upload_attachment(
                file_path=args.file,
                issue_id=args.issue_id,
                description=args.description,
            ))

        elif args.cmd == "attachments":
            _print(await client.list_attachments(
                issue_id=args.issue_id,
                page=args.page,
                per_page=args.per_page,
            ))

        elif args.cmd == "detach":
            await client.delete_attachment(args.attachment_id)
            _print({"ok": True})

        elif args.cmd == "comment":
            _print(await _post_comment(client, args.issue_id, args.message))

        elif args.cmd == "discussion":
            issue = await client.get_issue(args.issue_id)
            thread_id = issue.get("thread_id")
            if not thread_id:
                _print({"thread_id": None, "messages": [], "note": "No discussion yet."})
            else:
                _print(await client.get_thread(thread_id))

        else:
            print(f"Unknown subcommand: {args.cmd}", file=sys.stderr)
            return 2

        return 0
    finally:
        await client.close()


def _build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="superpos-issues",
        description=(
            "Manage Superpos issues: list, show, create, transition, close, "
            "link to tasks/channels, request approval, manage dependencies."
        ),
    )
    sub = parser.add_subparsers(dest="cmd", required=True)

    # list
    p_list = sub.add_parser("list", help="Paginated list with filters")
    p_list.add_argument("--state", help="Filter by state (open, in_progress, awaiting_review, done, blocked, cancelled)")
    p_list.add_argument("--issue-type-id", help="Restrict to a single issue type")
    p_list.add_argument("--assignee-id", help="Restrict to issues assigned to this ID")
    p_list.add_argument("--q", help="Case-insensitive title substring")
    p_list.add_argument("--page", type=int, help="1-based page index (see meta.current_page / has_more)")
    p_list.add_argument("--per-page", type=int, help="Page size (server caps at 100)")

    # show
    p_show = sub.add_parser("show", help="Fetch a single issue with relations")
    p_show.add_argument("issue_id")

    # create
    p_create = sub.add_parser("create", help="File a new issue")
    p_create.add_argument("--title", required=True)
    p_create.add_argument("--issue-type-id", required=True, help="ID of a row from `types`")
    p_create.add_argument("--description")
    p_create.add_argument("--assignee-type", help="Polymorphic class, e.g. App\\\\Models\\\\Agent")
    p_create.add_argument("--assignee-id")
    p_create.add_argument("--metadata", help="JSON object")
    p_create.add_argument("--channel-id")
    p_create.add_argument("--thread-id")

    # update
    p_update = sub.add_parser("update", help="Partial update of an existing issue")
    p_update.add_argument("issue_id")
    p_update.add_argument("--title")
    p_update.add_argument("--description")
    p_update.add_argument("--assignee-type")
    p_update.add_argument("--assignee-id")
    p_update.add_argument("--metadata", help="JSON object")
    p_update.add_argument("--issue-type-id")
    p_update.add_argument("--channel-id")
    p_update.add_argument("--thread-id")

    # transition
    p_trans = sub.add_parser("transition", help="Drive the issue state machine")
    p_trans.add_argument("issue_id")
    p_trans.add_argument("--to", required=True, help="Target state (see `show` -> allowed_transitions)")
    p_trans.add_argument("--reason")

    # close
    p_close = sub.add_parser("close", help="Policy-aware close")
    p_close.add_argument("issue_id")
    p_close.add_argument("--reason")

    # link-task
    p_lt = sub.add_parser("link-task", help="Attach an existing task to this issue")
    p_lt.add_argument("issue_id")
    p_lt.add_argument("--task-id", required=True)

    # link-channel
    p_lc = sub.add_parser("link-channel", help="Bind a channel to this issue")
    p_lc.add_argument("issue_id")
    p_lc.add_argument("--channel-id", required=True)

    # request-approval
    p_ra = sub.add_parser("request-approval", help="Escalate for human approval")
    p_ra.add_argument("issue_id")
    p_ra.add_argument("--summary", required=True)
    p_ra.add_argument("--recommended-action")
    p_ra.add_argument("--risks")
    p_ra.add_argument(
        "--linked-issue-id", action="append",
        help="Linked issue ID (repeatable)",
    )

    # add-dependency
    p_ad = sub.add_parser("add-dependency", help="Declare a dependency on another issue")
    p_ad.add_argument("issue_id")
    p_ad.add_argument("--depends-on", required=True, help="Issue ID this one depends on")
    p_ad.add_argument("--kind", required=True, help="Dependency kind (e.g. blocks, related_to)")

    # remove-dependency
    p_rd = sub.add_parser("remove-dependency", help="Drop a dependency row")
    p_rd.add_argument("issue_id")
    p_rd.add_argument("dependency_id")

    # types
    sub.add_parser("types", help="List the hive's issue-type catalogue")

    # attach
    p_attach = sub.add_parser("attach", help="Upload a file and attach it to an issue")
    p_attach.add_argument("--issue-id", required=True, help="Issue to attach the file to")
    p_attach.add_argument("--file", required=True, help="Path to the local file to upload")
    p_attach.add_argument("--description", help="Optional human-readable description")

    # attachments
    p_atts = sub.add_parser("attachments", help="List files attached to an issue")
    p_atts.add_argument("--issue-id", required=True, help="Issue whose attachments to list")
    p_atts.add_argument("--page", type=int, help="1-based page index (see meta.current_page / meta.last_page)")
    p_atts.add_argument("--per-page", type=int, help="Page size (server caps at 100)")

    # detach
    p_detach = sub.add_parser(
        "detach",
        help="Delete an attachment by id (destructive; requires the attachments.manage scope, not in hosted-agent defaults)",
    )
    p_detach.add_argument("attachment_id")

    # comment
    p_comment = sub.add_parser(
        "comment",
        help="Post a discussion comment on an issue (auto-creates the thread on first comment)",
    )
    p_comment.add_argument("--issue-id", required=True)
    p_comment.add_argument("--message", required=True, help="Comment body")

    # discussion
    p_disc = sub.add_parser("discussion", help="Print an issue's discussion history")
    p_disc.add_argument("--issue-id", required=True)

    return parser


def main(argv: list[str] | None = None) -> int:
    args = _build_parser().parse_args(argv)
    return asyncio.run(_run(args))


if __name__ == "__main__":
    sys.exit(main())
