#!/usr/bin/env python3
"""
Argus - CLI tool for GitHub/Forgejo community awareness.

Named after Argus Panoptes, the all-seeing giant of Greek mythology.
"""

import argparse
import json
import os
import sqlite3
import subprocess
import sys
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional

# ============================================================================
# Configuration
# ============================================================================

VERSION = "0.4.0"
DEFAULT_DB_PATH = Path.home() / ".argus" / "argus.db"
DEFAULT_PORT = 8100  # Dashboard port; datasette runs on port+1

def get_db_path() -> Path:
    return Path(os.environ.get("ARGUS_DB", DEFAULT_DB_PATH))

def get_schema_path() -> Path:
    # Check relative to script first (for development)
    script_dir = Path(__file__).parent.parent
    schema_path = script_dir / "lib" / "schema.sql"
    if schema_path.exists():
        return schema_path
    # Fall back to installed location
    return Path("/opt/argus/lib/schema.sql")

def get_dashboard_path() -> Path:
    script_dir = Path(__file__).parent.parent
    dashboard_path = script_dir / "dashboard" / "index.html"
    if dashboard_path.exists():
        return dashboard_path
    return Path("/opt/argus/dashboard/index.html")

# ============================================================================
# Database
# ============================================================================

def get_db() -> sqlite3.Connection:
    db_path = get_db_path()
    if not db_path.exists():
        print(f"Error: Database not found at {db_path}", file=sys.stderr)
        print("Run 'argus init' first.", file=sys.stderr)
        sys.exit(1)
    conn = sqlite3.connect(db_path)
    conn.row_factory = sqlite3.Row
    return conn

def init_db() -> None:
    db_path = get_db_path()
    db_path.parent.mkdir(parents=True, exist_ok=True)
    
    schema_path = get_schema_path()
    if not schema_path.exists():
        print(f"Error: Schema not found at {schema_path}", file=sys.stderr)
        sys.exit(1)
    
    conn = sqlite3.connect(db_path)
    with open(schema_path) as f:
        conn.executescript(f.read())
    conn.close()
    print(f"✓ Initialized database at {db_path}")

# ============================================================================
# Backend Configuration
# ============================================================================

DEFAULT_BACKEND = "gh"  # gh | tea

def get_backend() -> str:
    return os.environ.get("ARGUS_BACKEND", DEFAULT_BACKEND)

# ============================================================================
# GitHub API (gh backend)
# ============================================================================

def gh_api(endpoint: str, paginate: bool = False) -> dict:
    """Call GitHub API via gh CLI."""
    try:
        cmd = ["gh", "api"]
        if paginate:
            cmd.append("--paginate")
        cmd.append(endpoint)
        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            check=True
        )
        # --paginate may return multiple JSON arrays concatenated: [...][\n...]
        raw = result.stdout.strip()
        if paginate and raw.startswith('['):
            # Use JSONDecoder to parse successive arrays
            decoder = json.JSONDecoder()
            merged = []
            idx = 0
            while idx < len(raw):
                if raw[idx] in ' \t\n\r':
                    idx += 1
                    continue
                try:
                    obj, end = decoder.raw_decode(raw, idx)
                    if isinstance(obj, list):
                        merged.extend(obj)
                    else:
                        merged.append(obj)
                    idx = end
                except json.JSONDecodeError:
                    break
            return merged
        return json.loads(result.stdout)
    except subprocess.CalledProcessError as e:
        print(f"Error calling GitHub API: {e.stderr}", file=sys.stderr)
        sys.exit(1)
    except json.JSONDecodeError:
        print(f"Error parsing GitHub API response", file=sys.stderr)
        sys.exit(1)

def gh_fetch_notifications() -> list:
    """Fetch notifications from GitHub (with pagination)."""
    notifications = gh_api("notifications", paginate=True)
    results = []
    for notif in notifications:
        repo_full = notif["repository"]["full_name"]
        url = notif["subject"]["url"] or ""
        # Convert API URL to web URL
        web_url = url.replace("api.github.com/repos", "github.com")
        web_url = web_url.replace("/pulls/", "/pull/")
        
        results.append({
            "id": notif["id"],
            "repo_id": repo_full,
            "reason": notif["reason"],
            "title": notif["subject"]["title"],
            "url": web_url,
            "type": notif["subject"]["type"].lower(),
            "subject_url": notif["subject"]["url"] or "",
        })
    return results


def gh_get_own_prs(agent_user: str = "al-munazzim") -> dict:
    """Fetch all open PRs by agent user. Returns {subject_api_url: pr_info}."""
    try:
        results = gh_api(f"search/issues?q=author:{agent_user}+type:pr+state:open&per_page=100")
        own_prs = {}
        for item in results.get("items", []):
            html_url = item.get("html_url", "")
            if "/pull/" in html_url:
                # Build the subject API URL that matches notification subject_url
                api_url = html_url.replace("https://github.com/", "https://api.github.com/repos/")
                api_url = api_url.replace("/pull/", "/pulls/")
                own_prs[api_url] = {
                    "pr_number": item.get("number"),
                    "comments": item.get("comments", 0),
                    "repo": "/".join(html_url.split("/")[3:5]),
                }
        return own_prs
    except Exception as e:
        print(f"Warning: Could not fetch own PRs: {e}", file=sys.stderr)
        return {}


