#!/usr/bin/env python3
"""Comprehensive Redmine CLI for the UCSC Genome Browser team.

Reads the Redmine API key from ~/.hg.conf (redmine.apiKey=...).

Usage:
    redmineCli show 33571
    redmineCli list --project maillists --status open --limit 10
    redmineCli create --subject "Bug report" --description "Details here"
    redmineCli create --project genomebrowser --tracker 11 --subject "New track" \
        --description "Details here" --assemblies hg38   # Track tracker requires Assemblies
    redmineCli build-patch --subject "v500 build patch: ..." --description "..." \
        --developer braney --commit-id abc123 --files src/hg/js/hgTracks.js \
        --cgis hgTracks --test-case "..." --reviewer hiram \
        --target-version 500 --relates 27113
    redmineCli comment 33571 --message "Adding a note"
    redmineCli update 33571 --status 1 --assigned-to lou --note "Reopening"
    redmineCli update 33571 --release-log-url "../cgi-bin/hgTrackUi?db=hg38&g=myTrack"
    redmineCli update 33571 --custom-field 46="some value"
    redmineCli attach 33571 screenshot.png --note "See attached"
    redmineCli note 20460 85                   # show note-85 from ticket 20460
    redmineCli relate 10316 15336 30368       # relate tickets to each other
    redmineCli watch 37339 lou braney         # add watchers to a ticket
    redmineCli users                          # list user names and IDs
"""

import argparse
import json
import mimetypes
import os
import re
import sys
import tempfile
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime

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

DEFAULT_REDMINE = "https://redmine.gi.ucsc.edu"
DEFAULT_PROJECT = "maillists"

TRACKER_MLQ = 7
TRACKER_BUILD_PATCH = 36
PRIORITY_UNPRIORITIZED = 12
PRIORITY_URGENT = 6
STATUS_NEW = 1
PROJECT_GB = "genomebrowser"

# Name -> Redmine tracker ID mapping (case-insensitive lookup in resolve_tracker)
TRACKER_IDS = {
    "bug": 21,
    "feature": 23,
    "track": 11,
    "hub": 45,
    "data sets": 46, "datasets": 46,
    "to do": 10, "todo": 10,
    "docs": 25,
    "assembly": 24,
    "process": 12,
    "meeting": 28,
    "cr": 26,
    "cgi build": 33,
    "mlq": 7,
    "mlq off list": 15,
    "suggestion box": 44,
    "github": 48,
    "information": 35, "info": 35,
    "build patch": 36,
    "release": 47,
    "housekeeping": 49,
}

# Name -> Redmine status ID mapping (case-insensitive lookup in resolve_status)
STATUS_IDS = {
    "new": 1,
    "looking for dev": 37,
    "snoozed": 34,
    "limbo": 36,
    "researching/exploratory": 35, "researching": 35, "exploratory": 35,
    "masked 2bit file": 29,
    "initial sequence": 25,
    "minimal browser": 26,
    "docs in progress": 27,
    "on deck": 33,
    "in progress": 2,
    "stalled": 13,
    "qa ready": 10, "qa": 10,
    "available": 30,
    "loaded": 8,
    "resolved": 3,
    "written": 15,
    "reviewing": 11,
    "approved": 16,
    "bounced": 24,
    "feedback": 4,
    "patched": 22,
    "cgi-ready": 20,
    "cgi-ready-open-issues": 21,
    "hibernating": 32,
    "preview1": 17,
    "preview2": 18,
    "final build": 19,
    "rejected": 6,
    "verified": 23,
    "released": 12,
    "closed": 5,
    "reopened": 31,
}

CF_CATEGORY = 28
CF_EMAIL = 40
CF_MLM = 9

# Track ticket custom fields
CF_RELEASE_LOG_TEXT = 48
CF_RELEASE_LOG_URL = 46
CF_RELEASED_TO_RR = 47
CF_FILE_LIST = 43
CF_TABLE_LIST = 44
CF_ASSEMBLIES = 2

# Build Patch ticket custom fields (all but Post-mortem are required by the tracker)
CF_DEVELOPER = 22
CF_COMMIT_ID = 23
CF_FILES_CHANGED = 24
CF_CGIS_RETEST = 25
CF_TEST_CASE = 27
CF_CODE_REVIEWER = 31
CF_POST_MORTEM = 61


# Name -> Redmine user ID mapping (short names and full names)
USER_IDS = {
    "ana": 174, "ana benet": 174,
    "angie": 34, "angie hinrichs": 34,
    "ann": 3, "ann zweig": 3,
    "bob": 45, "bob kuhn": 45,
    "blee": 122, "brian lee": 122,
    "braney": 31, "brian raney": 31,
    "build": 197, "build meister": 197,
    "cath": 155, "cath tyner": 155,
    "charlie": 186, "charlie vaske": 186,
    "chin": 25, "chin li": 25,
    "chris": 152, "chris eisenhart": 152,
    "christopher": 156, "christopher lee": 156,
    "clay": 161, "clay fischer": 161,
    "cricket": 29, "cricket sloan": 29,
    "daniel": 172, "daniel schmelter": 172,
    "dev": 157, "dev team": 157,
    "donna": 4, "donna karolchik": 4,
    "erich": 52, "erich weiler": 52,
    "galt": 28, "galt barber": 28,
    "gautomation": 195, "genome automation": 195,
    "gadmin": 71, "genome browser admin": 71,
    "gerardo": 179, "gerardo perez": 179, "gera": 179,
    "haifang": 165, "haifang telc": 165,
    "hiram": 24, "hiram clawson": 24,
    "jairo": 163, "jairo navarro": 163,
    "jason": 180, "jason fernandes": 180,
    "jeltje": 184, "jeltje van baren": 184,
    "jim": 44, "jim kent": 44,
    "johannes": 190, "johannes birgmeier": 190,
    "jonathan": 142, "jonathan casper": 142,
    "jorge": 5, "jorge garcia": 5,
    "kate": 33, "kate rosenbloom": 33,
    "lou": 171, "lou nassar": 171,
    "marc": 183, "marc perry": 183,
    "mark": 7, "mark diekhans": 7,
    "matt": 150, "matt speir": 150,
    "max": 100, "max haeussler": 100,
    "melissa": 27, "melissa cline": 27,
    "pauline": 16, "pauline fujita": 16,
    "qa": 99, "qa team": 99,
    "rachel": 41, "rachel harte": 41,
    "ward": 196, "ward en": 196,
}

