#!/usr/bin/env python3
"""Wee-Dev autonomous dispatcher — no QA, no PRs.

wee-dev picks up GitHub issues, works directly on dev branch,
commits directly to dev, and closes the issue when done.

Label state machine (simplified):
  (no status label)      = queued, ready for wee-dev
  wee-dev:in-progress    = wee-dev working
  (removed)              = task complete
"""

import argparse
import json
import os
import ssl
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
from urllib import request

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

REPO = "leprachuan/Wee-Orchestrator"
OWNER_LOGIN = "leprachuan"
PIPELINE_STATE_PATH = Path("/opt/wee-dev/pipeline_state.json")
ENV_PATH = Path("/opt/n8n-copilot-shim/.env")
BACKGROUND_TASKS_URL = "https://127.0.0.1:8000/api/v1/background-tasks"
AGENTS_CONFIG_PATH = Path("/opt/n8n-copilot-shim/agents.json")
USER_IDENTITY = "8193231291"
AUTH_CHANNEL = "telegram"

RUNNING_STATUSES = {"created", "queued", "pending", "running", "in_progress"}

# Labels required in repository
REQUIRED_LABELS = {
    "wee-dev": "0075ca",
    "wee-dev:in-progress": "e4e669",
    "wee-dev:needs-approval": "f9a825",
}

# Stall timeout: if in-progress with no running task for this long, re-dispatch
STALL_TIMEOUT_MINUTES = 30

DRY_RUN = False


# ---------------------------------------------------------------------------
# Utilities
# ---------------------------------------------------------------------------


def now_iso() -> str:
    return datetime.now(timezone.utc).isoformat()


def log(msg: str) -> None:
    ts = datetime.now(timezone.utc).isoformat()
    print(f"[{ts}] {msg}")


def load_state() -> dict:
    if PIPELINE_STATE_PATH.exists():
        with open(PIPELINE_STATE_PATH) as f:
            return json.load(f)
    return {}


def save_state(state: dict) -> None:
    if DRY_RUN:
        log("[dry-run] Would write pipeline state")
        return
    PIPELINE_STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
    with open(PIPELINE_STATE_PATH, "w") as f:
        json.dump(state, f, indent=2)


def get_issue_state(state: dict, issue_number: int) -> dict:
    key = str(issue_number)
    if key not in state:
        state[key] = {}
    return state[key]


def set_issue_field(state: dict, issue_number: int, field: str, value) -> None:
    get_issue_state(state, issue_number)[field] = value
    save_state(state)


def _load_api_key() -> str:
    if ENV_PATH.exists():
        with open(ENV_PATH) as f:
            for line in f:
                if line.startswith("API_KEY="):
                    return line.split("=", 1)[1].strip()
    raise RuntimeError("API_KEY not found in .env")


def minutes_since(iso_time: str) -> float:
    """Return minutes elapsed since iso_time."""
    if not iso_time:
        return None
    try:
        past = datetime.fromisoformat(iso_time)
        now = datetime.now(timezone.utc) if past.tzinfo else datetime.now()
        return (now - past).total_seconds() / 60
    except (ValueError, TypeError):
        return None


# ---------------------------------------------------------------------------
# GitHub API
# ---------------------------------------------------------------------------


def gh_api(method: str, path: str, data: dict = None) -> dict:
    """Call GitHub API via gh CLI."""
    cmd = ["gh", "api"]
    if method != "GET":
        cmd.extend(["-X", method])
    if data:
        cmd.append("-f")
        cmd.append(json.dumps(data))
    cmd.append(path)
    result = subprocess.run(cmd, capture_output=True, text=True, check=True)
    return json.loads(result.stdout)


def get_open_wee_dev_issues() -> list[dict]:
    """Fetch open issues with wee-dev label."""
    cmd = [
        "gh", "issue", "list",
        "--repo", REPO,
        "--label", "wee-dev",
        "--state", "open",
        "--json", "number,title,body,author,labels",
    ]
    result = subprocess.run(cmd, capture_output=True, text=True, check=True)
    return json.loads(result.stdout)