def gh_check_ci_status(repo: str, pr_number: int) -> str:
    """Check CI status for a specific PR."""
    try:
        pr_data = gh_api(f"repos/{repo}/pulls/{pr_number}")
        head_sha = pr_data.get("head", {}).get("sha", "")
        if not head_sha:
            return "unknown"
        check_data = gh_api(f"repos/{repo}/commits/{head_sha}/check-runs")
        runs = check_data.get("check_runs", [])
        if not runs:
            return "none"
        statuses = [r.get("conclusion") or r.get("status") for r in runs]
        if "failure" in statuses:
            return "failing"
        if all(s == "success" for s in statuses):
            return "passing"
        if "in_progress" in statuses or "queued" in statuses:
            return "pending"
        return "mixed"
    except Exception:
        return "unknown"


def apply_filter_rules(conn: sqlite3.Connection, notif: dict) -> tuple:
    """Apply filter rules to a notification. Returns (dismiss: bool, reason: str)."""
    # Rule 0: Own PRs with failing CI or review comments are ALWAYS kept
    if notif.get("_own_pr"):
        own = notif["_own_pr"]
        if own.get("ci_status") == "failing":
            return (False, f"KEEP: own PR #{own.get('pr_number')} has failing CI")
        if own.get("review_comments", 0) > 0:
            return (False, f"KEEP: own PR #{own.get('pr_number')} has {own['review_comments']} review comments")
        # Own PR but no issues — still keep but note why
        return (False, f"KEEP: own PR #{own.get('pr_number')} (ci: {own.get('ci_status', 'unknown')})")
    
    # Load DB filter rules (ordered by priority desc)
    rules = conn.execute(
        "SELECT * FROM filter_rules WHERE enabled = 1 ORDER BY priority DESC"
    ).fetchall()
    
    for rule in rules:
        field_val = str(notif.get(rule["field"], ""))
        match = False
        if rule["op"] == "eq":
            match = field_val == rule["value"]
        elif rule["op"] == "neq":
            match = field_val != rule["value"]
        elif rule["op"] == "contains":
            match = rule["value"].lower() in field_val.lower()
        elif rule["op"] == "matches":
            import re
            match = bool(re.search(rule["value"], field_val, re.IGNORECASE))
        
        if match:
            desc = rule["description"] or f"{rule['field']} {rule['op']} '{rule['value']}'"
            if rule["action"] == "keep":
                return (False, f"KEEP (rule): {desc}")
            else:
                return (True, f"AUTO-DISMISS (rule): {desc}")
    
    # Default rules (hardcoded fallbacks when no DB rules match)
    # Subscribed + not own PR = noise
    if notif.get("reason") == "subscribed":
        return (True, "AUTO-DISMISS: reason=subscribed (mass watch noise)")
    
    # Keep everything else
    return (False, f"KEEP: reason={notif.get('reason')}")

# ============================================================================
# Forgejo API (tea backend)
# ============================================================================

def tea_cmd(args: list) -> str:
    """Run tea CLI command and return output."""
    try:
        result = subprocess.run(
            ["tea"] + args,
            capture_output=True,
            text=True,
            check=True
        )
        return result.stdout
    except subprocess.CalledProcessError as e:
        # Filter out the "NOTE: no gitea login" warning
        stderr = e.stderr.strip()
        if "token does not have" in stderr:
            print(f"Error: {stderr}", file=sys.stderr)
            print("Hint: Your tea token needs 'read:notification' scope.", file=sys.stderr)
        else:
            print(f"Error calling tea CLI: {stderr}", file=sys.stderr)
        sys.exit(1)

def tea_fetch_notifications() -> list:
    """Fetch notifications from Forgejo via tea CLI."""
    import yaml
    
    output = tea_cmd(["notifications", "list", "--mine", "-o", "yaml"])
    
    # Filter out "NOTE:" lines that tea prints to stdout (breaks YAML parsing)
    lines = [l for l in output.split('\n') if not l.startswith('NOTE:')]
    clean_output = '\n'.join(lines)
    
    # Parse YAML output
    try:
        notifications = yaml.safe_load(clean_output) or []
    except Exception as e:
        print(f"Warning: Failed to parse tea output: {e}", file=sys.stderr)
        notifications = []
    
    results = []
    for notif in notifications:
        # tea notifications format differs from gh
        repo = notif.get("repository", "")
        index = notif.get("index", "")
        # Construct URL from repo and issue/PR number
        url = f"https://forgejo.tail593e12.ts.net/{repo}/issues/{index}" if repo and index else ""
        
        results.append({
            "id": str(notif.get("id", "")),
            "repo_id": repo,
            "reason": notif.get("type", "unknown"),
            "title": notif.get("title", ""),
            "url": url,
            "type": notif.get("type", "").lower(),
        })
    return results

# ============================================================================
# Unified Backend Interface
# ============================================================================

def fetch_notifications(backend: str) -> list:
    """Fetch notifications using the specified backend."""
    if backend == "gh":
        return gh_fetch_notifications()
    elif backend == "tea":
        return tea_fetch_notifications()
    else:
        print(f"Error: Unknown backend '{backend}'. Use 'gh' or 'tea'.", file=sys.stderr)
        sys.exit(1)

# ============================================================================
# Repo Watching
# ============================================================================

def gh_list_watched() -> list:
    """List watched repos via gh CLI."""
    repos = gh_api("user/subscriptions?per_page=100")
    return [r["full_name"] for r in repos]

def gh_watch_repo(repo: str) -> bool:
    """Watch a repo via gh CLI."""
    try:
        subprocess.run(
            ["gh", "api", "-X", "PUT", f"repos/{repo}/subscription", 
             "-f", "subscribed=true"],
            capture_output=True, check=True
        )
        return True
    except subprocess.CalledProcessError:
        return False