ATTRIBUTION = "**From Claude:**\n\n"

# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------

_BOOL_TRUE = {"1", "true", "yes", "on"}
_BOOL_FALSE = {"0", "false", "no", "off"}


def _validate_bool(value, field_name):
    """Validate a checkbox custom-field value; return canonical "0" or "1"."""
    norm = str(value).strip().lower()
    if norm in _BOOL_TRUE:
        return "1"
    if norm in _BOOL_FALSE:
        return "0"
    sys.exit(f"Error: {field_name}: expected one of "
             f"0/1/true/false/yes/no/on/off, got: {value!r}")


# CF ID -> (validator, friendly name). Used both for named flags and to gate
# the --custom-field ID=VALUE escape hatch when ID matches a known typed field.
FIELD_VALIDATORS = {
    CF_RELEASED_TO_RR: (_validate_bool, "Released To RR"),
}


def read_api_key(conf_path="~/.hg.conf"):
    """Read redmine.apiKey from ~/.hg.conf."""
    conf_path = os.path.expanduser(conf_path)
    with open(conf_path) as f:
        for line in f:
            line = line.strip()
            if line.startswith("redmine.apiKey="):
                return line.split("=", 1)[1]
    sys.exit("Error: redmine.apiKey not found in " + conf_path)


def api_get(base_url, path, api_key):
    """GET a JSON endpoint from Redmine."""
    url = base_url.rstrip("/") + path
    req = urllib.request.Request(url)
    req.add_header("X-Redmine-API-Key", api_key)
    req.add_header("Accept", "application/json")
    try:
        with urllib.request.urlopen(req, timeout=30) as resp:
            return json.loads(resp.read())
    except urllib.error.HTTPError as e:
        body = e.read().decode("utf-8", errors="replace")[:500]
        sys.exit(f"Error: HTTP {e.code} GET {url}: {body}")
    except urllib.error.URLError as e:
        sys.exit(f"Error: {e.reason} connecting to {url}")


def api_post(base_url, path, api_key, data):
    """POST JSON to Redmine. Returns parsed JSON response (or None if empty)."""
    url = base_url.rstrip("/") + path
    body = json.dumps(data).encode("utf-8")
    req = urllib.request.Request(url, data=body, method="POST")
    req.add_header("X-Redmine-API-Key", api_key)
    req.add_header("Content-Type", "application/json")
    try:
        with urllib.request.urlopen(req, timeout=30) as resp:
            raw = resp.read()
            return json.loads(raw) if raw else None
    except urllib.error.HTTPError as e:
        resp_body = e.read().decode("utf-8", errors="replace")[:500]
        sys.exit(f"Error: HTTP {e.code} POST {url}: {resp_body}")
    except urllib.error.URLError as e:
        sys.exit(f"Error: {e.reason} connecting to {url}")


def api_put(base_url, path, api_key, data):
    """PUT JSON to Redmine. Returns True on success."""
    url = base_url.rstrip("/") + path
    body = json.dumps(data).encode("utf-8")
    req = urllib.request.Request(url, data=body, method="PUT")
    req.add_header("X-Redmine-API-Key", api_key)
    req.add_header("Content-Type", "application/json")
    try:
        with urllib.request.urlopen(req, timeout=30) as resp:
            return True
    except urllib.error.HTTPError as e:
        resp_body = e.read().decode("utf-8", errors="replace")[:500]
        sys.exit(f"Error: HTTP {e.code} PUT {url}: {resp_body}")
    except urllib.error.URLError as e:
        sys.exit(f"Error: {e.reason} connecting to {url}")


def api_upload(base_url, api_key, filename, file_data):
    """Upload binary file to Redmine, returns upload token."""
    encoded_name = urllib.parse.quote(filename)
    url = f"{base_url.rstrip('/')}/uploads.json?filename={encoded_name}"
    req = urllib.request.Request(url, data=file_data, method="POST")
    req.add_header("X-Redmine-API-Key", api_key)
    req.add_header("Content-Type", "application/octet-stream")
    try:
        with urllib.request.urlopen(req, timeout=60) as resp:
            return json.loads(resp.read())["upload"]["token"]
    except urllib.error.HTTPError as e:
        resp_body = e.read().decode("utf-8", errors="replace")[:500]
        sys.exit(f"Error: HTTP {e.code} uploading {filename}: {resp_body}")
    except urllib.error.URLError as e:
        sys.exit(f"Error: {e.reason} uploading {filename}")


def format_date(iso_str):
    """Format an ISO date string nicely."""
    dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
    return dt.strftime("%Y-%m-%d %H:%M UTC")


def redmine_textile_to_md(text):
    """Convert common Redmine textile/wiki markup to Markdown."""
    if not text:
        return ""
    text = re.sub(r'(?<!\w)\*(\S.*?\S)\*(?!\w)', r'**\1**', text)
    text = re.sub(r'(?<!\w)_(\S.*?\S)_(?!\w)', r'*\1*', text)
    text = re.sub(r'@([^@\n]+)@', r'`\1`', text)
    text = re.sub(r'<pre>\s*', '\n```\n', text)
    text = re.sub(r'\s*</pre>', '\n```\n', text)
    for i in range(1, 7):
        text = re.sub(rf'^h{i}\.\s*', '#' * i + ' ', text, flags=re.MULTILINE)
    text = re.sub(r'!([^!\n]+\.(png|jpg|jpeg|gif))!', r'![image](\1)', text, flags=re.IGNORECASE)
    text = re.sub(r'"([^"]+)":(\S+)', r'[\1](\2)', text)
    return text