def get_issue_status(item: dict) -> str:
    """Determine issue status from labels."""
    labels = [l["name"] for l in item.get("labels", [])]
    if "wee-dev:in-progress" in labels:
        return "in-progress"
    if "wee-dev:needs-approval" in labels:
        return "needs-approval"
    # Default: queued
    return "queued"


def add_label(issue_number: int, label: str) -> None:
    """Add label to issue."""
    if DRY_RUN:
        log(f"[dry-run] Would add label '{label}' to #{issue_number}")
        return
    subprocess.run(
        ["gh", "issue", "edit", str(issue_number),
         "--repo", REPO,
         "--add-label", label],
        check=True,
    )


def remove_label(issue_number: int, label: str) -> None:
    """Remove label from issue."""
    if DRY_RUN:
        log(f"[dry-run] Would remove label '{label}' from #{issue_number}")
        return
    subprocess.run(
        ["gh", "issue", "edit", str(issue_number),
         "--repo", REPO,
         "--remove-label", label],
        check=True,
    )


def add_comment(issue_number: int, body: str) -> None:
    """Add comment to issue."""
    if DRY_RUN:
        preview = body.replace("\n", " ")[:120]
        log(f"[dry-run] Would comment on #{issue_number}: {preview}")
        return
    subprocess.run(
        ["gh", "issue", "comment", str(issue_number),
         "--repo", REPO,
         "--body", body],
        check=True,
    )


def close_issue(issue_number: int) -> None:
    """Close issue."""
    if DRY_RUN:
        log(f"[dry-run] Would close #{issue_number}")
        return
    subprocess.run(
        ["gh", "issue", "close", str(issue_number),
         "--repo", REPO],
        check=True,
    )


def ensure_labels_exist() -> None:
    """Create required labels if they don't exist."""
    if DRY_RUN:
        log("[dry-run] Would ensure required GitHub labels exist")
        return
    for label, color in REQUIRED_LABELS.items():
        subprocess.run(
            ["gh", "label", "create", label,
             "--repo", REPO,
             "--color", color,
             "--force"],
            capture_output=True,
        )


# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------


def _load_agents_config() -> dict:
    """Load agents.json configuration."""
    with open(AGENTS_CONFIG_PATH) as f:
        return json.load(f)


def get_agent_dispatch_config(agent_name: str) -> dict:
    """Get dispatch config for an agent from agents.json."""
    config = _load_agents_config()
    agents_list = config.get("agents", [])
    agent = next((a for a in agents_list if a.get("name") == agent_name), {})
    permissions = agent.get("permissions", {})
    return {
        "runtime": agent.get("primary_runtime", "copilot"),
        "model": agent.get("primary_model", "auto"),
        "timeout": agent.get("timeout", 3600),
        "permission_mode": agent.get("permission_mode") or permissions.get("mode", "restricted"),
        "yolo": agent.get("yolo", False),
        "fallback_runtime": agent.get("fallback_runtime"),
        "fallback_model": agent.get("fallback_model"),
    }


# ---------------------------------------------------------------------------
# Background task dispatch
# ---------------------------------------------------------------------------