def gh_unwatch_repo(repo: str) -> bool:
    """Unwatch a repo via gh CLI."""
    try:
        subprocess.run(
            ["gh", "api", "-X", "DELETE", f"repos/{repo}/subscription"],
            capture_output=True, check=True
        )
        return True
    except subprocess.CalledProcessError:
        return False

def tea_list_watched() -> list:
    """List watched repos via Forgejo API."""
    # tea doesn't have a direct command, use API
    try:
        result = subprocess.run(
            ["tea", "api", "GET", "user/subscriptions"],
            capture_output=True, text=True, check=True
        )
        import json
        repos = json.loads(result.stdout)
        return [r["full_name"] for r in repos]
    except:
        # Fallback: try curl with token from tea config
        return tea_list_watched_api()

def tea_list_watched_api() -> list:
    """List watched repos via direct API call."""
    import yaml
    config_path = Path.home() / ".config" / "tea" / "config.yml"
    if not config_path.exists():
        return []
    with open(config_path) as f:
        config = yaml.safe_load(f)
    login = config.get("logins", [{}])[0]
    token = login.get("token", "")
    url = login.get("url", "")
    if not token or not url:
        return []
    try:
        import urllib.request
        req = urllib.request.Request(
            f"{url}/api/v1/user/subscriptions",
            headers={"Authorization": f"token {token}"}
        )
        with urllib.request.urlopen(req) as resp:
            import json
            repos = json.loads(resp.read())
            return [r["full_name"] for r in repos]
    except:
        return []

def tea_watch_repo(repo: str) -> bool:
    """Watch a repo via Forgejo API."""
    import yaml
    config_path = Path.home() / ".config" / "tea" / "config.yml"
    if not config_path.exists():
        return False
    with open(config_path) as f:
        config = yaml.safe_load(f)
    login = config.get("logins", [{}])[0]
    token = login.get("token", "")
    url = login.get("url", "")
    if not token or not url:
        return False
    try:
        import urllib.request
        req = urllib.request.Request(
            f"{url}/api/v1/repos/{repo}/subscription",
            method="PUT",
            headers={"Authorization": f"token {token}"}
        )
        urllib.request.urlopen(req)
        return True
    except:
        return False

def tea_unwatch_repo(repo: str) -> bool:
    """Unwatch a repo via Forgejo API."""
    import yaml
    config_path = Path.home() / ".config" / "tea" / "config.yml"
    if not config_path.exists():
        return False
    with open(config_path) as f:
        config = yaml.safe_load(f)
    login = config.get("logins", [{}])[0]
    token = login.get("token", "")
    url = login.get("url", "")
    if not token or not url:
        return False
    try:
        import urllib.request
        req = urllib.request.Request(
            f"{url}/api/v1/repos/{repo}/subscription",
            method="DELETE",
            headers={"Authorization": f"token {token}"}
        )
        urllib.request.urlopen(req)
        return True
    except:
        return False

def list_watched_repos(backend: str) -> list:
    """List watched repos using the specified backend."""
    if backend == "gh":
        return gh_list_watched()
    elif backend == "tea":
        return tea_list_watched()
    return []

def watch_repo(backend: str, repo: str) -> bool:
    """Watch a repo using the specified backend."""
    if backend == "gh":
        return gh_watch_repo(repo)
    elif backend == "tea":
        return tea_watch_repo(repo)
    return False

def unwatch_repo(backend: str, repo: str) -> bool:
    """Unwatch a repo using the specified backend."""
    if backend == "gh":
        return gh_unwatch_repo(repo)
    elif backend == "tea":
        return tea_unwatch_repo(repo)
    return False

# ============================================================================
# Commands: Core
# ============================================================================

def cmd_init(args) -> None:
    """Initialize database."""
    init_db()

def cmd_version(args) -> None:
    """Show version."""
    print(f"argus {VERSION}")

def cmd_status(args) -> None:
    """Show status summary."""
    conn = get_db()
    
    # Pending notifications
    pending = conn.execute(
        "SELECT COUNT(*) FROM notifications WHERE dismissed = 0 AND acted_at IS NULL"
    ).fetchone()[0]
    
    # Recent activity (last 24h)
    yesterday = (datetime.now() - timedelta(days=1)).isoformat()
    recent_activity = conn.execute(
        "SELECT COUNT(*) FROM activity_log WHERE timestamp > ?",
        (yesterday,)
    ).fetchone()[0]
    
    # Open escalations
    open_escalations = conn.execute(
        "SELECT COUNT(*) FROM escalations WHERE status = 'open'"
    ).fetchone()[0]
    
    # Last poll
    last_poll = conn.execute(
        "SELECT polled_at, new_count FROM notification_polls ORDER BY id DESC LIMIT 1"
    ).fetchone()
    
    print("=" * 50)
    print("ARGUS STATUS")
    print("=" * 50)
    print(f"Pending notifications:  {pending}")
    print(f"Activity (24h):         {recent_activity}")
    print(f"Open escalations:       {open_escalations}")
    if last_poll:
        print(f"Last poll:              {last_poll['polled_at']} ({last_poll['new_count']} new)")
    else:
        print("Last poll:              Never")
    print("=" * 50)
    
    conn.close()

# ============================================================================
# Commands: Repos
# ============================================================================

def cmd_repo_list(args) -> None:
    """List watched repositories."""
    backend = getattr(args, 'backend', None) or get_backend()
    
    repos = list_watched_repos(backend)
    
    if not repos:
        print(f"No watched repositories (backend: {backend})")
        return
    
    print(f"Watched repositories ({backend}):")
    for repo in sorted(repos):
        print(f"  • {repo}")
    print(f"\nTotal: {len(repos)}")

