#!/usr/bin/env python3
"""
TAS ADO Integration Script
Two-way sync between local .md files and Azure DevOps work items.

Kit v3: Epic and User Story types are removed — Feature is the only TAS work unit.

Usage: python tools/tas-ado.py <command> [arguments]

Commands:
  create-feature <temp-id> [--parent-id <id>]
  create-bug     <temp-id> [--parent-id <id>]
  get            <ado-id>
  pull           <ado-id>   (alias for get)
  update-feature <ado-id> [--assign <name>] [--status <state>]
  update-bug     <ado-id> [--assign <name>] [--status <state>]
  update-status  <ado-id> --status <state> [--assign <name>]
  delete-feature <ado-id>
  delete-bug     <ado-id>

  PR commands:
  pr-get    <pr-id>
  pr-diff   <pr-id>
  pr-comment <pr-id> --comment <text>
  pr-inline  <pr-id> --repo-id <guid> --file <path> --line <n> --comment <text>
  pr-vote    <pr-id> --vote <approve|reject|wait-for-author|reset>

Prerequisites:
  - Azure CLI + azure-devops extension
  - Python 3.8+ with pyyaml
  - PAT in tas.yaml, AZURE_DEVOPS_PAT, or AzureDevops_Personal_AccessToken env var
"""

import argparse
import base64
import json
import os
import re
import subprocess
import sys
import tempfile
import urllib.error
import urllib.request
from datetime import datetime
from pathlib import Path

try:
    import yaml
except ImportError:
    print("ERROR: pyyaml required. Run: pip install pyyaml")
    sys.exit(1)


# --- Config ---

def find_repo_root():
    """Walk up from cwd to find tas.yaml at repo root"""
    path = Path.cwd()
    while path != path.parent:
        if (path / "tas.yaml").exists():
            return path
        path = path.parent
    print("ERROR: tas.yaml not found. Run /tas-init first.")
    sys.exit(1)


def load_dotenv(root):
    """Load .env and .env.local from repo root into os.environ.

    .env.local takes precedence (loaded first; setdefault keeps first value).
    """
    for filename in (".env.local", ".env"):
        env_file = root / filename
        if not env_file.exists():
            continue
        with open(env_file, "r", encoding="utf-8") as f:
            for line in f:
                line = line.strip()
                if line and not line.startswith("#") and "=" in line:
                    key, _, value = line.partition("=")
                    value = value.strip().strip('"').strip("'")
                    os.environ.setdefault(key.strip(), value)


def load_config():
    root = find_repo_root()
    load_dotenv(root)
    config_path = root / "tas.yaml"
    with open(config_path, "r", encoding="utf-8") as f:
        config = yaml.safe_load(f)
    ado = config.get("ado", {})
    org = ado.get("organization", "")
    project = ado.get("project_id", "")
    pat = (ado.get("pat")
           or os.environ.get("AZURE_DEVOPS_PAT", "")
           or os.environ.get("AzureDevops_Personal_AccessToken", ""))
    if not org or not project:
        print("ERROR: ado.organization and ado.project_id required in .tas/tas.yaml")
        sys.exit(1)

    project_code = config.get("project", {}).get("code", "")

    team = config.get("team", [])
    assignee_by_role = {}
    for member in team:
        role = member.get("role", "").lower()
        if role not in assignee_by_role:
            assignee_by_role[role] = member.get("ado_id", "")

    return {
        "root": root,
        "org": org,
        "project": project,
        "pat": pat,
        "project_code": project_code,
        "assignee_by_role": assignee_by_role,
    }