def dispatch_via_api(agent: str, prompt: str, cfg: dict) -> str:
    """Dispatch a background task and return the task_id."""
    import hashlib
    import hmac

    api_key = _load_api_key()
    resolved_model = None if (cfg.get("model") or "").lower() in ("auto", "") else cfg.get("model")
    resolved_fallback_model = None if (cfg.get("fallback_model") or "").lower() in ("auto", "") else cfg.get("fallback_model")

    body = {
        "prompt": prompt,
        "agent": agent,
        "runtime": cfg["runtime"],
        "timeout": cfg.get("timeout", 3600),
        "notify": False,
    }
    if resolved_model:
        body["model"] = resolved_model
    if cfg.get("permission_mode"):
        body["permission_mode"] = cfg["permission_mode"]
    if cfg.get("yolo"):
        body["yolo"] = cfg["yolo"]
    # Only send fallback if it's different from primary runtime
    if cfg.get("fallback_runtime") and cfg.get("fallback_runtime") != cfg["runtime"]:
        body["fallback_runtime"] = cfg["fallback_runtime"]
    if resolved_fallback_model:
        body["fallback_model"] = resolved_fallback_model

    payload_json = json.dumps(body, sort_keys=True)
    signature = hmac.new(
        api_key.encode(), payload_json.encode(), hashlib.sha256
    ).hexdigest()

    headers = {
        "Content-Type": "application/json",
        "Authorization": f"Bearer shared_{api_key}",
        "X-User-Identity": USER_IDENTITY,
        "X-Auth-Channel": AUTH_CHANNEL,
        "X-Wee-Executor-Signature": signature,
    }
    ssl_ctx = ssl.create_default_context()
    ssl_ctx.check_hostname = False
    ssl_ctx.verify_mode = ssl.CERT_NONE

    req = request.Request(
        BACKGROUND_TASKS_URL,
        data=json.dumps(body).encode(),
        headers=headers,
        method="POST",
    )
    with request.urlopen(req, context=ssl_ctx, timeout=30) as resp:
        result = json.loads(resp.read())
        task_id = result.get("task_id", "")
        if not task_id:
            raise RuntimeError(f"No task_id in response: {result}")
        return task_id


def is_task_running(task_id: str) -> bool:
    """Return True if the background task is still active."""
    try:
        api_key = _load_api_key()
        url = f"{BACKGROUND_TASKS_URL}/{task_id}"
        headers = {"Authorization": f"Bearer shared_{api_key}"}
        ssl_ctx = ssl.create_default_context()
        ssl_ctx.check_hostname = False
        ssl_ctx.verify_mode = ssl.CERT_NONE
        req = request.Request(url, headers=headers)
        with request.urlopen(req, context=ssl_ctx, timeout=15) as resp:
            data = json.loads(resp.read())
            return data.get("status", "").lower() in RUNNING_STATUSES
    except Exception:
        return False  # API unreachable → assume not running


# ---------------------------------------------------------------------------
# Safety gate
# ---------------------------------------------------------------------------


def passes_safety_gate(item: dict) -> bool:
    """Check if issue is approved for dispatch."""
    author = item.get("author", {}).get("login", "")
    if author == OWNER_LOGIN:
        return True
    # Non-owner issue needs approval
    labels = [l["name"] for l in item.get("labels", [])]
    if "wee-dev:needs-approval" not in labels:
        log(f"  Issue #{item['number']} filed by @{author} — needs approval")
        add_label(item["number"], "wee-dev:needs-approval")
        add_comment(
            item["number"],
            f"⏳ Awaiting owner approval before wee-dev picks this up. "
            f"Filed by @{author}; only @{OWNER_LOGIN} issues are auto-dispatched.",
        )
    return False


# ---------------------------------------------------------------------------
# wee-dev dispatch
# ---------------------------------------------------------------------------


def build_wee_dev_prompt(item: dict) -> str:
    """Build prompt for wee-dev to work on issue."""
    return (
        f"Work on GitHub issue #{item['number']} in {REPO}: {item['title']}.\n\n"
        f"Issue body:\n{item['body']}\n\n"
        "## Task:\n"
        "1. Read the full issue on GitHub to understand all requirements:\n"
        f"   gh issue view {item['number']} --repo {REPO} --comments\n\n"
        "2. Implement the fix/feature on the dev host (192.168.1.100):\n"
        "   SSH: ssh root@192.168.1.100\n"
        "   Path: /opt/n8n-copilot-shim-dev/\n\n"
        "3. Work directly on the dev branch (no feature branches):\n"
        "   git checkout dev && git pull origin dev\n"
        "   (make changes, test locally)\n"
        "   git commit -m 'message'\n"
        "   git push origin dev\n\n"
        "4. Leave clear notes on GitHub as you work:\n"
        "   - Implementation decisions\n"
        "   - Test results\n"
        "   - Final commit SHA\n"
        "   ⚠️ NEVER put secrets, API keys, or credentials in GitHub\n\n"
        "5. When complete:\n"
        "   - Remove the 'wee-dev' label\n"
        "   - Close the issue\n"
        "   - Leave final comment with summary\n\n"
        "6. Work on only ONE issue at a time."
    )