def cmd_repo_watch(args) -> None:
    """Watch a repository."""
    backend = getattr(args, 'backend', None) or get_backend()
    repo = args.repo
    
    if "/" not in repo:
        print(f"Error: Invalid repo format. Use 'owner/repo'.", file=sys.stderr)
        sys.exit(1)
    
    if watch_repo(backend, repo):
        print(f"✓ Now watching: {repo}")
    else:
        print(f"✗ Failed to watch: {repo}", file=sys.stderr)
        sys.exit(1)

def cmd_repo_unwatch(args) -> None:
    """Unwatch a repository."""
    backend = getattr(args, 'backend', None) or get_backend()
    repo = args.repo
    
    if "/" not in repo:
        print(f"Error: Invalid repo format. Use 'owner/repo'.", file=sys.stderr)
        sys.exit(1)
    
    if unwatch_repo(backend, repo):
        print(f"✓ Unwatched: {repo}")
    else:
        print(f"✗ Failed to unwatch: {repo}", file=sys.stderr)
        sys.exit(1)

# ============================================================================
# Commands: Notifications
# ============================================================================

def cmd_notif_pull(args) -> None:
    """Pull notifications from GitHub/Forgejo with smart filtering."""
    conn = get_db()
    
    # Ensure new columns exist (migration for existing DBs)
    _migrate_filter_columns(conn)
    
    # Determine backend
    backend = getattr(args, 'backend', None) or get_backend()
    no_filter = getattr(args, 'no_filter', False)
    print(f"Using backend: {backend}")
    
    # Fetch notifications using the appropriate backend
    notifications = fetch_notifications(backend)
    
    # Pre-fetch own PRs in one API call (instead of per-notification)
    own_pr_index = {}
    if backend == "gh":
        print("Checking own PRs...")
        own_pr_index = gh_get_own_prs()
        if own_pr_index:
            print(f"  Found {len(own_pr_index)} open own PRs")
    
    new_count = 0
    kept_count = 0
    dismissed_count = 0
    
    for notif in notifications:
        notif_id = notif["id"]
        
        # Check if already exists
        existing = conn.execute(
            "SELECT id FROM notifications WHERE id = ?",
            (notif_id,)
        ).fetchone()
        
        if existing:
            continue
        
        # Match against pre-fetched own PRs (O(1) lookup, no API call)
        own_pr = {}
        subject_url = notif.get("subject_url", "")
        if subject_url in own_pr_index:
            own_info = own_pr_index[subject_url]
            # Only fetch CI for own PRs (targeted API calls)
            ci_status = gh_check_ci_status(own_info["repo"], own_info["pr_number"])
            own_pr = {
                "is_own": True,
                "pr_number": own_info["pr_number"],
                "review_comments": own_info["comments"],
                "ci_status": ci_status,
            }
            notif["_own_pr"] = own_pr
        
        # Apply filters
        if no_filter:
            dismiss = False
            filter_reason = "KEEP: --no-filter"
        else:
            dismiss, filter_reason = apply_filter_rules(conn, notif)
        
        is_own = 1 if own_pr.get("is_own") else 0
        ci_status = own_pr.get("ci_status")
        
        if args.dry_run:
            status = "DISMISS" if dismiss else "KEEP"
            print(f"[DRY-RUN] [{status}] {notif['title'][:60]}")
            print(f"           → {filter_reason}")
            new_count += 1
            if dismiss:
                dismissed_count += 1
            else:
                kept_count += 1
            continue
        
        conn.execute("""
            INSERT INTO notifications (id, repo_id, reason, title, url, type, seen_at,
                                       dismissed, filter_reason, is_own_pr, ci_status)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
        """, (notif_id, notif["repo_id"], notif["reason"], notif["title"],
              notif["url"], notif.get("type"), datetime.now().isoformat(),
              1 if dismiss else 0, filter_reason, is_own, ci_status))
        new_count += 1
        if dismiss:
            dismissed_count += 1
        else:
            kept_count += 1
        
        # Mark as read on GitHub (prevents re-fetching ~5000 stale notifications)
        if not args.dry_run and backend == "gh":
            try:
                subprocess.run(
                    ["gh", "api", "--method", "PATCH",
                     f"notifications/threads/{notif_id}"],
                    capture_output=True, text=True, check=False
                )
            except Exception:
                pass  # Best-effort — don't fail the pull if marking read fails
    
    if not args.dry_run:
        # Record poll
        conn.execute(
            "INSERT INTO notification_polls (new_count) VALUES (?)",
            (new_count,)
        )
        conn.commit()
    
    conn.close()
    
    prefix = "[DRY-RUN] " if args.dry_run else "✓ "
    print(f"\n{prefix}Pulled {new_count} new ({kept_count} kept, {dismissed_count} auto-dismissed)")


def _migrate_filter_columns(conn: sqlite3.Connection) -> None:
    """Add new columns to existing databases."""
    cursor = conn.execute("PRAGMA table_info(notifications)")
    columns = {row[1] for row in cursor.fetchall()}
    
    migrations = {
        "type": "TEXT",
        "filter_reason": "TEXT", 
        "is_own_pr": "INTEGER DEFAULT 0",
        "ci_status": "TEXT",
    }
    for col, typedef in migrations.items():
        if col not in columns:
            conn.execute(f"ALTER TABLE notifications ADD COLUMN {col} {typedef}")
    
    # Create filter_rules table if missing
    conn.execute("""
        CREATE TABLE IF NOT EXISTS filter_rules (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            field TEXT NOT NULL,
            op TEXT NOT NULL,
            value TEXT NOT NULL,
            action TEXT DEFAULT 'dismiss',
            priority INTEGER DEFAULT 0,
            enabled INTEGER DEFAULT 1,
            description TEXT
        )
    """)
    conn.commit()