def az_cmd(args, config):
    """Run az CLI command and return parsed JSON output.

    Multiline string args (e.g. --description) are written to a temp file
    and passed as @filepath so newlines never break az.cmd on Windows.
    """
    az_bin = "az.cmd" if sys.platform == "win32" else "az"

    tmp_files = []
    safe_args = []
    i = 0
    while i < len(args):
        if (len(str(args[i])) > 200 or "\n" in str(args[i])) and i > 0 and str(args[i - 1]).startswith("--"):
            tmp = tempfile.NamedTemporaryFile(
                mode="wb", suffix=".txt", delete=False
            )
            tmp.write(args[i].encode("utf-8"))
            tmp.close()
            tmp_files.append(tmp.name)
            safe_args.append(f"@{tmp.name}")
        else:
            safe_args.append(args[i])
        i += 1

    # work-item update/show/delete/relation operate by global ID — --project is not accepted
    _no_project_cmds = {"update", "show", "delete", "relation"}
    wi_action = args[2] if len(args) > 2 else ""
    needs_project = wi_action not in _no_project_cmds

    tail = ["--org", config["org"], "--output", "json"]
    if needs_project:
        tail = ["--org", config["org"], "--project", config["project"], "--output", "json"]

    cmd = [az_bin] + safe_args + tail
    env = os.environ.copy()
    if config["pat"]:
        env["AZURE_DEVOPS_EXT_PAT"] = config["pat"]
    try:
        result = subprocess.run(cmd, capture_output=True, env=env)
    finally:
        for f in tmp_files:
            try:
                os.unlink(f)
            except OSError:
                pass

    def _decode(b):
        try:
            return (b or b"").decode("utf-8")
        except UnicodeDecodeError:
            return (b or b"").decode("cp1252", errors="replace")

    if result.returncode != 0:
        print(f"ERROR: az command failed:\n{_decode(result.stderr)}")
        sys.exit(1)
    stdout = _decode(result.stdout).strip()
    if not stdout:
        return {}
    # Some az commands (e.g. delete) prefix output with a plain-text status line
    json_start = re.search(r"[{\[]", stdout)
    if json_start:
        return json.loads(stdout[json_start.start():])
    return {}


# --- File helpers ---

TYPE_MAP = {
    "feature": "Feature",
    "bug": "Bug",
}

# Reverse map for cmd_get — accept legacy ADO types but map them to local Feature.
TYPE_REVERSE = {
    "Feature": "feature",
    "Bug": "bug",
    "Epic": "feature",       # legacy: any Epic pulled from ADO becomes a local Feature
    "User Story": "feature", # legacy: any Story pulled from ADO becomes a local Feature
}


def slugify(text):
    text = text.lower().strip()
    text = re.sub(r"[^\w\s-]", "", text)
    text = re.sub(r"[\s]+", "-", text)
    return text[:60]


def find_file(config, file_type, id_str):
    """Find .md file matching {type}-{id}-*.md or {type}-tmp-{id}-*.md recursively (case-insensitive)."""
    root = config["root"]
    docs_dir = root / "docs"
    id_lower = id_str.lower()
    type_lower = file_type.lower() if file_type != "*" else None
    for md_file in docs_dir.rglob("*.md"):
        name_lower = md_file.name.lower()
        parts = name_lower.split("-", 3)
        if len(parts) < 2:
            continue
        if type_lower is not None and parts[0] != type_lower:
            continue
        # Match: {type}-{id}-*
        if parts[1] == id_lower:
            return str(md_file)
        # Match: {type}-tmp-{id}-* (e.g. story-tmp-A-title.md with id="A")
        if parts[1] == "tmp" and len(parts) >= 3 and parts[2] == id_lower:
            return str(md_file)
    return None


def now_str():
    return datetime.now().strftime("%Y-%m-%d %H:%M:%S")


def read_md_file(filepath):
    """Read .md file, return (frontmatter_dict, body_str)."""
    with open(filepath, "r", encoding="utf-8") as f:
        content = f.read()
    fm = {}
    body = content
    if content.startswith("---"):
        parts = content.split("---", 2)
        if len(parts) >= 3:
            fm = yaml.safe_load(parts[1]) or {}
            body = parts[2].strip()
    return fm, body


def write_md_file(filepath, frontmatter, body):
    """Write .md file with YAML frontmatter."""
    fm_str = yaml.dump(frontmatter, default_flow_style=False, allow_unicode=True).strip()
    with open(filepath, "w", encoding="utf-8") as f:
        f.write(f"---\n{fm_str}\n---\n{body}\n")


