#!/usr/bin/env python3
from __future__ import annotations

import argparse
import json
import os
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any


OPENHANDS_BASE_URL = os.environ.get("OPENHANDS_BASE_URL", "https://app.all-hands.dev")
REPOSITORY_PATTERN = re.compile(r"^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$")
GITHUB_API_BASE_URL = os.environ.get("GITHUB_API_BASE_URL", "https://api.github.com")
GITHUB_BASE_URL = os.environ.get("GITHUB_BASE_URL", "https://github.com")
FAILED_EXECUTION_STATUSES = {
    "error",
    "errored",
    "failed",
    "stopped",
}
SUCCESSFUL_TERMINAL_EXECUTION_STATUSES = {
    "completed",
    "finished",
}
TERMINAL_EXECUTION_STATUSES = (
    FAILED_EXECUTION_STATUSES | SUCCESSFUL_TERMINAL_EXECUTION_STATUSES
)
EVENT_SEARCH_LIMIT = 1000
EVENT_SEARCH_LIMIT_HIT_MESSAGE = (
    f"Event search returned at least {EVENT_SEARCH_LIMIT} events; results may be "
    "incomplete"
)
OPENHANDS_DEBUG_KEYS = (
    "id",
    "status",
    "app_conversation_id",
    "execution_status",
    "conversation_url",
    "error",
    "error_detail",
    "detail",
    "message",
)
OPENHANDS_SENSITIVE_KEYS = frozenset({"session_api_key"})


class HTTPError(RuntimeError):
    def __init__(self, method: str, url: str, status_code: int, body: str) -> None:
        self.method = method
        self.url = url
        self.status_code = status_code
        self.body = body
        super().__init__(f"{method} {url} failed with HTTP {status_code}: {body}")


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description=(
            "Start an OpenHands Cloud conversation that checks a GitHub issue "
            "for duplicates."
        )
    )
    parser.add_argument(
        "--repository", required=True, help="Repository in owner/repo form"
    )
    parser.add_argument(
        "--issue-number", required=True, type=int, help="Issue number to inspect"
    )
    parser.add_argument(
        "--output",
        default="duplicate-check-result.json",
        help="Path where the JSON result should be written",
    )
    parser.add_argument(
        "--poll-interval-seconds",
        default=5,
        type=int,
        help="Polling interval while waiting for the conversation to finish",
    )
    parser.add_argument(
        "--max-wait-seconds",
        default=900,
        type=int,
        help=(
            "Maximum time to wait per polling phase; if a start task must be awaited "
            "first, the total runtime can approach twice this value"
        ),
    )
    return parser.parse_args()


def github_headers() -> dict[str, str]:
    github_token = os.environ.get("GITHUB_TOKEN")
    if not github_token:
        raise RuntimeError("GITHUB_TOKEN environment variable is required")
    return {
        "Authorization": f"Bearer {github_token}",
        "Accept": "application/vnd.github+json",
        "User-Agent": "openhands-issue-duplicate-check",
        "X-GitHub-Api-Version": "2022-11-28",
    }


def openhands_headers() -> dict[str, str]:
    api_key = os.environ.get("OPENHANDS_API_KEY")
    if not api_key:
        raise RuntimeError("OPENHANDS_API_KEY environment variable is required")
    return {
        "Authorization": f"Bearer {api_key}",
        "Content-Type": "application/json",
    }


def request_json(
    base_url: str,
    path: str,
    *,
    method: str = "GET",
    headers: dict[str, str] | None = None,
    body: dict[str, Any] | None = None,
) -> Any:
    data = json.dumps(body).encode("utf-8") if body is not None else None
    url = f"{base_url}{path}"
    request = urllib.request.Request(
        url,
        data=data,
        headers=headers or {},
        method=method,
    )
    try:
        with urllib.request.urlopen(request, timeout=60) as response:
            return json.load(response)
    except urllib.error.HTTPError as exc:
        error_body = exc.read().decode("utf-8", errors="replace")
        raise HTTPError(method, url, exc.code, error_body) from exc
    except json.JSONDecodeError as exc:
        raise RuntimeError(f"Failed to parse JSON from {method} {url}: {exc}") from exc
    except urllib.error.URLError as exc:
        raise RuntimeError(f"{method} {url} failed: {exc}") from exc


def fetch_issue(repository: str, issue_number: int) -> dict[str, Any]:
    if not REPOSITORY_PATTERN.fullmatch(repository):
        raise ValueError(f"Invalid repository format: {repository}")
    return request_json(
        GITHUB_API_BASE_URL,
        f"/repos/{repository}/issues/{issue_number}",
        headers=github_headers(),
    )