def cmd_notif_list(args) -> None:
    """List notifications."""
    conn = get_db()
    
    query = "SELECT * FROM notifications"
    conditions = []
    params = []
    
    if args.pending:
        conditions.append("dismissed = 0 AND acted_at IS NULL AND processing = 0")
    if getattr(args, 'dismissed', False):
        conditions.append("dismissed = 1")
    if getattr(args, 'own', False):
        conditions.append("is_own_pr = 1")
    
    if conditions:
        query += " WHERE " + " AND ".join(conditions)
    
    query += " ORDER BY seen_at DESC"
    
    if args.limit:
        query += f" LIMIT {args.limit}"
    
    notifications = conn.execute(query, params).fetchall()
    
    if not notifications:
        print("No notifications found.")
        return
    
    for n in notifications:
        try:
            locked = n["processing"]
        except (IndexError, KeyError):
            locked = 0
        status = "✓" if n["acted_at"] else ("✗" if n["dismissed"] else ("🔒" if locked else "○"))
        own_tag = " 🔵" if n["is_own_pr"] else ""
        ci_tag = f" [CI:{n['ci_status']}]" if n["ci_status"] else ""
        print(f"{status}{own_tag}{ci_tag} [{n['id'][:8]}] {n['title'][:60]}")
        print(f"   Repo: {n['repo_id']} | Reason: {n['reason']}")
        if n["filter_reason"]:
            print(f"   Filter: {n['filter_reason']}")
        if n["url"]:
            print(f"   URL: {n['url']}")
        print()
    
    conn.close()

def cmd_notif_act(args) -> None:
    """Mark notification as acted on."""
    conn = get_db()
    
    # Find notification by prefix match
    notif = conn.execute(
        "SELECT * FROM notifications WHERE id LIKE ?",
        (f"{args.id}%",)
    ).fetchone()
    
    if not notif:
        print(f"Error: Notification not found: {args.id}", file=sys.stderr)
        sys.exit(1)
    
    action = args.action
    detail = args.detail or ""
    
    conn.execute("""
        UPDATE notifications 
        SET acted_at = ?, action_taken = ?
        WHERE id = ?
    """, (datetime.now().isoformat(), f"{action}: {detail}".strip(": "), notif["id"]))
    
    # Log the activity
    conn.execute("""
        INSERT INTO activity_log (issue_id, action, detail, github_url)
        VALUES (?, ?, ?, ?)
    """, (notif.get("issue_id"), action, detail or notif["title"], notif.get("url")))
    
    conn.commit()
    conn.close()
    
    print(f"✓ Marked notification as acted: {action}")

def cmd_notif_lock(args) -> None:
    """Lock notification to prevent duplicate processing."""
    conn = get_db()
    notif = conn.execute(
        "SELECT * FROM notifications WHERE id LIKE ?",
        (f"{args.id}%",)
    ).fetchone()
    if not notif:
        print(f"Error: Notification not found: {args.id}", file=sys.stderr)
        sys.exit(1)
    if notif["processing"]:
        print(f"⚠ Already locked: {notif['id'][:8]}")
        sys.exit(1)
    conn.execute("UPDATE notifications SET processing = 1 WHERE id = ?", (notif["id"],))
    conn.commit()
    conn.close()
    print(f"🔒 Locked: {notif['id'][:8]}")


def cmd_notif_unlock(args) -> None:
    """Unlock notification after processing completes."""
    conn = get_db()
    notif = conn.execute(
        "SELECT * FROM notifications WHERE id LIKE ?",
        (f"{args.id}%",)
    ).fetchone()
    if not notif:
        print(f"Error: Notification not found: {args.id}", file=sys.stderr)
        sys.exit(1)
    conn.execute("UPDATE notifications SET processing = 0 WHERE id = ?", (notif["id"],))
    conn.commit()
    conn.close()
    print(f"🔓 Unlocked: {notif['id'][:8]}")


def cmd_notif_dismiss(args) -> None:
    """Dismiss notification."""
    conn = get_db()
    
    # Find notification by prefix match
    notif = conn.execute(
        "SELECT * FROM notifications WHERE id LIKE ?",
        (f"{args.id}%",)
    ).fetchone()
    
    if not notif:
        print(f"Error: Notification not found: {args.id}", file=sys.stderr)
        sys.exit(1)
    
    reason = args.reason or "dismissed"
    
    conn.execute("""
        UPDATE notifications 
        SET dismissed = 1, action_taken = ?
        WHERE id = ?
    """, (f"dismissed: {reason}", notif["id"]))
    
    # Auto-log activity
    title = notif["title"] or "-"
    detail = f"Dismissed: {reason} — {title}"
    conn.execute(
        "INSERT INTO activity_log (issue_id, action, detail, github_url) VALUES (?, ?, ?, ?)",
        (notif["repo_id"], "dismiss", detail, notif["url"])
    )
    
    conn.commit()
    conn.close()
    
    print(f"✓ Dismissed notification: {reason}")