def extract_title(body):
    """Extract first # heading as title."""
    for line in body.split("\n"):
        line = line.strip()
        if line.startswith("# "):
            return line[2:].strip()
    return "Untitled"


def extract_description(body):
    """Extract body after first # heading."""
    lines = body.split("\n")
    found_title = False
    desc_lines = []
    for line in lines:
        if not found_title and line.strip().startswith("# "):
            found_title = True
            continue
        if found_title:
            desc_lines.append(line)
    return "\n".join(desc_lines).strip()


def extract_status(fm, body):
    """Read status: frontmatter `status` first (legacy `ado_state`), then body '> **Status:** ...' as fallback."""
    if fm.get("status") or fm.get("ado_state"):
        return fm.get("status") or fm.get("ado_state")
    for line in body.split("\n"):
        m = re.match(r"^>\s*\*{0,2}Status\*{0,2}:\*{0,2}\s*(.+)", line.strip(), re.IGNORECASE)
        if m:
            return m.group(1).strip().strip("*").strip()
    return None


def html_to_md(html):
    """Basic HTML to Markdown conversion for cmd_get."""
    if not html:
        return ""
    text = re.sub(r"<br\s*/?>", "\n", html)
    text = re.sub(r"<p>(.*?)</p>", r"\1\n\n", text, flags=re.DOTALL)
    text = re.sub(r"<strong>(.*?)</strong>", r"**\1**", text)
    text = re.sub(r"<em>(.*?)</em>", r"*\1*", text)
    text = re.sub(r"<li>(.*?)</li>", r"- \1", text, flags=re.DOTALL)
    text = re.sub(r"<[^>]+>", "", text)
    return text.strip()


# --- Commands ---