def escape_json_text(value: str | None) -> str:
    return json.dumps(value or "", ensure_ascii=False)


def build_prompt(repository: str, issue: dict[str, Any]) -> str:
    issue_number = issue["number"]
    issue_title = issue.get("title", "")
    issue_body = issue.get("body") or ""
    issue_url = issue.get("html_url", "")
    issue_title_json = escape_json_text(issue_title)
    issue_body_json = escape_json_text(issue_body)

    return "\n".join(
        [
            "You are investigating whether a GitHub issue should be redirected "
            "to an existing issue because it is either:",
            "- an exact or near-exact duplicate, or",
            "- so overlapping in scope that discussion or fix planning would "
            "likely be better kept in one canonical issue.",
            "",
            "Be conservative about auto-close decisions, but do investigate "
            "seriously before deciding.",
            "",
            f"Repository: {repository}",
            f"New issue number: #{issue_number}",
            f"New issue URL: {issue_url}",
            f"New issue title (JSON-escaped string): {issue_title_json}",
            f"New issue body (JSON-escaped string): {issue_body_json}",
            "",
            "Task:",
            "1. Understand the core problem, user-facing outcome, likely root "
            "cause, and requested fix or behavior.",
            "2. Investigate this repository's open issues and issues closed "
            "in the last 90 days for exact duplicates, near-duplicates, or "
            "strong scope overlap.",
            "3. Use multiple search approaches with diverse keywords and "
            "phrasings rather than a single literal search.",
            "4. Ignore pull requests.",
            "5. Distinguish carefully between:",
            "   - duplicate: essentially the same report, request, or root cause",
            "   - overlapping-scope: not identical, but likely to fragment "
            "discussion or produce competing fixes",
            "   - related-but-distinct: similar area, but should stay separate",
            "   - no-match: no strong candidate worth redirecting to",
            "6. Inspect the strongest 1-3 candidates carefully. If needed, "
            "inspect comments on the strongest candidates to disambiguate "
            "false positives.",
            "7. Do not post comments, do not modify files, and do not change "
            "repository state.",
            "8. Useful API shapes include:",
            f"   - GET {GITHUB_API_BASE_URL}/repos/{repository}/issues?state=open&per_page=100",
            f"   - GET {GITHUB_API_BASE_URL}/repos/"
            f"{repository}/issues?state=closed&since=<ISO-8601 timestamp>&per_page=100",
            f"   - GET {GITHUB_API_BASE_URL}/search/issues?q=<query>",
            f"   - GET {GITHUB_API_BASE_URL}/repos/{repository}/issues/<number>/comments",
            "9. Return exactly one JSON object and nothing else. Do not wrap "
            "it in markdown fences.",
            "",
            "Return schema:",
            "{",
            f'  "issue_number": {issue_number},',
            '  "should_comment": true or false,',
            '  "is_duplicate": true or false,',
            '  "auto_close_candidate": true or false,',
            '  "classification": "duplicate" | "overlapping-scope" | '
            '"related-but-distinct" | "no-match",',
            '  "confidence": "high" | "medium" | "low",',
            '  "summary": "short explanation",',
            '  "canonical_issue_number": 123 or null,',
            '  "candidate_issues": [',
            "    {",
            '      "number": 123,',
            f'      "url": "{GITHUB_BASE_URL}/{repository}/issues/123",',
            '      "title": "issue title",',
            '      "state": "open or closed",',
            '      "closed_at": "ISO timestamp or null",',
            '      "similarity_reason": "why it looks similar"',
            "    }",
            "  ]",
            "}",
            "",
            "Rules:",
            "- `should_comment` should be true only when redirecting the "
            "author would likely help.",
            "- `is_duplicate` should be true only for exact or near-exact duplicates.",
            "- `auto_close_candidate` should be true only when:",
            "  - classification is `duplicate`",
            "  - confidence is `high`",
            "  - one canonical issue clearly stands out",
            "  - a maintainer would likely be comfortable closing this issue "
            "after a waiting period",
            "- For `overlapping-scope`, `auto_close_candidate` must be false.",
            "- `candidate_issues` must contain at most 3 issues, sorted best-first.",
            "- If no strong match exists, return `should_comment: false`, "
            '`classification: "no-match"`, `canonical_issue_number: null`, '
            "and an empty candidate list.",
            "- Be especially careful not to collapse broad meta, tracking, "
            "feedback, or umbrella issues with specific bug reports unless "
            "the new issue clearly belongs in that exact thread.",
        ]
    )


