#!/usr/bin/env python3
"""
Muda Audit — Fleet Waste Elimination
Measures the 7 wastes across Timmy_Foundation repos and posts a weekly report.

Part of Epic: #345
Issue: #350

Wastes:
1. Overproduction — agent issues created vs closed
2. Waiting       — rate-limited API attempts from loop logs
3. Transport     — issues closed-and-redirected to other repos
4. Overprocessing— PR diff size outliers (>500 lines for non-epics)
5. Inventory     — issues open >30 days with no activity
6. Motion        — git clone/rebase operations per issue from logs
7. Defects       — PRs closed without merge vs merged
"""

from __future__ import annotations

import json
import os
import re
import sys
import urllib.request
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any

# Add repo root to path so we can import gitea_client
_REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(_REPO_ROOT))

from gitea_client import GiteaClient, GiteaError  # noqa: E402

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

ORG = "Timmy_Foundation"
AGENT_LOGINS = {
    "allegro",
    "antigravity",
    "bezalel",
    "claude",
    "codex-agent",
    "ezra",
    "gemini",
    "google",
    "grok",
    "groq",
    "hermes",
    "kimi",
    "manus",
    "perplexity",
}
AGENT_LOGINS_HUMAN = {
    "claude": "Claude",
    "codex-agent": "Codex",
    "ezra": "Ezra",
    "gemini": "Gemini",
    "google": "Google",
    "grok": "Grok",
    "groq": "Groq",
    "hermes": "Hermes",
    "kimi": "Kimi",
    "manus": "Manus",
    "perplexity": "Perplexity",
    "allegro": "Allegro",
    "antigravity": "Antigravity",
    "bezalel": "Bezalel",
}

TELEGRAM_CHAT = "-1003664764329"
TELEGRAM_TOKEN_FILE = Path.home() / ".hermes" / "telegram_token"

METRICS_DIR = Path(os.path.expanduser("~/.local/timmy/muda-audit"))
METRICS_FILE = METRICS_DIR / "metrics.json"

LOG_PATHS = [
    Path.home() / ".hermes" / "logs" / "claude-loop.log",
    Path.home() / ".hermes" / "logs" / "gemini-loop.log",
    Path.home() / ".hermes" / "logs" / "agent.log",
    Path.home() / ".hermes" / "logs" / "errors.log",
    Path.home() / ".hermes" / "logs" / "gateway.log",
]

# Patterns that indicate an issue was redirected / transported
TRANSPORT_PATTERNS = [
    re.compile(r"redirect", re.IGNORECASE),
    re.compile(r"moved to", re.IGNORECASE),
    re.compile(r"wrong repo", re.IGNORECASE),
    re.compile(r"belongs in", re.IGNORECASE),
    re.compile(r"should be in", re.IGNORECASE),
    re.compile(r"transported", re.IGNORECASE),
    re.compile(r"relocated", re.IGNORECASE),
]

RATE_LIMIT_PATTERNS = [
    re.compile(r"rate.limit", re.IGNORECASE),
    re.compile(r"ratelimit", re.IGNORECASE),
    re.compile(r"429"),
    re.compile(r"too many requests", re.IGNORECASE),
    re.compile(r"rate limit exceeded", re.IGNORECASE),
]

MOTION_PATTERNS = [
    re.compile(r"git clone", re.IGNORECASE),
    re.compile(r"git rebase", re.IGNORECASE),
    re.compile(r"rebasing", re.IGNORECASE),
    re.compile(r"cloning into", re.IGNORECASE),
]

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


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


def parse_iso(dt_str: str) -> datetime:
    dt_str = dt_str.replace("Z", "+00:00")
    return datetime.fromisoformat(dt_str)


def since_days_ago(days: int) -> datetime:
    return datetime.now(timezone.utc) - timedelta(days=days)


def fmt_num(n: float) -> str:
    return f"{n:.1f}" if isinstance(n, float) else str(n)