def resolve_user(name_or_id):
    """Resolve a user name or numeric ID to a Redmine user ID."""
    if name_or_id.isdigit():
        return int(name_or_id)
    key = name_or_id.lower().strip()
    if key in USER_IDS:
        return USER_IDS[key]
    sys.exit(f"Error: unknown user '{name_or_id}'. "
             "Run 'redmineCli users' to see available names and IDs.")


def resolve_status(name_or_id):
    """Resolve a status name to a Redmine status ID. Accepts name or numeric ID."""
    if str(name_or_id).isdigit():
        return int(name_or_id)
    key = str(name_or_id).lower().strip()
    if key in STATUS_IDS:
        return STATUS_IDS[key]
    sys.exit(f"Error: unknown status '{name_or_id}'. Known statuses: "
             + ", ".join(sorted(k for k in STATUS_IDS if " " in k or not any(
                 k2 != k and STATUS_IDS[k2] == STATUS_IDS[k] for k2 in STATUS_IDS))))


def resolve_version(name_or_id, project_id, base_url, api_key):
    """Resolve a version name or numeric ID to a Redmine fixed_version_id.

    Name lookup is tried first (case-insensitive, exact match preferred, else
    unique substring). Only falls through to treating the input as a literal
    ID if it is purely digits and no name matched. This matters because
    Redmine version names are often numeric (e.g. "497") and differ from
    their internal IDs.
    """
    key = str(name_or_id).lower().strip()
    data = api_get(base_url, f"/projects/{project_id}/versions.json", api_key)
    versions = data.get("versions", [])
    for v in versions:
        if v["name"].lower() == key:
            return v["id"]
    matches = [v for v in versions if key in v["name"].lower()]
    if len(matches) == 1:
        return matches[0]["id"]
    if len(matches) > 1:
        names = ", ".join(v["name"] for v in matches)
        sys.exit(f"Error: ambiguous version '{name_or_id}': matches {names}")
    if str(name_or_id).isdigit():
        return int(name_or_id)
    all_names = ", ".join(v["name"] for v in versions) or "(none)"
    sys.exit(f"Error: unknown version '{name_or_id}'. "
             f"Available for project {project_id}: {all_names}")


def resolve_tracker(name_or_id):
    """Resolve a tracker name to a Redmine tracker ID. Accepts name or numeric ID."""
    if str(name_or_id).isdigit():
        return int(name_or_id)
    key = str(name_or_id).lower().strip()
    if key in TRACKER_IDS:
        return TRACKER_IDS[key]
    sys.exit(f"Error: unknown tracker '{name_or_id}'. Known trackers: "
             + ", ".join(sorted(set(TRACKER_IDS.keys()))))


def prepend_attribution(text):
    """Prepend 'From Claude:' attribution to text for write operations.

    Idempotent: if the text already begins with a 'From Claude:' attribution
    line (e.g. '**From Claude:**', '***From Claude:***'), return it unchanged
    so the header is not duplicated when Claude models include it in the body.
    """
    if text and re.match(r'^\s*\*+\s*From Claude:?\s*\*+', text, re.IGNORECASE):
        return text
    return ATTRIBUTION + text


def strip_emoji(text):
    """Strip 4-byte Unicode (emoji) that Redmine's MySQL may reject."""
    if not text:
        return text
    return re.sub(r'[\U00010000-\U0010FFFF]', '', text)


def read_text_input(direct, from_file):
    """Read text from --message/--description or --message-file/--description-file."""
    if from_file:
        if from_file == "-":
            return sys.stdin.read()
        with open(from_file) as f:
            return f.read()
    return direct


def make_url(base_url, ticket_id):
    """Build the web URL for a ticket."""
    return f"{base_url.rstrip('/')}/issues/{ticket_id}"


def format_details(details):
    """Format journal detail changes (status changes, assignments, etc.)."""
    lines = []
    for d in details:
        prop = d.get("property", "")
        name = d.get("name", "")
        old = d.get("old_value", "")
        new = d.get("new_value", "")
        if prop == "attr":
            if name == "status_id":
                lines.append(f"  - Status changed: {old} -> {new}")
            elif name == "assigned_to_id":
                lines.append(f"  - Assignee changed: {old} -> {new}")
            elif name == "done_ratio":
                lines.append(f"  - Progress: {old}% -> {new}%")
            else:
                lines.append(f"  - {name}: {old} -> {new}")
        elif prop == "attachment":
            lines.append(f"  - Attached: {new}")
    return "\n".join(lines)


# ---------------------------------------------------------------------------
# Subcommand: show
# ---------------------------------------------------------------------------