def cmd_notif_audit(args) -> None:
    """Audit stale notifications and create escalations."""
    conn = get_db()
    
    stale_hours = args.stale_hours or 48
    cutoff = (datetime.now() - timedelta(hours=stale_hours)).isoformat()
    
    stale = conn.execute("""
        SELECT * FROM notifications 
        WHERE dismissed = 0 AND acted_at IS NULL AND seen_at < ?
    """, (cutoff,)).fetchall()
    
    if not stale:
        print("✓ No stale notifications found")
        return
    
    print(f"Found {len(stale)} stale notifications (>{stale_hours}h):")
    
    for n in stale:
        print(f"  - {n['title'][:50]}...")
        
        # Create escalation
        conn.execute("""
            INSERT INTO escalations (category, title, detail)
            VALUES (?, ?, ?)
        """, (
            "stale-notification",
            f"Stale: {n['title'][:100]}",
            f"Notification {n['id']} has been pending for >{stale_hours}h.\nURL: {n.get('url', 'N/A')}"
        ))
    
    conn.commit()
    conn.close()
    
    print(f"\n✓ Created {len(stale)} escalations")

# ============================================================================
# Commands: Filter Rules
# ============================================================================

def cmd_filter_list(args) -> None:
    """List filter rules."""
    conn = get_db()
    _migrate_filter_columns(conn)
    rules = conn.execute("SELECT * FROM filter_rules ORDER BY priority DESC").fetchall()
    conn.close()
    
    if not rules:
        print("No filter rules configured.")
        print("\nDefault rules (hardcoded):")
        print("  • reason=subscribed → auto-dismiss")
        print("  • own PRs (al-munazzim) with failing CI or reviews → always keep")
        return
    
    print("Filter rules (highest priority first):\n")
    for r in rules:
        status = "✓" if r["enabled"] else "✗"
        print(f"  {status} [{r['id']}] {r['field']} {r['op']} '{r['value']}' → {r['action']}")
        if r["description"]:
            print(f"       {r['description']}")
    
    print("\nHardcoded rules (always active):")
    print("  • own PRs (al-munazzim) with failing CI or reviews → always keep")
    print("  • reason=subscribed → auto-dismiss (fallback)")


def cmd_filter_add(args) -> None:
    """Add a filter rule."""
    conn = get_db()
    _migrate_filter_columns(conn)
    conn.execute("""
        INSERT INTO filter_rules (field, op, value, action, priority, description)
        VALUES (?, ?, ?, ?, ?, ?)
    """, (args.field, args.op, args.value, args.action, args.priority, args.desc))
    conn.commit()
    conn.close()
    print(f"✓ Added filter rule: {args.field} {args.op} '{args.value}' → {args.action}")


def cmd_filter_rm(args) -> None:
    """Remove a filter rule."""
    conn = get_db()
    _migrate_filter_columns(conn)
    deleted = conn.execute("DELETE FROM filter_rules WHERE id = ?", (args.id,)).rowcount
    conn.commit()
    conn.close()
    if deleted:
        print(f"✓ Removed filter rule {args.id}")
    else:
        print(f"Error: Rule {args.id} not found", file=sys.stderr)
        sys.exit(1)


# ============================================================================
# Commands: Activity
# ============================================================================

def cmd_activity_log(args) -> None:
    """Log an activity."""
    conn = get_db()
    
    conn.execute("""
        INSERT INTO activity_log (issue_id, action, detail)
        VALUES (?, ?, ?)
    """, (args.issue, args.action, args.detail))
    
    conn.commit()
    conn.close()
    
    print(f"✓ Logged: {args.action}")

def cmd_activity_list(args) -> None:
    """List recent activity."""
    conn = get_db()
    
    limit = args.limit or 20
    
    activities = conn.execute("""
        SELECT * FROM activity_log 
        ORDER BY timestamp DESC 
        LIMIT ?
    """, (limit,)).fetchall()
    
    if not activities:
        print("No activity found.")
        return
    
    for a in activities:
        ts = a["timestamp"][:16].replace("T", " ")
        issue = f"[{a['issue_id']}] " if a["issue_id"] else ""
        print(f"{ts} | {issue}{a['action']}: {a['detail'] or ''}")
    
    conn.close()

# ============================================================================
# Commands: Escalations
# ============================================================================

def cmd_escalate_create(args) -> None:
    """Create an escalation."""
    conn = get_db()
    
    conn.execute("""
        INSERT INTO escalations (category, priority, title, detail)
        VALUES (?, ?, ?, ?)
    """, (args.category, args.priority or "normal", args.title, args.detail))
    
    conn.commit()
    conn.close()
    
    print(f"✓ Created escalation: {args.title}")

def cmd_escalate_list(args) -> None:
    """List escalations."""
    conn = get_db()
    
    query = "SELECT * FROM escalations"
    params = []
    
    if args.status:
        query += " WHERE status = ?"
        params.append(args.status)
    
    query += " ORDER BY created_at DESC"
    
    escalations = conn.execute(query, params).fetchall()
    
    if not escalations:
        print("No escalations found.")
        return
    
    for e in escalations:
        status_icon = "🔴" if e["status"] == "open" else "✅"
        priority_icon = "⚠️" if e["priority"] == "high" else ""
        print(f"{status_icon} [{e['id']}] {priority_icon}{e['title']}")
        print(f"   Category: {e['category']} | Created: {e['created_at'][:10]}")
        if e["status"] == "resolved":
            print(f"   Resolved by: {e['resolved_by']} | {e['resolution']}")
        print()
    
    conn.close()

def cmd_escalate_ack(args) -> None:
    """Acknowledge an escalation."""
    conn = get_db()
    
    conn.execute("""
        UPDATE escalations 
        SET status = 'acknowledged'
        WHERE id = ?
    """, (args.id,))
    
    if conn.total_changes == 0:
        print(f"Error: Escalation not found: {args.id}", file=sys.stderr)
        sys.exit(1)
    
    conn.commit()
    conn.close()
    
    print(f"✓ Acknowledged escalation #{args.id}")

