#!/usr/bin/env python3
"""
Sovereign Orchestrator v1
Reads the Gitea backlog, scores/prioritizes issues, dispatches to agents.

Usage:
    python3 orchestrator.py --once     # single dispatch cycle
    python3 orchestrator.py --daemon   # run every 15 min
    python3 orchestrator.py --dry-run  # score and report, no dispatch
"""

import json
import os
import sys
import time
import subprocess
import urllib.request
import urllib.error
import urllib.parse
from datetime import datetime, timezone

# ---------------------------------------------------------------------------
# CONFIG
# ---------------------------------------------------------------------------

GITEA_API = "https://forge.alexanderwhitestone.com/api/v1"
GITEA_OWNER = "Timmy_Foundation"
REPOS = ["timmy-config", "the-nexus", "timmy-home"]

TELEGRAM_CHAT_ID = "-1003664764329"
DAEMON_INTERVAL = 900  # 15 minutes

# Tags that mark issues we should never auto-dispatch
FILTER_TAGS = ["[EPIC]", "[DO NOT CLOSE]", "[PERMANENT]", "[PHILOSOPHY]", "[MORNING REPORT]"]

# Known agent usernames on Gitea (for assignee detection)
AGENT_USERNAMES = {"groq", "ezra", "bezalel", "allegro", "timmy", "thetimmyc"}

# ---------------------------------------------------------------------------
# AGENT ROSTER
# ---------------------------------------------------------------------------

AGENTS = {
    "groq": {
        "type": "loop",
        "endpoint": "local",
        "strengths": ["code", "bug-fix", "small-changes"],
        "repos": ["the-nexus", "hermes-agent", "timmy-config", "timmy-home"],
        "max_concurrent": 1,
    },
    "ezra": {
        "type": "gateway",
        "endpoint": "http://143.198.27.163:8643/v1/chat/completions",
        "ssh": "root@143.198.27.163",
        "strengths": ["research", "architecture", "complex", "multi-file"],
        "repos": ["timmy-config", "the-nexus", "timmy-home"],
        "max_concurrent": 1,
    },
    "bezalel": {
        "type": "gateway",
        "endpoint": "http://159.203.146.185:8643/v1/chat/completions",
        "ssh": "root@159.203.146.185",
        "strengths": ["ci", "infra", "ops", "testing"],
        "repos": ["timmy-config", "hermes-agent", "the-nexus"],
        "max_concurrent": 1,
    },
}

# ---------------------------------------------------------------------------
# CREDENTIALS
# ---------------------------------------------------------------------------

def load_gitea_token():
    """Read Gitea token from env or file."""
    token = os.environ.get("GITEA_TOKEN", "")
    if token:
        return token.strip()
    token_path = os.path.expanduser("~/.hermes/gitea_token_vps")
    try:
        with open(token_path) as f:
            return f.read().strip()
    except FileNotFoundError:
        print(f"[FATAL] No GITEA_TOKEN env and {token_path} not found")
        sys.exit(1)


def load_telegram_token():
    """Read Telegram bot token from file."""
    path = os.path.expanduser("~/.config/telegram/special_bot")
    try:
        with open(path) as f:
            return f.read().strip()
    except FileNotFoundError:
        return ""


GITEA_TOKEN = ""
TELEGRAM_TOKEN = ""

# ---------------------------------------------------------------------------
# HTTP HELPERS (stdlib only)
# ---------------------------------------------------------------------------

def gitea_request(path, method="GET", data=None):
    """Make an authenticated Gitea API request."""
    url = f"{GITEA_API}{path}"
    headers = {
        "Authorization": f"token {GITEA_TOKEN}",
        "Content-Type": "application/json",
        "Accept": "application/json",
    }
    body = json.dumps(data).encode() if data else None
    req = urllib.request.Request(url, data=body, headers=headers, method=method)
    try:
        with urllib.request.urlopen(req, timeout=30) as resp:
            return json.loads(resp.read().decode())
    except urllib.error.HTTPError as e:
        body_text = e.read().decode() if e.fp else ""
        print(f"[API ERROR] {method} {url} -> {e.code}: {body_text[:200]}")
        return None
    except Exception as e:
        print(f"[API ERROR] {method} {url} -> {e}")
        return None


