#!/usr/bin/env python3
"""auto-draft — schedule ai-pipeline work based on spare subscription capacity.

OUTLINE REVISION. This revision is dryrun-only: it probes the subscription, computes
the spare capacity, and prints the three priority queues. Job execution is not yet
implemented. See specs/auto-draft.spec.md for the full design.
"""

import argparse
import json
import re
import subprocess
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path

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

VALID_PROVIDERS = ("anthropic", "minimax")
WEEKLY_PERIOD_DAYS = 7
WEEKLY_PERIOD_SECONDS = WEEKLY_PERIOD_DAYS * 86400

# Inline HTML-comment scoring convention (see spec).
SCORE_RE = re.compile(r"<!--\s*score:\s*(\d+)\s*-->")
EXEMPT_RE = re.compile(r"<!--\s*exempt:\s*true\s*-->")
TITLE_RE = re.compile(r"^(\d+)-(.+)$")

# --- ANSI colour ------------------------------------------------------------

_use_colour = sys.stdout.isatty()
_BOLD = "\033[1m" if _use_colour else ""
_DIM = "\033[2m" if _use_colour else ""
_GREEN = "\033[32m" if _use_colour else ""
_YELLOW = "\033[33m" if _use_colour else ""
_RED = "\033[31m" if _use_colour else ""
_CYAN = "\033[36m" if _use_colour else ""
_RESET = "\033[0m" if _use_colour else ""


# --- Helpers ----------------------------------------------------------------

def _now() -> datetime:
    return datetime.now(timezone.utc)


def _fmt_duration(seconds: float) -> str:
    seconds = int(seconds)
    if seconds < 0:
        seconds = 0
    days, rem = divmod(seconds, 86400)
    hours, rem = divmod(rem, 3600)
    minutes, _ = divmod(rem, 60)
    if days > 0:
        return f"{days}d {hours}h"
    if hours > 0:
        return f"{hours}h {minutes}m"
    return f"{minutes}m"


def _fmt_pct(pct: float) -> str:
    return f"{pct:.1f}%"


def _fmt_pct_signed(pct: float) -> str:
    sign = "+" if pct >= 0 else ""
    return f"{sign}{pct:.1f}%"


# --- Provider probe ---------------------------------------------------------

def probe_provider(provider: str) -> dict | None:
    """Run agent-subscriptions --output json and return the matching provider entry."""
    try:
        result = subprocess.run(
            ["agent-subscriptions", "--output", "json"],
            capture_output=True, text=True, check=True, timeout=30,
        )
    except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as e:
        print(f"{_YELLOW}Warning: agent-subscriptions probe failed: {e}{_RESET}", file=sys.stderr)
        return None

    try:
        data = json.loads(result.stdout)
    except json.JSONDecodeError as e:
        print(f"{_YELLOW}Warning: agent-subscriptions returned invalid JSON: {e}{_RESET}", file=sys.stderr)
        return None

    for p in data.get("providers", []):
        if p.get("provider", "").lower() == provider.lower():
            return p
    return None


# --- Spare capacity ---------------------------------------------------------

def compute_spare(provider_record: dict | None, threshold_pct: float) -> dict | None:
    """Compute the linear limit and spare capacity for the seven-day window."""
    if not provider_record or not provider_record.get("available"):
        return None
    seven_day = provider_record.get("windows", {}).get("seven_day", {})
    reset_at_str = seven_day.get("reset_at")
    usage_pct = seven_day.get("utilization_pct")
    if not reset_at_str or usage_pct is None:
        return None

    reset_at = datetime.fromisoformat(reset_at_str.replace("Z", "+00:00"))
    renewed_at = reset_at - timedelta(seconds=WEEKLY_PERIOD_SECONDS)
    now = _now()
    elapsed_seconds = max(0.0, (now - renewed_at).total_seconds())
    elapsed_pct = (elapsed_seconds / WEEKLY_PERIOD_SECONDS) * 100.0
    limit_pct = (elapsed_seconds / WEEKLY_PERIOD_SECONDS) * threshold_pct
    spare_pct = limit_pct - usage_pct

    return {
        "reset_at": reset_at,
        "renewed_at": renewed_at,
        "elapsed_seconds": elapsed_seconds,
        "elapsed_pct": elapsed_pct,
        "threshold_pct": threshold_pct,
        "limit_pct": limit_pct,
        "usage_pct": usage_pct,
        "spare_pct": spare_pct,
    }


def print_spare_capacity(provider: str, spare: dict | None) -> None:
    if spare is None:
        print(f"{_YELLOW}[auto-draft] No spare capacity data for {provider} (provider unavailable or reset_at missing){_RESET}")
        return

    print(f"{_BOLD}[auto-draft] Spare capacity for {provider}{_RESET}")
    print(f"  Reset at:        {spare['reset_at'].isoformat()}")
    print(f"  Renewed at:      {spare['renewed_at'].isoformat()}")
    print(f"  Elapsed:         {_fmt_duration(spare['elapsed_seconds'])} of {WEEKLY_PERIOD_DAYS}d ({spare['elapsed_pct']:.1f}%)")
    print(f"  Threshold:       {_fmt_pct(spare['threshold_pct'])}")
    print(f"  Linear limit:    {_fmt_pct(spare['limit_pct'])}")
    print(f"  Current usage:   {_fmt_pct(spare['usage_pct'])}")

    spare_pct = spare["spare_pct"]
    if spare_pct >= 0:
        colour = _GREEN
        summary = "under budget"
    else:
        colour = _RED
        summary = "over budget; do not queue any work"
    print(f"  Spare capacity:  {colour}{_fmt_pct_signed(spare_pct)}{_RESET} ({summary})")
    print()