def cmd_escalate_resolve(args) -> None:
    """Resolve an escalation."""
    conn = get_db()
    
    conn.execute("""
        UPDATE escalations 
        SET status = 'resolved', resolved_at = ?, resolved_by = ?, resolution = ?
        WHERE id = ?
    """, (datetime.now().isoformat(), args.by, args.resolution, args.id))
    
    if conn.total_changes == 0:
        print(f"Error: Escalation not found: {args.id}", file=sys.stderr)
        sys.exit(1)
    
    conn.commit()
    conn.close()
    
    print(f"✓ Resolved escalation #{args.id}")

# ============================================================================
# Commands: Serve
# ============================================================================

def cmd_serve(args) -> None:
    """Start datasette + dashboard servers."""
    import http.server
    
    db_path = get_db_path()
    dashboard_path = get_dashboard_path()
    
    # Single port config: dashboard on port, datasette on port+1
    port = args.port or int(os.environ.get("ARGUS_PORT", DEFAULT_PORT))
    datasette_port = port + 1
    
    print(f"Starting Argus servers...")
    print(f"  Dashboard:  http://localhost:{port}")
    print(f"  Datasette:  http://localhost:{datasette_port}")
    print()
    
    # Start datasette in background (bind to all interfaces for remote access)
    datasette_proc = subprocess.Popen(
        ["datasette", str(db_path), "-h", "0.0.0.0", "-p", str(datasette_port), "--cors"],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL
    )
    
    # Custom handler that injects the datasette port into the dashboard
    class ArgusHandler(http.server.SimpleHTTPRequestHandler):
        def do_GET(self):
            if self.path == "/" or self.path == "/index.html":
                self.send_response(200)
                self.send_header("Content-type", "text/html")
                self.end_headers()
                with open(dashboard_path) as f:
                    html = f.read()
                # Inject datasette port config
                html = html.replace(
                    "const API_BASE = 'http://localhost:8100'",
                    f"const API_BASE = 'http://localhost:{datasette_port}'"
                )
                self.wfile.write(html.encode())
            else:
                super().do_GET()
    
    os.chdir(dashboard_path.parent)
    
    with http.server.HTTPServer(("", port), ArgusHandler) as httpd:
        try:
            httpd.serve_forever()
        except KeyboardInterrupt:
            print("\nShutting down...")
            datasette_proc.terminate()

# ============================================================================
# Main
# ============================================================================