def send_telegram(message):
    """Send message to Telegram group."""
    if not TELEGRAM_TOKEN:
        print("[WARN] No Telegram token, skipping notification")
        return False
    url = f"https://api.telegram.org/bot{TELEGRAM_TOKEN}/sendMessage"
    data = json.dumps({
        "chat_id": TELEGRAM_CHAT_ID,
        "text": message,
        "parse_mode": "Markdown",
        "disable_web_page_preview": True,
    }).encode()
    req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
    try:
        with urllib.request.urlopen(req, timeout=15) as resp:
            return resp.status == 200
    except Exception as e:
        print(f"[TELEGRAM ERROR] {e}")
        return False


# ---------------------------------------------------------------------------
# 1. BACKLOG READER
# ---------------------------------------------------------------------------

def fetch_issues(repo):
    """Fetch all open issues from a repo, handling pagination."""
    issues = []
    page = 1
    while True:
        result = gitea_request(
            f"/repos/{GITEA_OWNER}/{repo}/issues?state=open&type=issues&limit=50&page={page}"
        )
        if not result:
            break
        issues.extend(result)
        if len(result) < 50:
            break
        page += 1
    return issues


def should_filter(issue):
    """Check if issue title contains any filter tags."""
    title = issue.get("title", "").upper()
    for tag in FILTER_TAGS:
        if tag.upper().replace("[", "").replace("]", "") in title.replace("[", "").replace("]", ""):
            return True
    # Also filter pull requests
    if issue.get("pull_request"):
        return True
    return False


def read_backlog():
    """Read and filter the full backlog across all repos."""
    backlog = []
    for repo in REPOS:
        print(f"  Fetching {repo}...")
        issues = fetch_issues(repo)
        for issue in issues:
            if should_filter(issue):
                continue
            assignees = [a.get("login", "") for a in (issue.get("assignees") or [])]
            labels = [l.get("name", "") for l in (issue.get("labels") or [])]
            backlog.append({
                "repo": repo,
                "number": issue["number"],
                "title": issue["title"],
                "labels": labels,
                "assignees": assignees,
                "created_at": issue.get("created_at", ""),
                "comments": issue.get("comments", 0),
                "url": issue.get("html_url", ""),
            })
    print(f"  Total actionable issues: {len(backlog)}")
    return backlog


# ---------------------------------------------------------------------------
# 2. PRIORITY SCORER
# ---------------------------------------------------------------------------

def score_issue(issue):
    """Score an issue 0-100 based on priority signals."""
    score = 0
    title_upper = issue["title"].upper()
    labels_upper = [l.upper() for l in issue["labels"]]
    all_text = title_upper + " " + " ".join(labels_upper)

    # Critical / Bug: +30
    if any(tag in all_text for tag in ["CRITICAL", "BUG"]):
        score += 30

    # P0 / Urgent: +25
    if any(tag in all_text for tag in ["P0", "URGENT"]):
        score += 25

    # P1: +15
    if "P1" in all_text:
        score += 15

    # OPS / Security: +10
    if any(tag in all_text for tag in ["OPS", "SECURITY"]):
        score += 10

    # Unassigned: +10
    if not issue["assignees"]:
        score += 10

    # Age > 7 days: +5
    try:
        created = issue["created_at"].replace("Z", "+00:00")
        created_dt = datetime.fromisoformat(created)
        age_days = (datetime.now(timezone.utc) - created_dt).days
        if age_days > 7:
            score += 5
    except (ValueError, AttributeError):
        pass

    # Has comments: +5
    if issue["comments"] > 0:
        score += 5

    # Infrastructure repo: +5
    if issue["repo"] == "timmy-config":
        score += 5

    # Already assigned to an agent: -10
    if any(a.lower() in AGENT_USERNAMES for a in issue["assignees"]):
        score -= 10

    issue["score"] = max(0, min(100, score))
    return issue


def prioritize_backlog(backlog):
    """Score and sort the backlog by priority."""
    scored = [score_issue(i) for i in backlog]
    scored.sort(key=lambda x: x["score"], reverse=True)
    return scored


# ---------------------------------------------------------------------------
# 3. AGENT HEALTH CHECKS
# ---------------------------------------------------------------------------