def cmd_show(args):
    """Display a single ticket in Markdown."""
    data = api_get(args.base_url,
                   f"/issues/{args.ticket_id}.json?include=journals,attachments",
                   args.api_key)
    issue = data["issue"]

    attachments = {a["id"]: a for a in issue.get("attachments", [])}
    attach_by_name = {a["filename"]: a for a in issue.get("attachments", [])}

    dl_dir = None
    if args.images or args.download_all:
        dl_dir = tempfile.mkdtemp(prefix=f"redmine_{args.ticket_id}_")
        print(f"<!-- Attachments downloaded to: {dl_dir} -->", file=sys.stderr)

    def resolve_images(text):
        if not text:
            return text
        def replace_img(m):
            fname = m.group(1)
            if fname in attach_by_name:
                a = attach_by_name[fname]
                if dl_dir:
                    local = os.path.join(dl_dir, fname)
                    if not os.path.exists(local):
                        download_file(a["content_url"], local, args.api_key)
                        print(f"  Downloaded: {local}", file=sys.stderr)
                    return f"![image]({local})"
                else:
                    return f"![image]({a['content_url']})"
            return m.group(0)
        return re.sub(r'!\[image\]\(([^)]+\.(png|jpg|jpeg|gif))\)',
                       replace_img, text, flags=re.IGNORECASE)

    out = []
    out.append(f"# #{issue['id']}: {issue['subject']}")
    out.append("")
    out.append(f"- **Project:** {issue['project']['name']}")
    out.append(f"- **Tracker:** {issue['tracker']['name']}")
    out.append(f"- **Status:** {issue['status']['name']}")
    out.append(f"- **Priority:** {issue['priority']['name']}")
    out.append(f"- **Author:** {issue['author']['name']}")
    if issue.get("assigned_to"):
        out.append(f"- **Assigned to:** {issue['assigned_to']['name']}")
    out.append(f"- **Created:** {format_date(issue['created_on'])}")
    out.append(f"- **Updated:** {format_date(issue['updated_on'])}")
    if issue.get("closed_on"):
        out.append(f"- **Closed:** {format_date(issue['closed_on'])}")
    out.append(f"- **URL:** {make_url(args.base_url, issue['id'])}")

    # Custom fields
    for cf in issue.get("custom_fields", []):
        if cf.get("value"):
            out.append(f"- **{cf['name']}:** {cf['value']}")
    out.append("")

    if attachments:
        out.append("## Attachments")
        out.append("")
        for a in issue["attachments"]:
            out.append(f"- [{a['filename']}]({a['content_url']}) "
                       f"({a['filesize']} bytes, {a['author']['name']}, "
                       f"{format_date(a['created_on'])})")
        out.append("")

    out.append("## Description")
    out.append("")
    desc = redmine_textile_to_md(issue.get("description", ""))
    desc = resolve_images(desc)
    out.append(desc)
    out.append("")

    journals = issue.get("journals", [])
    if journals:
        out.append("---")
        out.append("## Discussion")
        out.append("")

    for j in journals:
        notes = j.get("notes", "")
        details = j.get("details", [])
        if not notes and not details:
            continue
        user = j["user"]["name"]
        date = format_date(j["created_on"])
        out.append(f"### {user} — {date}")
        out.append("")
        if details:
            detail_text = format_details(details)
            if detail_text:
                out.append(detail_text)
                out.append("")
        if notes:
            md_notes = redmine_textile_to_md(notes)
            md_notes = resolve_images(md_notes)
            out.append(md_notes)
            out.append("")
        out.append("---")
        out.append("")

    if dl_dir:
        for a in issue["attachments"]:
            is_image = a.get("content_type", "").startswith("image/")
            if args.download_all or is_image:
                local = os.path.join(dl_dir, a["filename"])
                if not os.path.exists(local):
                    download_file(a["content_url"], local, args.api_key)
                    print(f"  Downloaded: {local}", file=sys.stderr)

    print("\n".join(out))


def download_file(url, dest_path, api_key):
    """Download a file with API key auth."""
    req = urllib.request.Request(url)
    req.add_header("X-Redmine-API-Key", api_key)
    with urllib.request.urlopen(req, timeout=30) as resp:
        with open(dest_path, "wb") as f:
            f.write(resp.read())


# ---------------------------------------------------------------------------
# Subcommand: list
# ---------------------------------------------------------------------------

def cmd_list(args):
    """List/search tickets with filters."""
    params = {
        "project_id": args.project,
        "limit": str(args.limit),
        "offset": str(args.offset),
        "sort": args.sort,
    }

    if args.status:
        params["status_id"] = args.status

    if args.assigned_to:
        if args.assigned_to.lower() == "me":
            params["assigned_to_id"] = "me"
        else:
            params["assigned_to_id"] = str(resolve_user(args.assigned_to))

    if args.tracker:
        params["tracker_id"] = resolve_tracker(args.tracker)

    if args.search:
        params["subject"] = f"~{args.search}"

    query = urllib.parse.urlencode(params)
    data = api_get(args.base_url, f"/issues.json?{query}", args.api_key)

    issues = data.get("issues", [])
    total = data.get("total_count", 0)

    if not issues:
        print("No issues found.")
        return

    out = []
    out.append("| # | Status | Assignee | Subject |")
    out.append("|---|--------|----------|---------|")
    for iss in issues:
        tid = iss["id"]
        status = iss["status"]["name"]
        assignee = iss.get("assigned_to", {}).get("name", "—")
        subject = iss["subject"][:60].replace("|", "\\|")
        out.append(f"| {tid} | {status} | {assignee} | {subject} |")
    out.append("")
    start = args.offset + 1
    end = args.offset + len(issues)
    out.append(f"{total} issues total (showing {start}-{end})")

    print("\n".join(out))


# ---------------------------------------------------------------------------
# Subcommand: create
# ---------------------------------------------------------------------------

def cmd_create(args):
    """Create a new Redmine ticket."""
    description = read_text_input(args.description, args.description_file)
    if not description:
        sys.exit("Error: --description or --description-file is required")

    description = strip_emoji(prepend_attribution(description))
    subject = strip_emoji(args.subject)

    issue_data = {
        "issue": {
            "project_id": args.project,
            "subject": subject,
            "description": description,
            "tracker_id": resolve_tracker(args.tracker),
            "priority_id": args.priority,
            "status_id": resolve_status(args.status),
        }
    }

    custom_fields = []
    if args.category:
        custom_fields.append({"id": CF_CATEGORY, "value": args.category})
    if args.email:
        custom_fields.append({"id": CF_EMAIL, "value": args.email})
    if args.mlm:
        custom_fields.append({"id": CF_MLM, "value": args.mlm})
    if getattr(args, "assemblies", None) is not None:
        custom_fields.append({"id": CF_ASSEMBLIES, "value": args.assemblies})
    for cf_spec in (getattr(args, "custom_field", None) or []):
        if "=" not in cf_spec:
            sys.exit(f"Error: --custom-field must be ID=VALUE, got: {cf_spec}")
        cf_id, cf_val = cf_spec.split("=", 1)
        if not cf_id.isdigit():
            sys.exit(f"Error: custom field ID must be numeric, got: {cf_id}")
        cf_id = int(cf_id)
        if cf_id in FIELD_VALIDATORS:
            validator, name = FIELD_VALIDATORS[cf_id]
            cf_val = validator(cf_val, f"--custom-field {cf_id} ({name})")
        custom_fields.append({"id": cf_id, "value": cf_val})
    if custom_fields:
        issue_data["issue"]["custom_fields"] = custom_fields

    if args.assigned_to:
        issue_data["issue"]["assigned_to_id"] = resolve_user(args.assigned_to)

    result = api_post(args.base_url, "/issues.json", args.api_key, issue_data)
    ticket_id = result["issue"]["id"]
    print(f"Created #{ticket_id}: {make_url(args.base_url, ticket_id)}")