def cmd_create(args, config):
    file_type = args.type
    temp_id = args.temp_id
    ado_type = TYPE_MAP.get(file_type)
    if not ado_type:
        print(f"ERROR: Unknown type '{file_type}'. Use: feature, bug")
        sys.exit(1)

    filepath = find_file(config, file_type, temp_id)
    if not filepath:
        print(f"ERROR: File not found: {file_type}-{temp_id}-*.md")
        sys.exit(1)

    fm, body = read_md_file(filepath)
    raw_title = fm.get("ado_title") or extract_title(body)

    # Strip "Type-NNN: " prefix (e.g. "Feature-001: Foo" -> "Foo")
    # Accept legacy Epic/Story prefixes too in case user pulled in old files.
    core_title = re.sub(
        r"^(?:Epic|Feature|Story|User Story|Bug)-\d+[:\s\-]+\s*",
        "",
        raw_title,
        flags=re.IGNORECASE,
    ).strip() or raw_title

    # Build ADO title with project code prefix (strip existing prefix to avoid duplicates)
    project_code = config.get("project_code", "")
    if project_code:
        core_title = re.sub(rf"^\[{re.escape(project_code)}\]\s*", "", core_title).strip()
    ado_title = f"[{project_code}] {core_title}" if project_code else core_title

    description = extract_description(body).replace("\n", "<br>")

    # Default assignee: PE for feature, SE for bug
    assignee_by_role = config.get("assignee_by_role", {})
    default_assignee = assignee_by_role.get("pe" if file_type == "feature" else "se", "")

    status = extract_status(fm, body)

    # Create on ADO (without --state; set state in follow-up update if needed)
    create_args = [
        "boards", "work-item", "create",
        "--type", ado_type,
        "--title", ado_title,
    ]
    if description:
        create_args += ["--description", description]
    if default_assignee:
        create_args += ["--assigned-to", default_assignee]

    result = az_cmd(create_args, config)
    ado_id = result.get("id")
    if not ado_id:
        print("ERROR: Failed to get ADO ID from response")
        sys.exit(1)

    print(f"Created {ado_type} #{ado_id}: {ado_title}")

    # Set state via separate update if not default "New"
    if status and status.lower() != "new":
        az_cmd([
            "boards", "work-item", "update",
            "--id", str(ado_id),
            "--state", status,
        ], config)
        print(f"  Set state: {status}")

    # Auto-detect parent if not explicitly provided:
    # 1. from frontmatter parent_ado_id
    # 2. from parent directory name (e.g. epic-22893-*, feature-22919-*)
    if not args.parent_id:
        fm_parent = str(fm.get("parent_ado_id", "")).strip()
        if fm_parent and fm_parent.isdigit():
            args.parent_id = fm_parent
            print(f"  Auto-detected parent from frontmatter: #{args.parent_id}")
        else:
            # Check immediate parent dir for a numeric ID match (legacy/optional).
            # Kit v3 features live flat in docs/features/ — usually no parent to detect.
            for parent_dir in [Path(filepath).parent, Path(filepath).parent.parent]:
                m = re.match(r"^(?:epic|feature)-(\d+)", parent_dir.name, re.IGNORECASE)
                if m:
                    args.parent_id = m.group(1)
                    print(f"  Auto-detected parent from directory: #{args.parent_id}")
                    break

    # Add parent relation
    if args.parent_id:
        az_cmd([
            "boards", "work-item", "relation", "add",
            "--id", str(ado_id),
            "--relation-type", "parent",
            "--target-id", str(args.parent_id),
        ], config)
        print(f"  Added parent relation to #{args.parent_id}")

    # Rename file and parent folder — slug from core_title only
    old_path = Path(filepath)
    slug = slugify(core_title)
    new_name = f"{file_type}-{ado_id}-{slug}.md"
    new_folder_name = f"{file_type}-{ado_id}-{slug}"

    old_folder = old_path.parent
    new_folder = old_folder
    is_work_item_folder = file_type == "feature" and bool(
        re.match(rf"(?i)^{re.escape(file_type)}-", old_folder.name)
    )
    if is_work_item_folder:
        new_folder = old_folder.parent / new_folder_name
        if old_folder != new_folder:
            old_folder.rename(new_folder)
            print(f"  Renamed folder: {old_folder.name} -> {new_folder_name}")

    current_path = new_folder / old_path.name
    new_path = new_folder / new_name
    if current_path != new_path:
        current_path.rename(new_path)
    print(f"  Renamed: {old_path.name} -> {new_name}")

    # Set status in frontmatter (single source of truth)
    if status:
        fm["status"] = status
    else:
        fm["status"] = "New"

    # Update frontmatter
    fm["ado_id"] = ado_id
    fm["ado_type"] = ado_type
    fm["ado_title"] = ado_title
    fm["last_ado_sync"] = now_str()
    if args.parent_id:
        fm["parent_ado_id"] = int(args.parent_id)
    write_md_file(new_path, fm, body)
    print(f"  Updated frontmatter with ado_id={ado_id}")


def cmd_get(args, config):
    ado_id = args.ado_id
    result = az_cmd([
        "boards", "work-item", "show",
        "--id", str(ado_id),
        "--expand", "all",
    ], config)

    fields = result.get("fields", {})
    title = fields.get("System.Title", "Untitled")
    work_item_type = fields.get("System.WorkItemType", "User Story")
    state = fields.get("System.State", "New")
    assigned = fields.get("System.AssignedTo", {})
    assigned_name = assigned.get("displayName", "") if isinstance(assigned, dict) else str(assigned)
    assigned_email = assigned.get("uniqueName", "") if isinstance(assigned, dict) else ""
    description = fields.get("System.Description", "")
    created = fields.get("System.CreatedDate", "")[:10]

    file_type = TYPE_REVERSE.get(work_item_type, "feature")

    slug = slugify(title)
    filename = f"{file_type}-{ado_id}-{slug}.md"

    existing = find_file(config, file_type, str(ado_id))
    if existing:
        filepath = existing
        print(f"Updating existing file: {filepath}")
    else:
        filepath = config["root"] / "docs" / filename
        print(f"Creating new file: {filepath}")

    fm = {
        "ado_id": ado_id,
        "ado_type": work_item_type,
        "ado_title": title,
        "status": state,
        "owner": f"{assigned_name} <{assigned_email}>" if assigned_email else assigned_name,
        "created": created,
        "last_ado_sync": now_str(),
    }

    body_md = html_to_md(description)
    body = f"# {title}\n\n{body_md}" if body_md else f"# {title}\n"

    write_md_file(filepath, fm, body)
    print(f"  Synced #{ado_id}: {title} [{state}]")