def check_process(pattern):
    """Check if a local process matching pattern is running."""
    try:
        result = subprocess.run(
            ["pgrep", "-f", pattern],
            capture_output=True, text=True, timeout=5
        )
        return result.returncode == 0
    except Exception:
        return False


def check_ssh_service(host, service_name):
    """Check if a remote service is running via SSH."""
    try:
        result = subprocess.run(
            ["ssh", "-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=no",
             f"root@{host}",
             f"systemctl is-active {service_name} 2>/dev/null || pgrep -f {service_name}"],
            capture_output=True, text=True, timeout=15
        )
        return result.returncode == 0
    except Exception:
        return False


def check_agent_health(name, agent):
    """Check if an agent is alive and available."""
    if agent["type"] == "loop":
        alive = check_process(f"agent-loop.*{name}")
    elif agent["type"] == "gateway":
        host = agent["ssh"].split("@")[1]
        service = f"hermes-{name}"
        alive = check_ssh_service(host, service)
    else:
        alive = False
    return alive


def get_agent_status():
    """Get health status for all agents."""
    status = {}
    for name, agent in AGENTS.items():
        alive = check_agent_health(name, agent)
        status[name] = {
            "alive": alive,
            "type": agent["type"],
            "strengths": agent["strengths"],
        }
        symbol = "UP" if alive else "DOWN"
        print(f"  {name}: {symbol} ({agent['type']})")
    return status


# ---------------------------------------------------------------------------
# 4. DISPATCHER
# ---------------------------------------------------------------------------

def classify_issue(issue):
    """Classify issue type based on title and labels."""
    title = issue["title"].upper()
    labels = " ".join(issue["labels"]).upper()
    all_text = title + " " + labels

    types = []
    if any(w in all_text for w in ["BUG", "FIX", "BROKEN", "ERROR", "CRASH"]):
        types.append("bug-fix")
    if any(w in all_text for w in ["OPS", "DEPLOY", "CI", "INFRA", "PIPELINE", "MONITOR"]):
        types.append("ops")
    if any(w in all_text for w in ["SECURITY", "AUTH", "TOKEN", "CERT"]):
        types.append("ops")
    if any(w in all_text for w in ["RESEARCH", "AUDIT", "INVESTIGATE", "EXPLORE"]):
        types.append("research")
    if any(w in all_text for w in ["ARCHITECT", "DESIGN", "REFACTOR", "REWRITE"]):
        types.append("architecture")
    if any(w in all_text for w in ["TEST", "TESTING", "QA", "VALIDATE"]):
        types.append("testing")
    if any(w in all_text for w in ["CODE", "IMPLEMENT", "ADD", "CREATE", "BUILD"]):
        types.append("code")
    if any(w in all_text for w in ["SMALL", "QUICK", "SIMPLE", "MINOR", "TWEAK"]):
        types.append("small-changes")
    if any(w in all_text for w in ["COMPLEX", "MULTI", "LARGE", "OVERHAUL"]):
        types.append("complex")

    if not types:
        types = ["code"]  # default

    return types


def match_agent(issue, agent_status, dispatched_this_cycle):
    """Find the best available agent for an issue."""
    issue_types = classify_issue(issue)
    candidates = []

    for name, agent in AGENTS.items():
        # Agent must be alive
        if not agent_status.get(name, {}).get("alive", False):
            continue

        # Agent must handle this repo
        if issue["repo"] not in agent["repos"]:
            continue

        # Agent must not already be dispatched this cycle
        if dispatched_this_cycle.get(name, 0) >= agent["max_concurrent"]:
            continue

        # Score match based on overlapping strengths
        overlap = len(set(issue_types) & set(agent["strengths"]))
        candidates.append((name, overlap))

    if not candidates:
        return None

    # Sort by overlap score descending, return best match
    candidates.sort(key=lambda x: x[1], reverse=True)
    return candidates[0][0]


def assign_issue(repo, number, agent_name):
    """Assign an issue to an agent on Gitea."""
    # First get current assignees to not clobber
    result = gitea_request(f"/repos/{GITEA_OWNER}/{repo}/issues/{number}")
    if not result:
        return False

    current = [a.get("login", "") for a in (result.get("assignees") or [])]
    if agent_name in current:
        print(f"    Already assigned to {agent_name}")
        return True

    new_assignees = current + [agent_name]
    patch_result = gitea_request(
        f"/repos/{GITEA_OWNER}/{repo}/issues/{number}",
        method="PATCH",
        data={"assignees": new_assignees}
    )
    return patch_result is not None