# ---------------------------------------------------------------------------
# Subcommand: build-patch
# ---------------------------------------------------------------------------

def cmd_build_patch(args):
    """Create a Build Patch ticket with every field the process requires.

    Encodes the team's Build Patch process so the required fields cannot be
    forgotten: GB project, Build Patch tracker, Urgent priority, New status,
    no assignee (QA claims it), and the five required custom fields (Commit ID,
    Files Changed, CGIs to retest, Test Case, Suggested Code Reviewer). Relates
    the ticket to the bug it fixes and, unless --no-default-watchers is given,
    adds the QA Team and build meister as watchers.
    """
    description = read_text_input(args.description, args.description_file)
    if not description:
        sys.exit("Error: --description or --description-file is required")
    description = strip_emoji(prepend_attribution(description))

    custom_fields = [
        {"id": CF_DEVELOPER, "value": str(resolve_user(args.developer))},
        {"id": CF_COMMIT_ID, "value": args.commit_id},
        {"id": CF_FILES_CHANGED, "value": args.files},
        {"id": CF_CGIS_RETEST, "value": args.cgis},
        {"id": CF_TEST_CASE, "value": args.test_case},
        {"id": CF_CODE_REVIEWER, "value": str(resolve_user(args.reviewer))},
    ]
    if args.post_mortem:
        custom_fields.append({"id": CF_POST_MORTEM, "value": args.post_mortem})

    issue_data = {
        "issue": {
            "project_id": args.project,
            "subject": strip_emoji(args.subject),
            "description": description,
            "tracker_id": TRACKER_BUILD_PATCH,
            "priority_id": PRIORITY_URGENT,
            "status_id": STATUS_NEW,
            "fixed_version_id": resolve_version(
                args.target_version, args.project, args.base_url, args.api_key),
            "custom_fields": custom_fields,
        }
    }

    result = api_post(args.base_url, "/issues.json", args.api_key, issue_data)
    ticket_id = result["issue"]["id"]
    print(f"Created Build Patch #{ticket_id}: {make_url(args.base_url, ticket_id)}")

    # Relate to the bug(s) this patch fixes.
    for bug_id in (args.relates or []):
        data = {"relation": {"issue_to_id": int(bug_id), "relation_type": "relates"}}
        try:
            api_post(args.base_url, f"/issues/{ticket_id}/relations.json",
                     args.api_key, data)
            print(f"  Related #{ticket_id} <-> #{bug_id}")
        except SystemExit as e:
            if "422" in str(e):
                print(f"  Already related: #{ticket_id} <-> #{bug_id}")
            else:
                raise

    # Watchers: QA Team + build meister by default, plus any extras. The
    # process requires QA Team on every Build Patch from the start.
    watchers = [] if args.no_default_watchers else ["qa", "build"]
    watchers += (args.watch or [])
    seen = set()
    for name in watchers:
        uid = resolve_user(name)
        if uid in seen:
            continue
        seen.add(uid)
        try:
            api_post(args.base_url, f"/issues/{ticket_id}/watchers.json",
                     args.api_key, {"user_id": uid})
            print(f"  Added watcher {name} (user {uid}) to #{ticket_id}")
        except SystemExit as e:
            if "422" in str(e):
                print(f"  {name} is already watching #{ticket_id}")
            else:
                raise


# ---------------------------------------------------------------------------
# Subcommand: comment
# ---------------------------------------------------------------------------

def cmd_comment(args):
    """Add a comment to an existing ticket."""
    message = read_text_input(args.message, args.message_file)
    if not message:
        sys.exit("Error: --message or --message-file is required")

    message = strip_emoji(prepend_attribution(message))
    issue = {"notes": message}
    if args.private:
        issue["private_notes"] = True
    data = {"issue": issue}

    api_put(args.base_url, f"/issues/{args.ticket_id}.json", args.api_key, data)
    label = "private comment" if args.private else "Commented"
    print(f"{label} on #{args.ticket_id}: {make_url(args.base_url, args.ticket_id)}")


# ---------------------------------------------------------------------------
# Subcommand: update
# ---------------------------------------------------------------------------