def send_telegram(message: str) -> bool:
    if not TELEGRAM_TOKEN_FILE.exists():
        print("[WARN] Telegram token not found; skipping notification.")
        return False
    token = TELEGRAM_TOKEN_FILE.read_text().strip()
    url = f"https://api.telegram.org/bot{token}/sendMessage"
    body = json.dumps(
        {
            "chat_id": TELEGRAM_CHAT,
            "text": message,
            "parse_mode": "Markdown",
            "disable_web_page_preview": True,
        }
    ).encode()
    req = urllib.request.Request(
        url, data=body, headers={"Content-Type": "application/json"}, method="POST"
    )
    try:
        with urllib.request.urlopen(req, timeout=15) as resp:
            resp.read()
        return True
    except Exception as e:
        print(f"[WARN] Telegram send failed: {e}")
        return False


def load_previous_metrics() -> dict | None:
    if not METRICS_FILE.exists():
        return None
    try:
        history = json.loads(METRICS_FILE.read_text())
        if history and isinstance(history, list):
            return history[-1]
    except (json.JSONDecodeError, OSError):
        pass
    return None


def save_metrics(record: dict) -> None:
    METRICS_DIR.mkdir(parents=True, exist_ok=True)
    history: list[dict] = []
    if METRICS_FILE.exists():
        try:
            history = json.loads(METRICS_FILE.read_text())
            if not isinstance(history, list):
                history = []
        except (json.JSONDecodeError, OSError):
            history = []
    history.append(record)
    history = history[-52:]
    METRICS_FILE.write_text(json.dumps(history, indent=2))


# ---------------------------------------------------------------------------
# Gitea helpers
# ---------------------------------------------------------------------------


def paginate_all(func, *args, **kwargs) -> list[Any]:
    page = 1
    limit = kwargs.pop("limit", 50)
    results: list[Any] = []
    while True:
        batch = func(*args, limit=limit, page=page, **kwargs)
        if not batch:
            break
        results.extend(batch)
        if len(batch) < limit:
            break
        page += 1
    return results


def list_org_repos(client: GiteaClient, org: str) -> list[str]:
    repos = paginate_all(client.list_org_repos, org, limit=50)
    return [r["name"] for r in repos if not r.get("archived", False)]


def count_issues_created_by_agents(client: GiteaClient, repo: str, since: datetime) -> int:
    issues = paginate_all(client.list_issues, repo, state="all", sort="created", direction="desc", limit=50)
    count = 0
    for issue in issues:
        created = parse_iso(issue.created_at)
        if created < since:
            break
        if issue.user.login in AGENT_LOGINS:
            count += 1
    return count


def count_issues_closed(client: GiteaClient, repo: str, since: datetime) -> int:
    issues = paginate_all(client.list_issues, repo, state="closed", sort="updated", direction="desc", limit=50)
    count = 0
    for issue in issues:
        updated = parse_iso(issue.updated_at)
        if updated < since:
            break
        count += 1
    return count


def count_inventory_issues(client: GiteaClient, repo: str, stale_days: int = 30) -> int:
    cutoff = since_days_ago(stale_days)
    issues = paginate_all(client.list_issues, repo, state="open", sort="updated", direction="asc", limit=50)
    count = 0
    for issue in issues:
        updated = parse_iso(issue.updated_at)
        if updated < cutoff:
            count += 1
        else:
            break
    return count


def count_transport_issues(client: GiteaClient, repo: str, since: datetime) -> int:
    issues = client.list_issues(repo, state="closed", sort="updated", direction="desc", limit=20)
    transport = 0
    for issue in issues:
        if parse_iso(issue.updated_at) < since:
            break
        try:
            comments = client.list_comments(repo, issue.number)
        except GiteaError:
            continue
        for comment in comments:
            body = comment.body or ""
            if any(p.search(body) for p in TRANSPORT_PATTERNS):
                transport += 1
                break
    return transport


def get_pr_diff_size(client: GiteaClient, repo: str, pr_number: int) -> int:
    try:
        files = client.get_pull_files(repo, pr_number)
        return sum(f.additions + f.deletions for f in files)
    except GiteaError:
        return 0


def measure_overprocessing(client: GiteaClient, repo: str, since: datetime) -> dict:
    pulls = paginate_all(client.list_pulls, repo, state="all", sort="newest", limit=30)
    sizes: list[int] = []
    outliers: list[tuple[int, str, int]] = []
    for pr in pulls:
        created = parse_iso(pr.created_at) if pr.created_at else since - timedelta(days=8)
        if created < since:
            break
        diff_size = get_pr_diff_size(client, repo, pr.number)
        sizes.append(diff_size)
        if diff_size > 500 and not any(w in pr.title.lower() for w in ("epic", "[epic]")):
            outliers.append((pr.number, pr.title, diff_size))
    avg = round(sum(sizes) / len(sizes), 1) if sizes else 0.0
    return {"avg_lines": avg, "outliers": outliers, "count": len(sizes)}