def main():
    parser = argparse.ArgumentParser(
        prog="argus",
        description="CLI tool for GitHub/Forgejo community awareness"
    )
    subparsers = parser.add_subparsers(dest="command", help="Commands")
    
    # Core commands
    subparsers.add_parser("init", help="Initialize database")
    subparsers.add_parser("version", help="Show version")
    subparsers.add_parser("status", help="Show status summary")
    
    # Repo commands
    repo_parser = subparsers.add_parser("repo", help="Repository watching")
    repo_sub = repo_parser.add_subparsers(dest="repo_cmd")
    
    repo_list_parser = repo_sub.add_parser("list", help="List watched repositories")
    repo_list_parser.add_argument("--backend", choices=["gh", "tea"], help="Backend: gh (GitHub) or tea (Forgejo)")
    
    repo_watch_parser = repo_sub.add_parser("watch", help="Watch a repository")
    repo_watch_parser.add_argument("repo", help="Repository (owner/repo)")
    repo_watch_parser.add_argument("--backend", choices=["gh", "tea"], help="Backend: gh (GitHub) or tea (Forgejo)")
    
    repo_unwatch_parser = repo_sub.add_parser("unwatch", help="Unwatch a repository")
    repo_unwatch_parser.add_argument("repo", help="Repository (owner/repo)")
    repo_unwatch_parser.add_argument("--backend", choices=["gh", "tea"], help="Backend: gh (GitHub) or tea (Forgejo)")
    
    # Notification commands
    notif_parser = subparsers.add_parser("notif", help="Notification management")
    notif_sub = notif_parser.add_subparsers(dest="notif_cmd")
    
    pull_parser = notif_sub.add_parser("pull", help="Pull notifications from GitHub/Forgejo")
    pull_parser.add_argument("--dry-run", action="store_true", help="Show what would be pulled")
    pull_parser.add_argument("--no-filter", action="store_true", help="Disable filtering, keep all")
    pull_parser.add_argument("--backend", choices=["gh", "tea"], help="Backend: gh (GitHub) or tea (Forgejo)")
    
    list_parser = notif_sub.add_parser("list", help="List notifications")
    list_parser.add_argument("--pending", action="store_true", help="Show only pending")
    list_parser.add_argument("--dismissed", action="store_true", help="Show only dismissed")
    list_parser.add_argument("--own", action="store_true", help="Show only own PRs")
    list_parser.add_argument("--limit", type=int, help="Limit results")
    
    # Filter rule management
    filter_parser = notif_sub.add_parser("filter", help="Manage filter rules")
    filter_sub = filter_parser.add_subparsers(dest="filter_cmd")
    
    filter_list_p = filter_sub.add_parser("list", help="List filter rules")
    
    filter_add_p = filter_sub.add_parser("add", help="Add filter rule")
    filter_add_p.add_argument("--field", required=True, choices=["reason", "repo_id", "title", "type"], help="Field to match")
    filter_add_p.add_argument("--op", required=True, choices=["eq", "neq", "contains", "matches"], help="Operator")
    filter_add_p.add_argument("--value", required=True, help="Value to match")
    filter_add_p.add_argument("--action", choices=["dismiss", "keep"], default="dismiss", help="Action (default: dismiss)")
    filter_add_p.add_argument("--priority", type=int, default=0, help="Priority (higher = checked first)")
    filter_add_p.add_argument("--desc", help="Human-readable description")
    
    filter_rm_p = filter_sub.add_parser("rm", help="Remove filter rule")
    filter_rm_p.add_argument("id", type=int, help="Rule ID")
    
    act_parser = notif_sub.add_parser("act", help="Mark as acted on")
    act_parser.add_argument("id", help="Notification ID (prefix match)")
    act_parser.add_argument("action", help="Action taken")
    act_parser.add_argument("detail", nargs="?", help="Additional detail")
    
    dismiss_parser = notif_sub.add_parser("dismiss", help="Dismiss notification")
    dismiss_parser.add_argument("id", help="Notification ID (prefix match)")
    dismiss_parser.add_argument("reason", nargs="?", help="Dismissal reason")
    
    lock_parser = notif_sub.add_parser("lock", help="Lock notification for processing")
    lock_parser.add_argument("id", help="Notification ID (prefix match)")
    
    unlock_parser = notif_sub.add_parser("unlock", help="Unlock notification after processing")
    unlock_parser.add_argument("id", help="Notification ID (prefix match)")
    
    audit_parser = notif_sub.add_parser("audit", help="Audit stale notifications")
    audit_parser.add_argument("--stale-hours", type=int, help="Hours to consider stale (default: 48)")
    
    # Activity commands
    activity_parser = subparsers.add_parser("activity", help="Activity logging")
    activity_sub = activity_parser.add_subparsers(dest="activity_cmd")
    
    log_parser = activity_sub.add_parser("log", help="Log an activity")
    log_parser.add_argument("action", help="Action type")
    log_parser.add_argument("detail", help="Action detail")
    log_parser.add_argument("--issue", help="Related issue ID")
    
    list_activity_parser = activity_sub.add_parser("list", help="List recent activity")
    list_activity_parser.add_argument("--limit", type=int, help="Limit results")
    
    # Escalation commands
    escalate_parser = subparsers.add_parser("escalate", help="Escalation management")
    escalate_sub = escalate_parser.add_subparsers(dest="escalate_cmd")
    
    create_parser = escalate_sub.add_parser("create", help="Create escalation")
    create_parser.add_argument("--category", required=True, help="Escalation category")
    create_parser.add_argument("--title", required=True, help="Escalation title")
    create_parser.add_argument("--detail", help="Additional detail")
    create_parser.add_argument("--priority", choices=["low", "normal", "high"], help="Priority")
    
    list_esc_parser = escalate_sub.add_parser("list", help="List escalations")
    list_esc_parser.add_argument("--status", choices=["open", "acknowledged", "resolved"], help="Filter by status")
    
    ack_parser = escalate_sub.add_parser("ack", help="Acknowledge escalation")
    ack_parser.add_argument("id", type=int, help="Escalation ID")
    
    resolve_parser = escalate_sub.add_parser("resolve", help="Resolve escalation")
    resolve_parser.add_argument("id", type=int, help="Escalation ID")
    resolve_parser.add_argument("--by", required=True, help="Who resolved")
    resolve_parser.add_argument("--resolution", required=True, help="Resolution description")
    
    # Serve command
    serve_parser = subparsers.add_parser("serve", help="Start servers")
    serve_parser.add_argument("--port", type=int, help=f"Dashboard port (default: {DEFAULT_PORT}); datasette runs on port+1")
    
    args = parser.parse_args()
    
    if not args.command:
        parser.print_help()
        sys.exit(0)
    
    # Route to command handlers
    if args.command == "init":
        cmd_init(args)
    elif args.command == "version":
        cmd_version(args)
    elif args.command == "status":
        cmd_status(args)
    elif args.command == "repo":
        if args.repo_cmd == "list":
            cmd_repo_list(args)
        elif args.repo_cmd == "watch":
            cmd_repo_watch(args)
        elif args.repo_cmd == "unwatch":
            cmd_repo_unwatch(args)
        else:
            repo_parser.print_help()
    elif args.command == "notif":
        if args.notif_cmd == "pull":
            cmd_notif_pull(args)
        elif args.notif_cmd == "list":
            cmd_notif_list(args)
        elif args.notif_cmd == "act":
            cmd_notif_act(args)
        elif args.notif_cmd == "dismiss":
            cmd_notif_dismiss(args)
        elif args.notif_cmd == "lock":
            cmd_notif_lock(args)
        elif args.notif_cmd == "unlock":
            cmd_notif_unlock(args)
        elif args.notif_cmd == "audit":
            cmd_notif_audit(args)
        elif args.notif_cmd == "filter":
            if getattr(args, 'filter_cmd', None) == "list":
                cmd_filter_list(args)
            elif getattr(args, 'filter_cmd', None) == "add":
                cmd_filter_add(args)
            elif getattr(args, 'filter_cmd', None) == "rm":
                cmd_filter_rm(args)
            else:
                filter_parser.print_help()
        else:
            notif_parser.print_help()
    elif args.command == "activity":
        if args.activity_cmd == "log":
            cmd_activity_log(args)
        elif args.activity_cmd == "list":
            cmd_activity_list(args)
        else:
            activity_parser.print_help()
    elif args.command == "escalate":
        if args.escalate_cmd == "create":
            cmd_escalate_create(args)
        elif args.escalate_cmd == "list":
            cmd_escalate_list(args)
        elif args.escalate_cmd == "ack":
            cmd_escalate_ack(args)
        elif args.escalate_cmd == "resolve":
            cmd_escalate_resolve(args)
        else:
            escalate_parser.print_help()
    elif args.command == "serve":
        cmd_serve(args)
    else:
        parser.print_help()

if __name__ == "__main__":
    main()