def cmd_update(args):
    """Update fields on an existing ticket."""
    issue_data = {}

    if args.status is not None:
        issue_data["status_id"] = resolve_status(args.status)
    if args.assigned_to is not None:
        if args.assigned_to == "":
            issue_data["assigned_to_id"] = ""
        else:
            issue_data["assigned_to_id"] = resolve_user(args.assigned_to)
    if args.priority is not None:
        issue_data["priority_id"] = args.priority
    if args.subject is not None:
        issue_data["subject"] = strip_emoji(args.subject)
    if args.target_version is not None:
        if args.target_version == "":
            issue_data["fixed_version_id"] = ""
        else:
            issue = api_get(args.base_url,
                            f"/issues/{args.ticket_id}.json", args.api_key)
            project_id = issue["issue"]["project"]["id"]
            issue_data["fixed_version_id"] = resolve_version(
                args.target_version, project_id, args.base_url, args.api_key)

    custom_fields = []
    if args.category is not None:
        custom_fields.append({"id": CF_CATEGORY, "value": args.category})
    if args.mlm is not None:
        custom_fields.append({"id": CF_MLM, "value": args.mlm})
    if args.release_log_text is not None:
        custom_fields.append({"id": CF_RELEASE_LOG_TEXT, "value": args.release_log_text})
    if args.release_log_url is not None:
        custom_fields.append({"id": CF_RELEASE_LOG_URL, "value": args.release_log_url})
    if args.released_to_rr is not None:
        val = _validate_bool(args.released_to_rr, "--released-to-rr")
        custom_fields.append({"id": CF_RELEASED_TO_RR, "value": val})
    if args.file_list is not None:
        custom_fields.append({"id": CF_FILE_LIST, "value": args.file_list})
    if args.file_list_add:
        existing = api_get(args.base_url,
                           f"/issues/{args.ticket_id}.json",
                           args.api_key)["issue"]
        current = ""
        for cf in existing.get("custom_fields", []):
            if cf["id"] == CF_FILE_LIST:
                current = cf.get("value") or ""
                break
        lines = [l for l in current.splitlines() if l.strip()]
        for path in args.file_list_add:
            if path not in lines:
                lines.append(path)
        custom_fields.append({"id": CF_FILE_LIST,
                              "value": "\r\n".join(lines)})
    if args.table_list is not None:
        custom_fields.append({"id": CF_TABLE_LIST, "value": args.table_list})
    if args.assemblies is not None:
        custom_fields.append({"id": CF_ASSEMBLIES, "value": args.assemblies})
    for cf_spec in (args.custom_field or []):
        if "=" not in cf_spec:
            sys.exit(f"Error: --custom-field must be ID=VALUE, got: {cf_spec}")
        cf_id, cf_val = cf_spec.split("=", 1)
        if not cf_id.isdigit():
            sys.exit(f"Error: custom field ID must be numeric, got: {cf_id}")
        cf_id = int(cf_id)
        if cf_id in FIELD_VALIDATORS:
            validator, name = FIELD_VALIDATORS[cf_id]
            cf_val = validator(cf_val, f"--custom-field {cf_id} ({name})")
        custom_fields.append({"id": cf_id, "value": cf_val})
    if custom_fields:
        issue_data["custom_fields"] = custom_fields

    note = read_text_input(args.note, args.note_file)
    if note:
        issue_data["notes"] = strip_emoji(prepend_attribution(note))
        if args.private:
            issue_data["private_notes"] = True
    elif args.private:
        sys.exit("Error: --private requires --note or --note-file")

    if not issue_data:
        sys.exit("Error: no fields to update. Provide at least one of: "
                 "--status, --assigned-to, --priority, --subject, "
                 "--target-version, --category, --mlm, "
                 "--release-log-text, --release-log-url, "
                 "--released-to-rr, --file-list, --file-list-add, "
                 "--table-list, --assemblies, --custom-field, --note")

    data = {"issue": issue_data}
    api_put(args.base_url, f"/issues/{args.ticket_id}.json", args.api_key, data)
    print(f"Updated #{args.ticket_id}: {make_url(args.base_url, args.ticket_id)}")


# ---------------------------------------------------------------------------
# Subcommand: attach
# ---------------------------------------------------------------------------

def cmd_attach(args):
    """Upload an attachment to a ticket."""
    filepath = args.file
    if not os.path.isfile(filepath):
        sys.exit(f"Error: file not found: {filepath}")

    filename = args.filename or os.path.basename(filepath)
    with open(filepath, "rb") as f:
        file_data = f.read()

    token = api_upload(args.base_url, args.api_key, filename, file_data)

    content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
    issue_data = {
        "uploads": [{"token": token, "filename": filename,
                      "content_type": content_type}]
    }

    if args.description:
        issue_data["uploads"][0]["description"] = args.description

    if args.note:
        issue_data["notes"] = strip_emoji(prepend_attribution(args.note))
        if args.private:
            issue_data["private_notes"] = True
    elif args.private:
        sys.exit("Error: --private requires --note")

    data = {"issue": issue_data}
    api_put(args.base_url, f"/issues/{args.ticket_id}.json", args.api_key, data)
    print(f"Attached {filename} to #{args.ticket_id}: "
          f"{make_url(args.base_url, args.ticket_id)}")


# ---------------------------------------------------------------------------
# Subcommand: users
# ---------------------------------------------------------------------------

def cmd_users(args):
    """List project members and their Redmine user IDs."""
    users = []
    offset = 0
    limit = 100
    while True:
        data = api_get(args.base_url,
                       f"/projects/{args.project}/memberships.json?limit={limit}&offset={offset}",
                       args.api_key)
        for m in data.get("memberships", []):
            if "user" not in m:
                continue
            users.append((m["user"]["name"], m["user"]["id"]))
        total = data.get("total_count", 0)
        offset += limit
        if offset >= total:
            break

    users.sort(key=lambda x: x[0].lower())

    # Count first-name usage to detect collisions
    first_counts = {}
    for name, uid in users:
        first = name.split()[0].lower()
        first_counts[first] = first_counts.get(first, 0) + 1

    out = []
    out.append("| Short | Name | ID |")
    out.append("|-------|------|----|")
    for name, uid in users:
        parts = name.split()
        first = parts[0].lower()
        if first_counts[first] == 1:
            short = first
        elif len(parts) >= 2 and parts[0].isalpha() and parts[-1].isalpha():
            short = (parts[0][0] + parts[-1]).lower()
        else:
            short = first
        out.append(f"| {short} | {name} | {uid} |")
    print("\n".join(out))


# ---------------------------------------------------------------------------
# Subcommand: relate
# ---------------------------------------------------------------------------

def cmd_relate(args):
    """Create 'relates' relations between tickets."""
    ticket_ids = args.ticket_ids
    if len(ticket_ids) < 2:
        sys.exit("Error: need at least two ticket IDs to relate")

    relation_type = args.type
    created = 0
    skipped = 0

    # Relate each pair: for N tickets, relate ticket[0] to all others,
    # then ticket[1] to all after it, etc. Redmine relations are bidirectional
    # so we only need to create them in one direction.
    for i in range(len(ticket_ids)):
        for j in range(i + 1, len(ticket_ids)):
            data = {
                "relation": {
                    "issue_to_id": int(ticket_ids[j]),
                    "relation_type": relation_type,
                }
            }
            try:
                api_post(args.base_url,
                         f"/issues/{ticket_ids[i]}/relations.json",
                         args.api_key, data)
                created += 1
                print(f"  Related #{ticket_ids[i]} <-> #{ticket_ids[j]}")
            except SystemExit as e:
                # Duplicate relation returns 422; treat as skip
                if "422" in str(e):
                    skipped += 1
                    print(f"  Already related: #{ticket_ids[i]} <-> #{ticket_ids[j]}")
                else:
                    raise

    print(f"Done: {created} created, {skipped} already existed")