def measure_defects(client: GiteaClient, repo: str, since: datetime) -> dict:
    pulls = paginate_all(client.list_pulls, repo, state="closed", sort="newest", limit=50)
    merged = 0
    closed_unmerged = 0
    for pr in pulls:
        created = parse_iso(pr.created_at) if pr.created_at else since - timedelta(days=8)
        if created < since:
            break
        if pr.merged:
            merged += 1
        else:
            closed_unmerged += 1
    return {"merged": merged, "closed_unmerged": closed_unmerged}


# ---------------------------------------------------------------------------
# Log parsing
# ---------------------------------------------------------------------------


def parse_logs_for_patterns(since: datetime, patterns: list[re.Pattern]) -> list[str]:
    matches: list[str] = []
    for log_path in LOG_PATHS:
        if not log_path.exists():
            continue
        try:
            with open(log_path, "r", errors="ignore") as f:
                for line in f:
                    line = line.strip()
                    if not line:
                        continue
                    ts = None
                    m = re.match(r"^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})", line)
                    if m:
                        try:
                            ts = datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc)
                        except ValueError:
                            pass
                    if ts and ts < since:
                        continue
                    if any(p.search(line) for p in patterns):
                        matches.append(line)
        except OSError:
            continue
    return matches


def measure_waiting(since: datetime) -> dict:
    lines = parse_logs_for_patterns(since, RATE_LIMIT_PATTERNS)
    by_agent: dict[str, int] = {}
    total = len(lines)
    for line in lines:
        agent = "unknown"
        for name in AGENT_LOGINS_HUMAN.values():
            if name.lower() in line.lower():
                agent = name.lower()
                break
        if agent == "unknown":
            if "claude" in line.lower():
                agent = "claude"
            elif "gemini" in line.lower():
                agent = "gemini"
            elif "groq" in line.lower():
                agent = "groq"
            elif "kimi" in line.lower():
                agent = "kimi"
        by_agent[agent] = by_agent.get(agent, 0) + 1
    return {"total": total, "by_agent": by_agent}


def measure_motion(since: datetime) -> dict:
    lines = parse_logs_for_patterns(since, MOTION_PATTERNS)
    by_issue: dict[str, int] = {}
    total = len(lines)
    issue_pattern = re.compile(r"issue[_\s-]?(\d+)", re.IGNORECASE)
    branch_pattern = re.compile(r"\b([a-z]+)/issue[_\s-]?(\d+)\b", re.IGNORECASE)
    for line in lines:
        issue_key = None
        m = branch_pattern.search(line)
        if m:
            issue_key = f"{m.group(1).lower()}/issue-{m.group(2)}"
        else:
            m = issue_pattern.search(line)
            if m:
                issue_key = f"issue-{m.group(1)}"
        if issue_key:
            by_issue[issue_key] = by_issue.get(issue_key, 0) + 1
        else:
            by_issue["unknown"] = by_issue.get("unknown", 0) + 1
    flagged = {k: v for k, v in by_issue.items() if v > 3 and k != "unknown"}
    return {"total": total, "by_issue": by_issue, "flagged": flagged}


# ---------------------------------------------------------------------------
# Report builder
# ---------------------------------------------------------------------------