# --- Title discovery --------------------------------------------------------

def discover_titles(base_dir: Path) -> list[tuple[int, str, str, Path]]:
    """Return list of (title_id, title_slug, original_stem, parent_dir) sorted by title_id."""
    titles: list[tuple[int, str, str, Path]] = []
    seen: set[tuple[str, Path]] = set()
    for f in sorted(base_dir.rglob("*.overview.md")):
        stem = f.name.removesuffix(".overview.md")
        key = (stem, f.parent)
        if key in seen:
            continue
        seen.add(key)
        m = TITLE_RE.match(stem)
        if m:
            title_id = int(m.group(1))
            title_slug = m.group(2)
        else:
            title_id = float("inf")
            title_slug = stem
        titles.append((title_id, title_slug, stem, f.parent))
    titles.sort(key=lambda x: (x[0], x[1]))
    return titles


def _files_for(parent: Path, stem: str, provider: str) -> tuple[Path, Path]:
    score = parent / f"{stem}.ai_score.{provider}.md"
    generated = parent / f"{stem}.ai_generated.{provider}.md"
    return score, generated


# --- Score parsing ----------------------------------------------------------

def parse_score_file(score_path: Path, polish_score: int) -> tuple[int, int]:
    """Return (paragraphs_below, total_scored)."""
    try:
        content = score_path.read_text()
    except OSError:
        return 0, 0
    paragraphs = re.split(r"\n\s*\n", content)
    below = 0
    scored = 0
    for para in paragraphs:
        if not para.strip():
            continue
        score_m = SCORE_RE.search(para)
        if not score_m:
            continue
        scored += 1
        score = int(score_m.group(1))
        is_exempt = bool(EXEMPT_RE.search(para))
        if score < polish_score and not is_exempt:
            below += 1
    return below, scored


# --- Queue building ---------------------------------------------------------

def build_queues(base_dir: Path, provider: str, polish_score: int):
    """Return (polish_queue, scoring_queue, generation_queue). Each queue is a list of dicts."""
    polish_q: list[dict] = []
    scoring_q: list[dict] = []
    generation_q: list[dict] = []

    for title_id, title_slug, stem, parent in discover_titles(base_dir):
        score_file, generated_file = _files_for(parent, stem, provider)
        if score_file.exists():
            below, total = parse_score_file(score_file, polish_score)
            if below > 0:
                polish_q.append({
                    "title_id": title_id,
                    "title_slug": title_slug,
                    "stem": stem,
                    "parent": parent,
                    "below": below,
                    "total_scored": total,
                })
        elif generated_file.exists():
            scoring_q.append({
                "title_id": title_id,
                "title_slug": title_slug,
                "stem": stem,
                "parent": parent,
            })
        else:
            generation_q.append({
                "title_id": title_id,
                "title_slug": title_slug,
                "stem": stem,
                "parent": parent,
            })

    return polish_q, scoring_q, generation_q


def _sort_queue(q: list[dict]) -> list[dict]:
    return sorted(q, key=lambda x: (x["title_id"], x["title_slug"]))


def print_queue(label: str, kind: str, queue: list[dict]) -> None:
    queue = _sort_queue(queue)
    plural = "s" if len(queue) != 1 else ""
    print(f"{_BOLD}[auto-draft] {label} ({kind}) — {len(queue)} item{plural}{_RESET}")
    if not queue:
        print(f"  {_DIM}(empty){_RESET}")
    else:
        for item in queue:
            stem = item["stem"]
            if kind == "polish":
                print(f"  {stem}  {item['below']} paragraph(s) below score  (of {item['total_scored']} scored)")
            else:
                print(f"  {stem}")
    print()


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

def parse_args(argv: list[str]) -> argparse.Namespace:
    p = argparse.ArgumentParser(
        prog="auto-draft",
        description="Schedule ai-pipeline work based on spare subscription capacity (outline revision).",
    )
    p.add_argument("--provider", required=True, choices=VALID_PROVIDERS,
                   help="Subscription provider to draw capacity from")
    p.add_argument("--threshold", required=True, type=float,
                   help="Maximum subscription utilization over the period, as a percentage")
    p.add_argument("--polish-score", required=True, type=int,
                   help="Paragraph score below which a paragraph is queued for polishing")
    p.add_argument("--base-dir", default=".", type=Path,
                   help="Root directory to scan for dossier files (default: current directory)")
    p.add_argument("-n", "--dryrun", action="store_true", default=True,
                   help="Show capacity and queues without executing (default: dryrun)")
    args = p.parse_args(argv)
    if not (0 < args.threshold <= 100):
        p.error("--threshold must be between 0 and 100 (exclusive of 0)")
    if not (0 <= args.polish_score <= 10):
        p.error("--polish-score must be between 0 and 10")
    return args


def main(argv: list[str] | None = None) -> int:
    args = parse_args(argv if argv is not None else sys.argv[1:])
    base_dir = args.base_dir.expanduser().resolve()
    if not base_dir.is_dir():
        print(f"Error: --base-dir {base_dir} is not a directory", file=sys.stderr)
        return 1

    provider_record = probe_provider(args.provider)
    spare = compute_spare(provider_record, args.threshold)
    print_spare_capacity(args.provider, spare)

    polish_q, scoring_q, generation_q = build_queues(base_dir, args.provider, args.polish_score)
    print_queue("Polish queue", "polish", polish_q)
    print_queue("Scoring queue", "scoring", scoring_q)
    print_queue("Generation queue", "generation", generation_q)

    return 0


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