# ---------------------------------------------------------------------------
# Subcommand: watch
# ---------------------------------------------------------------------------

def cmd_watch(args):
    """Add watchers to a ticket."""
    ticket_id = args.ticket_id
    for name in args.users:
        user_id = resolve_user(name)
        data = {"user_id": user_id}
        try:
            api_post(args.base_url,
                     f"/issues/{ticket_id}/watchers.json",
                     args.api_key, data)
            print(f"  Added watcher {name} (user {user_id}) to #{ticket_id}")
        except SystemExit as e:
            if "422" in str(e):
                print(f"  {name} is already watching #{ticket_id}")
            else:
                raise

    print(f"Done: {make_url(args.base_url, ticket_id)}")


# ---------------------------------------------------------------------------
# Subcommand: note
# ---------------------------------------------------------------------------

def cmd_note(args):
    """Display a specific note from a ticket."""
    data = api_get(args.base_url,
                   f"/issues/{args.ticket_id}.json?include=journals",
                   args.api_key)
    issue = data["issue"]
    journals = issue.get("journals", [])

    # Redmine API may return journals newest-first; sort by created_on
    # to match the web UI's #note-N numbering (note-1 = oldest).
    journals.sort(key=lambda j: j["created_on"])

    note_num = args.note_number
    if note_num < 1 or note_num > len(journals):
        sys.exit(f"Error: note-{note_num} does not exist. "
                 f"Ticket #{args.ticket_id} has {len(journals)} journal entries.")

    j = journals[note_num - 1]
    user = j["user"]["name"]
    date = format_date(j["created_on"])
    notes = j.get("notes", "")
    details = j.get("details", [])

    out = []
    out.append(f"# #{issue['id']} note-{note_num}: {user} — {date}")
    out.append(f"URL: {make_url(args.base_url, issue['id'])}#note-{note_num}")
    out.append("")
    if details:
        detail_text = format_details(details)
        if detail_text:
            out.append(detail_text)
            out.append("")
    if notes:
        out.append(redmine_textile_to_md(notes))
    if not notes and not details:
        out.append("(empty journal entry)")

    print("\n".join(out))


# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------