def cmd_update(args, config):
    file_type = args.type
    ado_id = args.ado_id

    filepath = find_file(config, file_type, str(ado_id))
    if not filepath:
        filepath = find_file(config, "*", str(ado_id))
    if not filepath:
        print(f"ERROR: File not found for {file_type}-{ado_id}")
        sys.exit(1)

    fm, body = read_md_file(filepath)
    title = fm.get("ado_title") or extract_title(body)
    description = extract_description(body).replace("\n", "<br>")

    status = args.status or extract_status(fm, body)

    update_args = ["boards", "work-item", "update", "--id", str(ado_id), "--title", title]
    if description:
        update_args += ["--description", description]
    if args.assign:
        update_args += ["--assigned-to", args.assign]
    if status:
        update_args += ["--state", status]

    az_cmd(update_args, config)
    print(f"Updated {file_type} #{ado_id}: {title}")

    fm["last_ado_sync"] = now_str()
    if status:
        fm["status"] = status
    if args.assign:
        fm["owner"] = args.assign
    write_md_file(filepath, fm, body)


def cmd_update_status(args, config):
    ado_id = args.ado_id
    update_args = ["boards", "work-item", "update", "--id", str(ado_id)]
    if args.status:
        update_args += ["--state", args.status]
    if args.assign:
        update_args += ["--assigned-to", args.assign]

    az_cmd(update_args, config)
    print(f"Updated status #{ado_id} -> {args.status}")

    filepath = find_file(config, "*", str(ado_id))
    if filepath:
        fm, body = read_md_file(filepath)
        fm["last_ado_sync"] = now_str()
        if args.status:
            fm["status"] = args.status
        if args.assign:
            fm["owner"] = args.assign
        write_md_file(filepath, fm, body)
        print(f"  Updated local file: {filepath}")


def cmd_delete(args, config):
    file_type = args.type
    ado_id = args.ado_id

    az_cmd([
        "boards", "work-item", "delete",
        "--id", str(ado_id),
        "--yes",
    ], config)
    print(f"Deleted {file_type} #{ado_id} on ADO")

    filepath = find_file(config, file_type, str(ado_id))
    if filepath:
        fm, body = read_md_file(filepath)
        fm["status"] = "Removed"
        fm["last_ado_sync"] = now_str()
        write_md_file(filepath, fm, body)
        print(f"  Updated local file status to Removed (file preserved)")


# --- PR helpers ---

def get_org_name(org):
    """Extract bare org name from org URL or name."""
    org = org.rstrip("/")
    if "dev.azure.com/" in org:
        return org.split("dev.azure.com/")[-1]
    if "visualstudio.com" in org:
        return org.split(".visualstudio.com")[0].split("/")[-1]
    return org