def dispatch_wee_dev(item: dict, state: dict) -> None:
    """Dispatch wee-dev to work on an issue."""
    cfg = get_agent_dispatch_config("wee-dev")
    prompt = build_wee_dev_prompt(item)

    if DRY_RUN:
        log(f"[dry-run] Would dispatch wee-dev for #{item['number']}")
        return

    task_id = dispatch_via_api("wee-dev", prompt, cfg)
    log(f"Dispatched wee-dev task_id={task_id} for #{item['number']}: {item['title']}")
    set_issue_field(state, item["number"], "wee_dev_task_id", task_id)
    set_issue_field(state, item["number"], "wee_dev_dispatched_at", now_iso())


# ---------------------------------------------------------------------------
# Pipeline
# ---------------------------------------------------------------------------


def run_pipeline() -> None:
    """Main dispatcher loop."""
    state = load_state()
    ensure_labels_exist()

    # Fetch issues
    items = get_open_wee_dev_issues()
    log(f"Found {len(items)} open wee-dev issue(s)")

    # Categorize
    in_progress = [i for i in items if get_issue_status(i) == "in-progress"]
    needs_approval = [i for i in items if get_issue_status(i) == "needs-approval"]
    queued = [i for i in items if get_issue_status(i) == "queued"]

    log("  in-progress: " + (", ".join(f"#{i['number']}" for i in in_progress) or "(none)"))
    log("  needs-approval: " + (", ".join(f"#{i['number']}" for i in needs_approval) or "(none)"))
    log("  queued: " + (", ".join(f"#{i['number']}" for i in queued) or "(none)"))

    # Handle in-progress (check for stall)
    if in_progress:
        item = in_progress[0]
        issue_state = get_issue_state(state, item["number"])
        task_id = issue_state.get("wee_dev_task_id")

        if task_id and is_task_running(task_id):
            log(f"wee-dev still running task={task_id} for #{item['number']} — waiting")
            return

        # Task not running — check stall timeout
        dispatched_at = issue_state.get("wee_dev_dispatched_at")
        mins = minutes_since(dispatched_at) if dispatched_at else None
        if mins is not None and mins < STALL_TIMEOUT_MINUTES:
            log(
                f"wee-dev task for #{item['number']} ended within {mins:.1f}min "
                f"(< {STALL_TIMEOUT_MINUTES}min stall timeout) — may still be processing"
            )
            return

        # Timeout exceeded — re-dispatch
        mins_str = f"{mins:.1f}min" if mins is not None else "unknown time"
        log(f"Re-dispatching stalled #{item['number']} (no running task after {mins_str})")
        add_label(item["number"], "wee-dev:in-progress")
        try:
            dispatch_wee_dev(item, state)
        except Exception as exc:
            log(f"ERROR: Failed to re-dispatch: {exc}")
            remove_label(item["number"], "wee-dev:in-progress")
        return

    # No in-progress — pick next queued issue
    if queued:
        for candidate in queued:
            if not passes_safety_gate(candidate):
                continue
            log(f"Dispatching wee-dev for #{candidate['number']}: {candidate['title']}")
            add_label(candidate["number"], "wee-dev:in-progress")
            try:
                dispatch_wee_dev(candidate, state)
            except Exception as exc:
                log(f"ERROR: Failed to dispatch: {exc}")
                remove_label(candidate["number"], "wee-dev:in-progress")
            return

    log("No wee-dev work to do.")


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------


def main() -> None:
    parser = argparse.ArgumentParser(description="Wee-Dev autonomous dispatcher")
    parser.add_argument("--dry-run", action="store_true", help="Simulate without dispatching")
    args = parser.parse_args()

    global DRY_RUN
    DRY_RUN = args.dry_run

    run_pipeline()


if __name__ == "__main__":
    main()