def build_parser():
    parser = argparse.ArgumentParser(
        prog="redmineCli",
        description="Redmine CLI for the UCSC Genome Browser team")
    parser.add_argument("--redmine", default=DEFAULT_REDMINE,
                        help="Redmine base URL (default: %(default)s)")
    parser.add_argument("--conf", default="~/.hg.conf",
                        help="Config file with redmine.apiKey (default: %(default)s)")

    sub = parser.add_subparsers(dest="command", required=True)

    # show
    p_show = sub.add_parser("show", help="Display a ticket in Markdown, optionally download attachments")
    p_show.add_argument("ticket_id", help="Ticket ID number")
    p_show.add_argument("--images", action="store_true",
                        help="Download images to a temp directory")
    p_show.add_argument("--download-all", dest="download_all", action="store_true",
                        help="Download all attachments to a temp directory")

    # list
    p_list = sub.add_parser("list", help="List/search tickets")
    p_list.add_argument("--project", default=DEFAULT_PROJECT,
                        help="Project identifier (default: %(default)s)")
    p_list.add_argument("--status", default="open",
                        help="Status filter: open, closed, * (default: %(default)s)")
    p_list.add_argument("--assigned-to", dest="assigned_to",
                        help="Assignee name or 'me'")
    p_list.add_argument("--tracker",
                        help="Tracker name or ID")
    p_list.add_argument("--search",
                        help="Search in subject")
    p_list.add_argument("--limit", type=int, default=25,
                        help="Max results (default: %(default)s)")
    p_list.add_argument("--offset", type=int, default=0,
                        help="Pagination offset (default: %(default)s)")
    p_list.add_argument("--sort", default="updated_on:desc",
                        help="Sort field:direction (default: %(default)s)")

    # create
    p_create = sub.add_parser("create", help="Create a new ticket")
    p_create.add_argument("--subject", required=True, help="Ticket subject")
    p_create.add_argument("--description", help="Ticket description")
    p_create.add_argument("--description-file", dest="description_file",
                          help="Read description from file (- for stdin)")
    p_create.add_argument("--project", default=DEFAULT_PROJECT,
                          help="Project (default: %(default)s)")
    p_create.add_argument("--tracker", default=TRACKER_MLQ,
                          help="Tracker name or ID (e.g. 'To Do' or 10; default: %(default)s)")
    p_create.add_argument("--priority", type=int, default=PRIORITY_UNPRIORITIZED,
                          help="Priority ID (default: %(default)s)")
    p_create.add_argument("--status", default=STATUS_NEW,
                          help="Status name or ID (e.g. 'New' or 1; default: %(default)s)")
    p_create.add_argument("--assigned-to", dest="assigned_to",
                          help="Assignee name or user ID")
    p_create.add_argument("--category", help="MLQ Category (custom field)")
    p_create.add_argument("--email", help="Sender email (custom field)")
    p_create.add_argument("--mlm", help="MLM name (custom field)")
    p_create.add_argument("--assemblies",
                          help="Assemblies (custom field, e.g. 'hg38')")
    p_create.add_argument("--custom-field", dest="custom_field",
                          action="append", metavar="ID=VALUE",
                          help="Set an arbitrary custom field by numeric ID "
                               "(repeatable). Use for required fields on trackers "
                               "like Track that would otherwise 422.")

    # build-patch
    p_bp = sub.add_parser("build-patch",
                          help="Create a Build Patch ticket (GB, Urgent, all required fields set)")
    p_bp.add_argument("--subject", required=True, help="Ticket subject")
    p_bp.add_argument("--description", help="What is broken and why it needs a patch")
    p_bp.add_argument("--description-file", dest="description_file",
                      help="Read description from file (- for stdin)")
    p_bp.add_argument("--developer", required=True,
                      help="Developer who caused the bug (name or user ID)")
    p_bp.add_argument("--commit-id", dest="commit_id", required=True,
                      help="Fix commit hash; list all hashes if several commits fix it")
    p_bp.add_argument("--files", required=True,
                      help="Full paths of the source files changed")
    p_bp.add_argument("--cgis", required=True,
                      help="CGIs that could be affected and need retesting")
    p_bp.add_argument("--test-case", dest="test_case", required=True,
                      help="Step-by-step to reproduce both the failure and the fix")
    p_bp.add_argument("--reviewer", required=True,
                      help="Suggested code reviewer (name or user ID)")
    p_bp.add_argument("--target-version", dest="target_version", required=True,
                      help="Release number being built (e.g. 500)")
    p_bp.add_argument("--post-mortem", dest="post_mortem",
                      help="Optional plain-language what-happened / how-to-prevent note")
    p_bp.add_argument("--relates", nargs="+", metavar="BUG_ID",
                      help="Bug ticket(s) this patch fixes; related on creation")
    p_bp.add_argument("--watch", nargs="+", metavar="NAME",
                      help="Extra watchers beyond the QA Team + build meister defaults")
    p_bp.add_argument("--no-default-watchers", dest="no_default_watchers",
                      action="store_true",
                      help="Do not auto-add the QA Team and build meister as watchers")
    p_bp.add_argument("--project", default=PROJECT_GB,
                      help="Project (default: %(default)s)")

    # comment
    p_comment = sub.add_parser("comment", help="Add a comment to a ticket")
    p_comment.add_argument("ticket_id", help="Ticket ID number")
    p_comment.add_argument("--message", help="Comment text")
    p_comment.add_argument("--message-file", dest="message_file",
                           help="Read comment from file (- for stdin)")
    p_comment.add_argument("--private", action="store_true",
                           help="Mark this comment as a private note "
                                "(only visible to project members with permission)")

    # update
    p_update = sub.add_parser("update", help="Update ticket fields")
    p_update.add_argument("ticket_id", help="Ticket ID number")
    p_update.add_argument("--status", help="New status name or ID (e.g. 'QA Ready' or 10)")
    p_update.add_argument("--assigned-to", dest="assigned_to",
                          help="Assignee name/ID (empty string to clear)")
    p_update.add_argument("--priority", type=int, help="New priority ID")
    p_update.add_argument("--subject", help="New subject")
    p_update.add_argument("--target-version", dest="target_version",
                          help="Target version name or ID (empty string to clear)")
    p_update.add_argument("--category", help="MLQ Category")
    p_update.add_argument("--mlm", help="MLM name")
    p_update.add_argument("--release-log-text", dest="release_log_text",
                          help="Release Log Text (custom field)")
    p_update.add_argument("--release-log-url", dest="release_log_url",
                          help="Release Log URL (custom field)")
    p_update.add_argument("--released-to-rr", dest="released_to_rr",
                          help="Released to RR (checkbox: 0/1/true/false/yes/no/on/off)")
    p_update.add_argument("--file-list", dest="file_list",
                          help="File List (custom field, overwrites existing value)")
    p_update.add_argument("--file-list-add", dest="file_list_add",
                          action="append", metavar="PATH",
                          help="Append PATH to the File List custom field "
                               "(repeatable; idempotent, skips paths already present)")
    p_update.add_argument("--table-list", dest="table_list",
                          help="Table List (custom field)")
    p_update.add_argument("--assemblies",
                          help="Assemblies (custom field)")
    p_update.add_argument("--custom-field", dest="custom_field",
                          action="append", metavar="ID=VALUE",
                          help="Set arbitrary custom field by ID (repeatable)")
    p_update.add_argument("--note", help="Comment to include with update")
    p_update.add_argument("--note-file", dest="note_file",
                          help="Read note from file (- for stdin)")
    p_update.add_argument("--private", action="store_true",
                          help="Mark the accompanying note as a private note "
                               "(requires --note or --note-file)")

    # attach
    p_attach = sub.add_parser("attach", help="Upload an attachment")
    p_attach.add_argument("ticket_id", help="Ticket ID number")
    p_attach.add_argument("file", help="File path to upload")
    p_attach.add_argument("--filename", help="Override filename")
    p_attach.add_argument("--description", help="Attachment description")
    p_attach.add_argument("--note", help="Comment to add with attachment")
    p_attach.add_argument("--private", action="store_true",
                          help="Mark the accompanying note as a private note "
                               "(requires --note)")

    # users
    p_users = sub.add_parser("users", help="List project members and their user IDs")
    p_users.add_argument("--project", default=DEFAULT_PROJECT,
                         help="Project identifier (default: %(default)s)")

    # relate
    p_relate = sub.add_parser("relate", help="Create relations between tickets")
    p_relate.add_argument("ticket_ids", nargs="+", help="Two or more ticket IDs to relate")
    p_relate.add_argument("--type", default="relates",
                          help="Relation type: relates, duplicates, duplicated, blocks, "
                               "blocked, precedes, follows, copied_to, copied_from "
                               "(default: %(default)s)")

    # watch
    p_watch = sub.add_parser("watch", help="Add watchers to a ticket")
    p_watch.add_argument("ticket_id", help="Ticket ID number")
    p_watch.add_argument("users", nargs="+", help="User names or IDs to add as watchers")

    # note
    p_note = sub.add_parser("note", help="Display a specific note from a ticket")
    p_note.add_argument("ticket_id", help="Ticket ID number")
    p_note.add_argument("note_number", type=int, help="Note number (as shown in Redmine URL #note-N)")

    return parser


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

def main():
    parser = build_parser()
    args = parser.parse_args()

    args.api_key = read_api_key(args.conf)
    args.base_url = args.redmine

    commands = {
        "show": cmd_show,
        "list": cmd_list,
        "create": cmd_create,
        "build-patch": cmd_build_patch,
        "comment": cmd_comment,
        "update": cmd_update,
        "attach": cmd_attach,
        "users": cmd_users,
        "relate": cmd_relate,
        "watch": cmd_watch,
        "note": cmd_note,
    }
    commands[args.command](args)


if __name__ == "__main__":
    main()