def dispatch_to_gateway(agent_name, agent, issue):
    """Trigger work on a gateway agent via SSH."""
    host = agent["ssh"]
    repo = issue["repo"]
    number = issue["number"]
    title = issue["title"]

    # Try to trigger dispatch via SSH
    cmd = (
        f'ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no {host} '
        f'"echo \'Dispatched by orchestrator: {repo}#{number} - {title}\' '
        f'>> /tmp/hermes-dispatch.log"'
    )
    try:
        subprocess.run(cmd, shell=True, timeout=20, capture_output=True)
        return True
    except Exception as e:
        print(f"    [WARN] SSH dispatch to {agent_name} failed: {e}")
        return False


def dispatch_cycle(backlog, agent_status, dry_run=False):
    """Run one dispatch cycle. Returns dispatch report."""
    dispatched = []
    skipped = []
    dispatched_count = {}  # agent_name -> count dispatched this cycle

    # Only dispatch unassigned issues (or issues not assigned to agents)
    for issue in backlog:
        agent_assigned = any(a.lower() in AGENT_USERNAMES for a in issue["assignees"])

        if agent_assigned:
            skipped.append((issue, "already assigned to agent"))
            continue

        if issue["score"] < 5:
            skipped.append((issue, "score too low"))
            continue

        best_agent = match_agent(issue, agent_status, dispatched_count)
        if not best_agent:
            skipped.append((issue, "no available agent"))
            continue

        if dry_run:
            dispatched.append({
                "agent": best_agent,
                "repo": issue["repo"],
                "number": issue["number"],
                "title": issue["title"],
                "score": issue["score"],
                "dry_run": True,
            })
            dispatched_count[best_agent] = dispatched_count.get(best_agent, 0) + 1
            continue

        # Actually dispatch
        print(f"  Dispatching {issue['repo']}#{issue['number']} -> {best_agent}")
        success = assign_issue(issue["repo"], issue["number"], best_agent)
        if success:
            agent = AGENTS[best_agent]
            if agent["type"] == "gateway":
                dispatch_to_gateway(best_agent, agent, issue)

            dispatched.append({
                "agent": best_agent,
                "repo": issue["repo"],
                "number": issue["number"],
                "title": issue["title"],
                "score": issue["score"],
            })
            dispatched_count[best_agent] = dispatched_count.get(best_agent, 0) + 1
        else:
            skipped.append((issue, "assignment failed"))

    return dispatched, skipped


# ---------------------------------------------------------------------------
# 5. CONSOLIDATED REPORT
# ---------------------------------------------------------------------------

def generate_report(backlog, dispatched, skipped, agent_status, dry_run=False):
    """Generate dispatch cycle report."""
    now = datetime.now().strftime("%Y-%m-%d %H:%M")
    mode = " [DRY RUN]" if dry_run else ""

    lines = []
    lines.append(f"=== Sovereign Orchestrator Report{mode} ===")
    lines.append(f"Time: {now}")
    lines.append(f"Total backlog: {len(backlog)} issues")
    lines.append("")

    # Agent health
    lines.append("-- Agent Health --")
    for name, info in agent_status.items():
        symbol = "UP" if info["alive"] else "DOWN"
        lines.append(f"  {name}: {symbol} ({info['type']})")
    lines.append("")

    # Dispatched
    lines.append(f"-- Dispatched: {len(dispatched)} --")
    for d in dispatched:
        dry = " (dry-run)" if d.get("dry_run") else ""
        lines.append(f"  [{d['score']}] {d['repo']}#{d['number']} -> {d['agent']}{dry}")
        lines.append(f"       {d['title'][:60]}")
    lines.append("")

    # Skipped (top 10)
    skip_summary = {}
    for issue, reason in skipped:
        skip_summary[reason] = skip_summary.get(reason, 0) + 1
    lines.append(f"-- Skipped: {len(skipped)} --")
    for reason, count in sorted(skip_summary.items(), key=lambda x: -x[1]):
        lines.append(f"  {reason}: {count}")
    lines.append("")

    # Top 5 unassigned
    unassigned = [i for i in backlog if not i["assignees"]][:5]
    lines.append("-- Top 5 Unassigned (by priority) --")
    for i in unassigned:
        lines.append(f"  [{i['score']}] {i['repo']}#{i['number']}: {i['title'][:55]}")
    lines.append("")

    report = "\n".join(lines)
    return report