def build_report(metrics: dict, prev: dict | None) -> str:
    lines: list[str] = []
    lines.append("*🗑️ MUDA AUDIT — Weekly Waste Report*")
    lines.append(f"Week ending {metrics['week_ending'][:10]}\n")

    def trend_arrow(current: float, previous: float) -> str:
        if previous == 0:
            return ""
        if current < previous:
            return " ↓"
        if current > previous:
            return " ↑"
        return " →"

    prev_w = prev or {}

    op = metrics["overproduction"]
    op_prev = prev_w.get("overproduction", {})
    ratio = op["ratio"]
    ratio_prev = op_prev.get("ratio", 0.0)
    lines.append(
        f"*1. Overproduction:* {op['agent_created']} agent issues created / {op['closed']} closed"
        f" (ratio {fmt_num(ratio)}{trend_arrow(ratio, ratio_prev)})"
    )

    w = metrics["waiting"]
    w_prev = prev_w.get("waiting", {})
    w_total_prev = w_prev.get("total", 0)
    lines.append(
        f"*2. Waiting:* {w['total']} rate-limit hits this week{trend_arrow(w['total'], w_total_prev)}"
    )
    if w["by_agent"]:
        top = sorted(w["by_agent"].items(), key=lambda x: x[1], reverse=True)[:3]
        lines.append("   Top offenders: " + ", ".join(f"{k}({v})" for k, v in top))

    t = metrics["transport"]
    t_prev = prev_w.get("transport", {})
    t_total_prev = t_prev.get("total", 0)
    lines.append(
        f"*3. Transport:* {t['total']} issues closed-and-redirected{trend_arrow(t['total'], t_total_prev)}"
    )

    ov = metrics["overprocessing"]
    ov_prev = prev_w.get("overprocessing", {})
    avg_prev = ov_prev.get("avg_lines", 0.0)
    lines.append(
        f"*4. Overprocessing:* Avg PR diff {fmt_num(ov['avg_lines'])} lines"
        f"{trend_arrow(ov['avg_lines'], avg_prev)}, {len(ov['outliers'])} outliers >500 lines"
    )

    inv = metrics["inventory"]
    inv_prev = prev_w.get("inventory", {})
    inv_total_prev = inv_prev.get("total", 0)
    lines.append(
        f"*5. Inventory:* {inv['total']} stale issues open >30 days{trend_arrow(inv['total'], inv_total_prev)}"
    )

    m = metrics["motion"]
    m_prev = prev_w.get("motion", {})
    m_total_prev = m_prev.get("total", 0)
    lines.append(
        f"*6. Motion:* {m['total']} git clone/rebase ops this week{trend_arrow(m['total'], m_total_prev)}"
    )
    if m["flagged"]:
        lines.append(f"   Flagged: {len(m['flagged'])} issues with >3 ops")

    d = metrics["defects"]
    d_prev = prev_w.get("defects", {})
    defect_rate = d["defect_rate"]
    defect_rate_prev = d_prev.get("defect_rate", 0.0)
    lines.append(
        f"*7. Defects:* {d['merged']} merged, {d['closed_unmerged']} abandoned"
        f" (defect rate {fmt_num(defect_rate)}%{trend_arrow(defect_rate, defect_rate_prev)})"
    )

    lines.append("\n*🔥 Top 3 Elimination Suggestions:*")
    for i, suggestion in enumerate(metrics["eliminations"], 1):
        lines.append(f"{i}. {suggestion}")

    lines.append("\n_Week over week: waste metrics should decrease. If an arrow points up, investigate._")
    return "\n".join(lines)


def compute_eliminations(metrics: dict) -> list[str]:
    suggestions: list[tuple[str, float]] = []

    op = metrics["overproduction"]
    if op["ratio"] > 1.0:
        suggestions.append(
            (
                "Overproduction: Stop agent loops from creating issues faster than they close them."
                f" Cap new issue creation when open backlog >{op['closed'] * 2}.",
                op["ratio"],
            )
        )

    w = metrics["waiting"]
    if w["total"] > 10:
        top = max(w["by_agent"].items(), key=lambda x: x[1])
        suggestions.append(
            (
                f"Waiting: {top[0]} is burning cycles on rate limits ({top[1]} hits)."
                " Add exponential backoff or reduce worker count.",
                w["total"],
            )
        )

    t = metrics["transport"]
    if t["total"] > 0:
        suggestions.append(
            (
                "Transport: Issues are being filed in the wrong repos."
                " Add a repo-scoping gate before any agent creates an issue.",
                t["total"] * 2,
            )
        )

    ov = metrics["overprocessing"]
    if ov["outliers"]:
        suggestions.append(
            (
                f"Overprocessing: {len(ov['outliers'])} PRs exceeded 500 lines for non-epics."
                " Enforce a 200-line soft limit unless the issue is tagged 'epic'.",
                len(ov["outliers"]) * 1.5,
            )
        )

    inv = metrics["inventory"]
    if inv["total"] > 20:
        suggestions.append(
            (
                f"Inventory: {inv['total']} issues are dead stock (>30 days)."
                " Run a stale-issue sweep and auto-close or consolidate.",
                inv["total"],
            )
        )

    m = metrics["motion"]
    if m["flagged"]:
        suggestions.append(
            (
                f"Motion: {len(m['flagged'])} issues required excessive clone/rebase ops."
                " Cache worktrees and reuse branches across retries.",
                len(m["flagged"]) * 1.5,
            )
        )

    d = metrics["defects"]
    total_prs = d["merged"] + d["closed_unmerged"]
    if total_prs > 0 and d["defect_rate"] > 20:
        suggestions.append(
            (
                f"Defects: {d['defect_rate']:.0f}% of PRs were abandoned."
                " Require a pre-PR scoping check to prevent unmergeable work.",
                d["defect_rate"],
            )
        )

    suggestions.sort(key=lambda x: x[1], reverse=True)
    return [s[0] for s in suggestions[:3]] if suggestions else [
        "No major waste detected this week. Maintain current guardrails.",
        "Continue monitoring agent loop logs for emerging rate-limit patterns.",
        "Keep PR diff sizes under review during weekly standup.",
    ]


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