def az_repos_cmd(args, config):
    """Run az repos command with explicit org/project from config."""
    az_bin = "az.cmd" if sys.platform == "win32" else "az"

    tmp_files = []
    safe_args = []
    i = 0
    while i < len(args):
        if (len(str(args[i])) > 200 or "\n" in str(args[i])) and i > 0 and str(args[i - 1]).startswith("--"):
            tmp = tempfile.NamedTemporaryFile(mode="wb", suffix=".txt", delete=False)
            tmp.write(str(args[i]).encode("utf-8"))
            tmp.close()
            tmp_files.append(tmp.name)
            safe_args.append(f"@{tmp.name}")
        else:
            safe_args.append(args[i])
        i += 1

    # az repos pr show/list/comment/vote use globally-unique PR id; --project not accepted
    is_repos_pr = len(safe_args) >= 2 and safe_args[0] == "repos" and safe_args[1] == "pr"
    base_cmd = [az_bin] + safe_args + ["--org", config["org"]]
    if not is_repos_pr:
        base_cmd += ["--project", config["project"]]
    cmd = base_cmd + ["--output", "json"]
    env = os.environ.copy()
    if config["pat"]:
        env["AZURE_DEVOPS_EXT_PAT"] = config["pat"]

    try:
        result = subprocess.run(cmd, capture_output=True, env=env)
    finally:
        for f in tmp_files:
            try:
                os.unlink(f)
            except OSError:
                pass

    def _decode(b):
        try:
            return (b or b"").decode("utf-8")
        except UnicodeDecodeError:
            return (b or b"").decode("cp1252", errors="replace")

    if result.returncode != 0:
        print(f"ERROR: az command failed:\n{_decode(result.stderr)}")
        sys.exit(1)
    stdout = _decode(result.stdout).strip()
    if not stdout:
        return {}
    json_start = re.search(r"[{\[]", stdout)
    if json_start:
        return json.loads(stdout[json_start.start():])
    return {}


def ado_rest(method, url, data, config):
    """Call ADO REST API using PAT Basic auth."""
    token = base64.b64encode(f":{config['pat']}".encode("utf-8")).decode("ascii")
    body = json.dumps(data).encode("utf-8")
    req = urllib.request.Request(url, data=body, method=method)
    req.add_header("Authorization", f"Basic {token}")
    req.add_header("Content-Type", "application/json")
    req.add_header("Accept", "application/json")
    try:
        with urllib.request.urlopen(req) as resp:
            return json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        err_body = e.read().decode("utf-8")
        print(f"ERROR: ADO REST API {method} {url} failed ({e.code}): {err_body}")
        sys.exit(1)


# --- PR Commands ---

def cmd_pr_get(args, config):
    pr_id = args.pr_id
    result = az_repos_cmd([
        "repos", "pr", "show",
        "--id", str(pr_id),
    ], config)

    source_ref = result.get("sourceRefName", "")
    target_ref = result.get("targetRefName", "")
    source_branch = source_ref.replace("refs/heads/", "")
    target_branch = target_ref.replace("refs/heads/", "")
    created_by = result.get("createdBy", {})
    creator = created_by.get("displayName", "") if isinstance(created_by, dict) else str(created_by)
    repo = result.get("repository", {})
    repo_id = repo.get("id", "")
    repo_name = repo.get("name", "")
    work_item_refs = result.get("workItemRefs") or []
    work_items = [str(wi.get("id", "")) for wi in work_item_refs if wi.get("id")]

    print(f"PR_ID: {pr_id}")
    print(f"TITLE: {result.get('title', '')}")
    print(f"DESCRIPTION: {result.get('description', '')}")
    print(f"SOURCE_BRANCH: {source_branch}")
    print(f"TARGET_BRANCH: {target_branch}")
    print(f"CREATOR: {creator}")
    print(f"STATUS: {result.get('status', '')}")
    print(f"REPO_ID: {repo_id}")
    print(f"REPO_NAME: {repo_name}")
    print(f"WORK_ITEMS: {','.join(work_items)}")