def start_conversation(
    prompt: str, repository: str, issue_number: int
) -> dict[str, Any]:
    body = {
        "title": f"Issue duplicate check #{issue_number}",
        "selected_repository": repository,
        "initial_message": {
            "content": [
                {
                    "type": "text",
                    "text": prompt,
                }
            ]
        },
    }
    return request_json(
        OPENHANDS_BASE_URL,
        "/api/v1/app-conversations",
        method="POST",
        headers=openhands_headers(),
        body=body,
    )


def extract_first_item(payload: Any) -> dict[str, Any] | None:
    if isinstance(payload, list):
        first_item = payload[0] if payload else None
        return first_item if isinstance(first_item, dict) else None
    if not isinstance(payload, dict):
        return None

    items = payload.get("items")
    if isinstance(items, list):
        first_item = items[0] if items else None
        return first_item if isinstance(first_item, dict) else None
    return payload


def summarize_openhands_item(item: dict[str, Any]) -> str:
    summary = {}
    for key in OPENHANDS_DEBUG_KEYS:
        if key not in item:
            continue
        value = item[key]
        if value in (None, "", [], {}):
            continue
        summary[key] = value

    available_keys = sorted(
        key
        for key in item
        if key not in summary and key not in OPENHANDS_SENSITIVE_KEYS
    )
    if available_keys:
        summary["available_keys"] = available_keys
    sensitive_keys_present = sorted(
        key for key in item if key in OPENHANDS_SENSITIVE_KEYS
    )
    if sensitive_keys_present:
        summary["sensitive_keys_present"] = sensitive_keys_present
    return json.dumps(summary or {"available_keys": sorted(item)}, ensure_ascii=False)


def poll_start_task(
    start_task_id: str, poll_interval_seconds: int, max_wait_seconds: int
) -> dict[str, Any]:
    deadline = time.time() + max_wait_seconds
    while time.time() < deadline:
        payload = request_json(
            OPENHANDS_BASE_URL,
            f"/api/v1/app-conversations/start-tasks?ids={urllib.parse.quote(start_task_id)}",
            headers={"Authorization": openhands_headers()["Authorization"]},
        )
        item = extract_first_item(payload)
        if item is None:
            time.sleep(poll_interval_seconds)
            continue
        status = str(item.get("status") or "").lower()
        if status == "ready" and item.get("app_conversation_id"):
            return item
        if status in {"error", "failed"}:
            raise RuntimeError(
                f"OpenHands start task failed: {summarize_openhands_item(item)}"
            )
        time.sleep(poll_interval_seconds)
    raise TimeoutError(
        f"Timed out waiting for start task {start_task_id} to become ready"
    )


def poll_conversation(
    app_conversation_id: str, poll_interval_seconds: int, max_wait_seconds: int
) -> dict[str, Any]:
    deadline = time.time() + max_wait_seconds
    while time.time() < deadline:
        payload = request_json(
            OPENHANDS_BASE_URL,
            f"/api/v1/app-conversations?ids={app_conversation_id}",
            headers={"Authorization": openhands_headers()["Authorization"]},
        )
        item = extract_first_item(payload)
        if item is None:
            time.sleep(poll_interval_seconds)
            continue
        execution_status = str(item.get("execution_status", "")).lower()
        if execution_status in FAILED_EXECUTION_STATUSES:
            raise RuntimeError(
                "OpenHands conversation ended with "
                f"{execution_status}: {summarize_openhands_item(item)}"
            )
        if execution_status in SUCCESSFUL_TERMINAL_EXECUTION_STATUSES:
            return item
        time.sleep(poll_interval_seconds)
    raise TimeoutError(
        f"Timed out waiting for conversation {app_conversation_id} to finish running"
    )