def format_telegram_report(backlog, dispatched, skipped, agent_status, dry_run=False):
    """Format a compact Telegram message."""
    mode = " DRY RUN" if dry_run else ""
    now = datetime.now().strftime("%H:%M")

    parts = [f"*Orchestrator{mode}* ({now})"]
    parts.append(f"Backlog: {len(backlog)} | Dispatched: {len(dispatched)} | Skipped: {len(skipped)}")

    # Agent status line
    agent_line = " | ".join(
        f"{'✅' if v['alive'] else '❌'}{k}" for k, v in agent_status.items()
    )
    parts.append(agent_line)

    if dispatched:
        parts.append("")
        parts.append("*Dispatched:*")
        for d in dispatched[:5]:
            dry = " 🔍" if d.get("dry_run") else ""
            parts.append(f"  `{d['repo']}#{d['number']}` → {d['agent']}{dry}")

    # Top unassigned
    unassigned = [i for i in backlog if not i["assignees"]][:3]
    if unassigned:
        parts.append("")
        parts.append("*Top unassigned:*")
        for i in unassigned:
            parts.append(f"  [{i['score']}] `{i['repo']}#{i['number']}` {i['title'][:40]}")

    return "\n".join(parts)


# ---------------------------------------------------------------------------
# 6. MAIN
# ---------------------------------------------------------------------------

def run_cycle(dry_run=False):
    """Execute one full orchestration cycle."""
    global GITEA_TOKEN, TELEGRAM_TOKEN
    GITEA_TOKEN = load_gitea_token()
    TELEGRAM_TOKEN = load_telegram_token()

    print("\n[1/4] Reading backlog...")
    backlog = read_backlog()

    print("\n[2/4] Scoring and prioritizing...")
    backlog = prioritize_backlog(backlog)
    for i in backlog[:10]:
        print(f"  [{i['score']:3d}] {i['repo']}/{i['number']}: {i['title'][:55]}")

    print("\n[3/4] Checking agent health...")
    agent_status = get_agent_status()

    print("\n[4/4] Dispatching...")
    dispatched, skipped = dispatch_cycle(backlog, agent_status, dry_run=dry_run)

    # Generate reports
    report = generate_report(backlog, dispatched, skipped, agent_status, dry_run=dry_run)
    print("\n" + report)

    # Send Telegram notification
    if dispatched or not dry_run:
        tg_msg = format_telegram_report(backlog, dispatched, skipped, agent_status, dry_run=dry_run)
        send_telegram(tg_msg)

    return backlog, dispatched, skipped


def main():
    import argparse
    parser = argparse.ArgumentParser(description="Sovereign Orchestrator v1")
    parser.add_argument("--once", action="store_true", help="Single dispatch cycle")
    parser.add_argument("--daemon", action="store_true", help="Run every 15 min")
    parser.add_argument("--dry-run", action="store_true", help="Score/report only, no dispatch")
    parser.add_argument("--interval", type=int, default=DAEMON_INTERVAL,
                        help=f"Daemon interval in seconds (default: {DAEMON_INTERVAL})")
    args = parser.parse_args()

    if not any([args.once, args.daemon, args.dry_run]):
        args.dry_run = True  # safe default
        print("[INFO] No mode specified, defaulting to --dry-run")

    print("=" * 60)
    print("  SOVEREIGN ORCHESTRATOR v1")
    print("=" * 60)

    if args.daemon:
        print(f"[DAEMON] Running every {args.interval}s (Ctrl+C to stop)")
        cycle = 0
        while True:
            cycle += 1
            print(f"\n--- Cycle {cycle} ---")
            try:
                run_cycle(dry_run=args.dry_run)
            except Exception as e:
                print(f"[ERROR] Cycle failed: {e}")
            print(f"[DAEMON] Sleeping {args.interval}s...")
            time.sleep(args.interval)
    else:
        run_cycle(dry_run=args.dry_run)


if __name__ == "__main__":
    main()