def cmd_pr_diff(args, config):
    pr_id = args.pr_id
    result = az_repos_cmd([
        "repos", "pr", "show",
        "--id", str(pr_id),
    ], config)

    source_ref = result.get("sourceRefName", "")
    target_ref = result.get("targetRefName", "")
    source_branch = source_ref.replace("refs/heads/", "")
    target_branch = target_ref.replace("refs/heads/", "")

    print(f"SOURCE_BRANCH: {source_branch}")
    print(f"TARGET_BRANCH: {target_branch}")

    fetch_result = subprocess.run(
        ["git", "fetch", "origin", source_branch],
        capture_output=True,
        cwd=str(config["root"]),
    )

    fetch_head = ""
    diff_base = f"origin/{target_branch}"

    if fetch_result.returncode == 0:
        head_result = subprocess.run(
            ["git", "rev-parse", "FETCH_HEAD"],
            capture_output=True,
            cwd=str(config["root"]),
        )
        fetch_head = head_result.stdout.decode("utf-8").strip() if head_result.returncode == 0 else ""
    else:
        # Fallback for merged PRs where source branch was deleted
        src_commit = (result.get("lastMergeSourceCommit") or {}).get("commitId", "")
        tgt_commit = (result.get("lastMergeTargetCommit") or {}).get("commitId", "")
        if src_commit and tgt_commit:
            subprocess.run(["git", "fetch", "origin", src_commit], capture_output=True, cwd=str(config["root"]))
            subprocess.run(["git", "fetch", "origin", tgt_commit], capture_output=True, cwd=str(config["root"]))
            fetch_head = src_commit
            diff_base = tgt_commit
        else:
            warn = (fetch_result.stderr or b"").decode("utf-8", errors="replace")
            print(f"WARNING: git fetch failed and no merge commits available: {warn}")
            return

    print(f"FETCH_HEAD: {fetch_head}")

    diff_result = subprocess.run(
        ["git", "diff", "--name-status", f"{diff_base}...{fetch_head}"],
        capture_output=True,
        cwd=str(config["root"]),
    )
    if diff_result.returncode == 0:
        print("CHANGED_FILES:")
        print(diff_result.stdout.decode("utf-8", errors="replace").strip())
    else:
        warn = (diff_result.stderr or b"").decode("utf-8", errors="replace")
        print(f"WARNING: git diff failed: {warn}")


def cmd_pr_comment(args, config):
    """Post a PR-level (non-inline) comment thread via REST.

    Older azure-devops CLI extensions don't ship `az repos pr comment`, so we
    talk to the Threads REST endpoint directly. Resolves the repo id from
    `pr show`.
    """
    pr_id = args.pr_id
    pr_info = az_repos_cmd([
        "repos", "pr", "show",
        "--id", str(pr_id),
    ], config)
    repo_id = (pr_info.get("repository") or {}).get("id", "")
    if not repo_id:
        print("ERROR: could not resolve repo id for PR")
        sys.exit(1)

    org_name = get_org_name(config["org"])
    project = urllib.request.quote(config["project"], safe="")
    url = (
        f"https://dev.azure.com/{org_name}/{project}/_apis/git/repositories"
        f"/{repo_id}/pullRequests/{pr_id}/threads?api-version=7.1"
    )
    data = {
        "comments": [{"parentCommentId": 0, "content": args.comment, "commentType": "text"}],
        "status": "active",
    }
    result = ado_rest("POST", url, data, config)
    thread_id = result.get("id", "")
    print(f"Posted comment thread #{thread_id} on PR #{pr_id}")


def cmd_pr_inline(args, config):
    pr_id = args.pr_id
    repo_id = args.repo_id
    file_path = "/" + args.file_path.lstrip("/").replace("\\", "/")
    line = args.line

    org_name = get_org_name(config["org"])
    project = urllib.request.quote(config["project"], safe="")
    url = (
        f"https://dev.azure.com/{org_name}/{project}/_apis/git/repositories"
        f"/{repo_id}/pullRequests/{pr_id}/threads?api-version=7.1"
    )
    data = {
        "comments": [{"parentCommentId": 0, "content": args.comment, "commentType": "text"}],
        "status": "active",
        "threadContext": {
            "filePath": file_path,
            "rightFileStart": {"line": line, "offset": 1},
            "rightFileEnd": {"line": line, "offset": 1},
        },
    }
    result = ado_rest("POST", url, data, config)
    thread_id = result.get("id", "")
    print(f"Posted inline comment on {file_path}:{line} (thread #{thread_id})")


def cmd_pr_vote(args, config):
    vote_map = {
        "approve": "approve",
        "reject": "reject",
        "wait": "wait-for-author",
        "wait-for-author": "wait-for-author",
        "reset": "reset",
    }
    vote = vote_map.get(args.vote.lower(), args.vote)
    az_repos_cmd([
        "repos", "pr", "set-vote",
        "--id", str(args.pr_id),
        "--vote", vote,
    ], config)
    print(f"Voted '{vote}' on PR #{args.pr_id}")