def validate_event_search_results(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
    if len(events) >= EVENT_SEARCH_LIMIT:
        raise RuntimeError(EVENT_SEARCH_LIMIT_HIT_MESSAGE)
    return events


def fetch_app_server_events(app_conversation_id: str) -> list[dict[str, Any]]:
    payload = request_json(
        OPENHANDS_BASE_URL,
        f"/api/v1/conversation/{app_conversation_id}/events/search?limit={EVENT_SEARCH_LIMIT}",
        headers={"Authorization": openhands_headers()["Authorization"]},
    )
    if isinstance(payload, dict):
        items = payload.get("items")
        return validate_event_search_results(items) if isinstance(items, list) else []
    if isinstance(payload, list):
        return validate_event_search_results(payload)
    return []


def fetch_agent_server_events(
    app_conversation_id: str, agent_server_url: str, session_api_key: str
) -> list[dict[str, Any]]:
    payload = request_json(
        agent_server_url,
        f"/api/conversations/{urllib.parse.quote(app_conversation_id)}/events/search?limit={EVENT_SEARCH_LIMIT}",
        headers={"X-Session-API-Key": session_api_key},
    )
    if isinstance(payload, dict):
        items = payload.get("items")
        return validate_event_search_results(items) if isinstance(items, list) else []
    if isinstance(payload, list):
        return validate_event_search_results(payload)
    return []


def fetch_agent_server_final_response(
    app_conversation_id: str, agent_server_url: str, session_api_key: str
) -> str:
    payload = request_json(
        agent_server_url,
        f"/api/conversations/{urllib.parse.quote(app_conversation_id)}/agent_final_response",
        headers={"X-Session-API-Key": session_api_key},
    )
    if not isinstance(payload, dict):
        return ""
    return str(payload.get("response") or "").strip()


def extract_agent_server_url(conversation_url: str) -> str | None:
    parsed = urllib.parse.urlparse(conversation_url)
    if not parsed.scheme or not parsed.netloc:
        return None
    return f"{parsed.scheme}://{parsed.netloc}"


def extract_last_agent_text(events: list[dict[str, Any]]) -> str:
    agent_events = [
        event
        for event in events
        if event.get("kind") == "MessageEvent" and event.get("source") == "agent"
    ]
    if not agent_events:
        raise RuntimeError(
            "No assistant text message was found in the conversation events"
        )

    llm_message = agent_events[-1].get("llm_message")
    if not isinstance(llm_message, dict):
        raise RuntimeError("Last agent message has no llm_message field")
    content = llm_message.get("content")
    if not isinstance(content, list):
        raise RuntimeError("Last agent message content is not a list")

    text_parts: list[str] = []
    for part in content:
        if not isinstance(part, dict):
            continue
        if part.get("type") == "text" and part.get("text"):
            text_parts.append(str(part["text"]))
    if not text_parts:
        raise RuntimeError("Last agent message contains no text content")
    return "".join(text_parts).strip()


def parse_agent_json(text: str) -> dict[str, Any]:
    cleaned = text.strip()
    try:
        return json.loads(cleaned)
    except json.JSONDecodeError:
        decoder = json.JSONDecoder()
        for start, character in enumerate(cleaned):
            if character != "{":
                continue
            try:
                candidate, end = decoder.raw_decode(cleaned[start:])
            except json.JSONDecodeError:
                continue
            trailing = cleaned[start + end :].strip()
            if trailing not in {"", "```"}:
                continue
            if isinstance(candidate, dict):
                return candidate
        raise ValueError("No valid JSON object found in the agent response")


def as_bool(value: Any) -> bool:
    if isinstance(value, bool):
        return value
    if isinstance(value, str):
        return value.strip().lower() in {"true", "1", "yes"}
    if isinstance(value, (int, float)):
        return bool(value)
    return False


def normalize_result(result: dict[str, Any]) -> dict[str, Any]:
    normalized = dict(result)
    normalized["should_comment"] = as_bool(normalized.get("should_comment"))
    normalized["is_duplicate"] = as_bool(normalized.get("is_duplicate"))
    normalized["auto_close_candidate"] = as_bool(normalized.get("auto_close_candidate"))

    classification = str(normalized.get("classification") or "no-match").strip().lower()
    if classification not in {
        "duplicate",
        "overlapping-scope",
        "related-but-distinct",
        "no-match",
    }:
        classification = "no-match"
    normalized["classification"] = classification

    confidence = str(normalized.get("confidence") or "low").strip().lower()
    if confidence not in {"high", "medium", "low"}:
        confidence = "low"
    normalized["confidence"] = confidence

    try:
        canonical_issue_number = normalized.get("canonical_issue_number")
        if canonical_issue_number in {None, ""}:
            normalized["canonical_issue_number"] = None
        else:
            normalized["canonical_issue_number"] = int(str(canonical_issue_number))
    except (TypeError, ValueError):
        normalized["canonical_issue_number"] = None

    candidate_issues = normalized.get("candidate_issues")
    if not isinstance(candidate_issues, list):
        candidate_issues = []
    normalized["candidate_issues"] = candidate_issues[:3]
    if not normalized["candidate_issues"]:
        normalized["should_comment"] = False

    if classification not in {"duplicate", "overlapping-scope"}:
        normalized["should_comment"] = False
    if classification != "duplicate":
        normalized["is_duplicate"] = False
        normalized["auto_close_candidate"] = False
    if normalized["should_comment"] and confidence not in {"high", "medium"}:
        normalized["should_comment"] = False
    if normalized["auto_close_candidate"] and not normalized["should_comment"]:
        normalized["auto_close_candidate"] = False
    if normalized["auto_close_candidate"] and confidence != "high":
        normalized["auto_close_candidate"] = False
    if normalized["auto_close_candidate"] and not normalized["candidate_issues"]:
        normalized["auto_close_candidate"] = False
    if (
        normalized["auto_close_candidate"]
        and normalized["canonical_issue_number"] is None
    ):
        first_candidate = (
            normalized["candidate_issues"][0] if normalized["candidate_issues"] else {}
        )
        candidate_number = first_candidate.get("number")
        try:
            if candidate_number is None:
                raise ValueError("candidate number is missing")
            normalized["canonical_issue_number"] = int(str(candidate_number))
        except (TypeError, ValueError, AttributeError):
            normalized["auto_close_candidate"] = False

    normalized["summary"] = str(normalized.get("summary") or "").strip()
    return normalized


def main() -> int:
    args = parse_args()
    issue = fetch_issue(args.repository, args.issue_number)
    if issue.get("pull_request"):
        raise RuntimeError(f"#{args.issue_number} is a pull request, not an issue")

    prompt = build_prompt(args.repository, issue)
    start_task = start_conversation(prompt, args.repository, args.issue_number)
    app_conversation_id = start_task.get("app_conversation_id")
    conversation_url = ""

    if not app_conversation_id:
        task_id = start_task.get("id")
        if not task_id:
            raise RuntimeError(
                "Missing id in start task response: "
                f"{summarize_openhands_item(start_task)}"
            )
        ready_task = poll_start_task(
            task_id,
            args.poll_interval_seconds,
            args.max_wait_seconds,
        )
        app_conversation_id = ready_task.get("app_conversation_id")
        if not app_conversation_id:
            raise RuntimeError(
                "Missing app_conversation_id in response: "
                f"{summarize_openhands_item(ready_task)}"
            )

    conversation = poll_conversation(
        app_conversation_id,
        args.poll_interval_seconds,
        args.max_wait_seconds,
    )
    conversation_url = (
        conversation.get("conversation_url")
        or f"{OPENHANDS_BASE_URL}/conversations/{app_conversation_id}"
    )
    session_api_key_value = conversation.get("session_api_key")
    if session_api_key_value and not isinstance(session_api_key_value, str):
        raise RuntimeError(
            "session_api_key had unexpected type in the OpenHands conversation: "
            f"{type(session_api_key_value).__name__}"
        )
    session_api_key = session_api_key_value or ""
    agent_server_url = extract_agent_server_url(conversation_url)

    agent_text = ""
    if agent_server_url and session_api_key:
        try:
            agent_text = fetch_agent_server_final_response(
                app_conversation_id,
                agent_server_url,
                session_api_key,
            )
        except RuntimeError:
            agent_text = ""
    if not agent_text:
        events = fetch_app_server_events(app_conversation_id)
        try:
            agent_text = extract_last_agent_text(events)
        except RuntimeError as exc:
            if not session_api_key:
                raise RuntimeError(
                    "App server events did not contain assistant text and "
                    "session_api_key was missing from the OpenHands conversation"
                ) from exc
            if not agent_server_url:
                raise RuntimeError(
                    "App server events did not contain assistant text and cannot "
                    "extract agent server URL from conversation URL: "
                    f"{conversation_url}"
                ) from exc
            events = fetch_agent_server_events(
                app_conversation_id,
                agent_server_url,
                session_api_key,
            )
            agent_text = extract_last_agent_text(events)
    result = normalize_result(parse_agent_json(agent_text))

    result["issue_number"] = args.issue_number
    result["repository"] = args.repository
    result["app_conversation_id"] = app_conversation_id
    result["conversation_url"] = conversation_url
    result["agent_response"] = agent_text

    output_path = Path(args.output)
    try:
        output_path.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n")
    except OSError as exc:
        raise RuntimeError(f"Failed to write output to {output_path}: {exc}") from exc

    print(
        json.dumps(
            {
                "issue_number": result.get("issue_number"),
                "should_comment": result.get("should_comment"),
                "is_duplicate": result.get("is_duplicate"),
                "auto_close_candidate": result.get("auto_close_candidate"),
                "classification": result.get("classification"),
                "confidence": result.get("confidence"),
                "conversation_url": result.get("conversation_url"),
                "output": str(output_path),
            },
            ensure_ascii=False,
        )
    )
    return 0


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except Exception as exc:  # noqa: BLE001
        print(f"error: {exc}", file=sys.stderr)
        raise