def run_audit() -> dict:
    client = GiteaClient()
    since = since_days_ago(7)
    week_ending = datetime.now(timezone.utc).date().isoformat()

    print("[muda] Fetching repo list...")
    repo_names = list_org_repos(client, ORG)
    print(f"[muda] Scanning {len(repo_names)} repos")

    agent_created = 0
    issues_closed = 0
    transport_total = 0
    inventory_total = 0
    all_overprocessing: list[dict] = []
    all_defects_merged = 0
    all_defects_closed = 0

    for name in repo_names:
        repo = f"{ORG}/{name}"
        print(f"[muda] {repo}")
        try:
            agent_created += count_issues_created_by_agents(client, repo, since)
            issues_closed += count_issues_closed(client, repo, since)
            transport_total += count_transport_issues(client, repo, since)
            inventory_total += count_inventory_issues(client, repo, 30)

            op_proc = measure_overprocessing(client, repo, since)
            all_overprocessing.append(op_proc)

            defects = measure_defects(client, repo, since)
            all_defects_merged += defects["merged"]
            all_defects_closed += defects["closed_unmerged"]
        except GiteaError as e:
            print(f"  [WARN] {repo}: {e}")
            continue

    waiting = measure_waiting(since)
    motion = measure_motion(since)

    total_prs = all_defects_merged + all_defects_closed
    defect_rate = round((all_defects_closed / total_prs) * 100, 1) if total_prs else 0.0

    avg_lines = 0.0
    total_op_count = sum(op["count"] for op in all_overprocessing)
    if total_op_count:
        avg_lines = round(
            sum(op["avg_lines"] * op["count"] for op in all_overprocessing) / total_op_count, 1
        )
    all_outliers = [o for op in all_overprocessing for o in op["outliers"]]

    ratio = round(agent_created / issues_closed, 2) if issues_closed else float(agent_created)

    metrics = {
        "week_ending": week_ending,
        "timestamp": iso_now(),
        "overproduction": {
            "agent_created": agent_created,
            "closed": issues_closed,
            "ratio": ratio,
        },
        "waiting": waiting,
        "transport": {"total": transport_total},
        "overprocessing": {
            "avg_lines": avg_lines,
            "outliers": all_outliers,
            "count": total_op_count,
        },
        "inventory": {"total": inventory_total},
        "motion": motion,
        "defects": {
            "merged": all_defects_merged,
            "closed_unmerged": all_defects_closed,
            "defect_rate": defect_rate,
        },
    }

    metrics["eliminations"] = compute_eliminations(metrics)
    return metrics


def main() -> int:
    print("[muda] Starting Muda Audit...")
    metrics = run_audit()
    prev = load_previous_metrics()
    report = build_report(metrics, prev)

    print("\n" + "=" * 50)
    print(report)
    print("=" * 50)

    save_metrics(metrics)
    sent = send_telegram(report)
    if sent:
        print("\n[OK] Report posted to Telegram.")
    else:
        print("\n[WARN] Telegram notification not sent.")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