# --- Main ---

def main():
    parser = argparse.ArgumentParser(description="TAS ADO Integration")
    subparsers = parser.add_subparsers(dest="command", help="Command to run")

    for t in ["feature", "bug"]:
        p = subparsers.add_parser(f"create-{t}", help=f"Create {t} on ADO")
        p.add_argument("temp_id", help="Temporary ID in filename")
        p.add_argument("--parent-id", dest="parent_id", help="Parent work item ID")
        p.set_defaults(type=t)

    for cmd_name in ["get", "pull"]:
        p = subparsers.add_parser(cmd_name, help="Pull work item from ADO")
        p.add_argument("ado_id", type=int, help="ADO work item ID")

    for t in ["feature", "bug"]:
        p = subparsers.add_parser(f"update-{t}", help=f"Update {t} on ADO")
        p.add_argument("ado_id", type=int, help="ADO work item ID")
        p.add_argument("--assign", help="Assign to name/email")
        p.add_argument("--status", help="Set state")
        p.set_defaults(type=t)

    p = subparsers.add_parser("update-status", help="Quick status update")
    p.add_argument("ado_id", type=int, help="ADO work item ID")
    p.add_argument("--status", required=True, help="New state")
    p.add_argument("--assign", help="Assign to name/email")

    for t in ["feature", "bug"]:
        p = subparsers.add_parser(f"delete-{t}", help=f"Delete {t} on ADO")
        p.add_argument("ado_id", type=int, help="ADO work item ID")
        p.set_defaults(type=t)

    p = subparsers.add_parser("pr-get", help="Get PR metadata")
    p.add_argument("pr_id", type=int, help="Pull Request ID")

    p = subparsers.add_parser("pr-diff", help="Fetch PR branch and list changed files")
    p.add_argument("pr_id", type=int, help="Pull Request ID")

    p = subparsers.add_parser("pr-comment", help="Post summary comment thread on PR")
    p.add_argument("pr_id", type=int, help="Pull Request ID")
    p.add_argument("--comment", required=True, help="Comment text (markdown supported)")

    p = subparsers.add_parser("pr-inline", help="Post inline comment on PR diff")
    p.add_argument("pr_id", type=int, help="Pull Request ID")
    p.add_argument("--repo-id", dest="repo_id", required=True, help="Repository GUID (from pr-get REPO_ID)")
    p.add_argument("--file", dest="file_path", required=True, help="File path (e.g. src/api/users.ts)")
    p.add_argument("--line", type=int, required=True, help="Line number")
    p.add_argument("--comment", required=True, help="Inline comment text")

    p = subparsers.add_parser("pr-vote", help="Set vote on PR")
    p.add_argument("pr_id", type=int, help="Pull Request ID")
    p.add_argument("--vote", required=True,
                   choices=["approve", "reject", "wait-for-author", "reset"],
                   help="Vote to cast")

    args = parser.parse_args()
    if not args.command:
        parser.print_help()
        sys.exit(1)

    config = load_config()

    if args.command.startswith("create-"):
        cmd_create(args, config)
    elif args.command in ("get", "pull"):
        cmd_get(args, config)
    elif args.command.startswith("update-") and args.command != "update-status":
        cmd_update(args, config)
    elif args.command == "update-status":
        cmd_update_status(args, config)
    elif args.command.startswith("delete-"):
        cmd_delete(args, config)
    elif args.command == "pr-get":
        cmd_pr_get(args, config)
    elif args.command == "pr-diff":
        cmd_pr_diff(args, config)
    elif args.command == "pr-comment":
        cmd_pr_comment(args, config)
    elif args.command == "pr-inline":
        cmd_pr_inline(args, config)
    elif args.command == "pr-vote":
        cmd_pr_vote(args, config)
    else:
        parser.print_help()


if __name__ == "__main__":
    main()
