#!/usr/bin/env python3
"""
Jira Cloud API Client - REST API v3, Basic auth (email + API token).

Descriptions: --description and --description-file are always converted to ADF (## = h2, ** = bold, - = list)
so the card keeps formatting when edited in Jira. Use --description-adf only for raw ADF JSON.

Usage:
    python jira_client.py list-projects
    python jira_client.py search "JQL" [--max-results 50] [--format json]
    python jira_client.py get-issue ISSUE_KEY [--format json]
    python jira_client.py create-issue --project KEY --summary "..." [--description "..."] [--description-adf path/to/adf.json] [--type Story]
    python jira_client.py create-issue --project KEY --summary "Subtarea" --parent PARENT-KEY --type "Sub-task"
    python jira_client.py update-issue ISSUE_KEY --description-file path.txt
    python jira_client.py delete-issue ISSUE_KEY
"""

import argparse
import base64
import json
import os
import sys
try:
    import requests
except ImportError:
    print("Error: The 'requests' library is not installed.", file=sys.stderr)
    print("Install with: pip install requests", file=sys.stderr)
    sys.exit(1)


SKILL_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LEGACY_CONFIG_PATH = os.path.join(SKILL_DIR, "config.json")
NO_PROXY = {"http": None, "https": None}


def _user_config_path():
    """Return the user-writable config path (XDG-compliant)."""
    base = os.environ.get("XDG_CONFIG_HOME") or os.path.join(os.path.expanduser("~"), ".config")
    return os.path.join(base, "humand-pm-jira", "config.json")


def _load_config_from_env():
    """Build a config dict from environment variables; return None if any required var is missing."""
    url = (
        os.environ.get("JIRA_BASE_URL")
        or os.environ.get("JIRA_URL")
        or os.environ.get("ATLASSIAN_BASE_URL")
        or ""
    ).rstrip("/")
    email = os.environ.get("JIRA_EMAIL") or os.environ.get("ATLASSIAN_EMAIL") or ""
    token = os.environ.get("JIRA_API_TOKEN") or os.environ.get("ATLASSIAN_API_TOKEN") or ""
    if url and email and token:
        return {"jira_url": url, "jira_email": email, "jira_api_token": token}
    return None


def _load_config_from_file(path):
    """Load a config dict from a JSON file; exit on invalid contents."""
    with open(path, "r", encoding="utf-8") as f:
        config = json.load(f)
    url = (config.get("jira_url") or "").rstrip("/")
    email = config.get("jira_email") or ""
    token = config.get("jira_api_token") or ""
    if not url or not email or not token:
        print(f"Error: {path} must contain jira_url, jira_email, and jira_api_token.", file=sys.stderr)
        sys.exit(1)
    return {"jira_url": url, "jira_email": email, "jira_api_token": token}


def load_config():
    """Resolve Jira credentials.

    Order:
    1. Environment variables (JIRA_BASE_URL / JIRA_EMAIL / JIRA_API_TOKEN, or ATLASSIAN_* aliases).
    2. ~/.config/humand-pm-jira/config.json (XDG-compliant user path).
    3. config.json next to the skill directory (legacy / local-only setup).
    """
    env_cfg = _load_config_from_env()
    if env_cfg:
        return env_cfg

    user_path = _user_config_path()
    for path in (user_path, LEGACY_CONFIG_PATH):
        if os.path.exists(path):
            return _load_config_from_file(path)

    print("Error: Jira credentials not configured.", file=sys.stderr)
    print("Options:", file=sys.stderr)
    print("  1) Export JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN.", file=sys.stderr)
    print(f"  2) Copy config.template.json to {user_path} and fill it in.", file=sys.stderr)
    sys.exit(1)


def get_auth_headers(config):
    """Build Basic auth header from email and API token."""
    credentials = f"{config['jira_email']}:{config['jira_api_token']}"
    encoded = base64.b64encode(credentials.encode()).decode()
    return {
        "Authorization": f"Basic {encoded}",
        "Accept": "application/json",
        "Content-Type": "application/json",
    }


def _read_adf(path):
    """Read an ADF JSON document from a file path, or stdin when path == '-'."""
    if path == "-":
        try:
            return json.load(sys.stdin)
        except json.JSONDecodeError as e:
            print(f"Error: stdin is not valid JSON: {e}", file=sys.stderr)
            sys.exit(1)
    with open(path, "r", encoding="utf-8") as f:
        return json.load(f)


def jira_get(config, path, params=None):
    """GET request to Jira REST API v3."""
    url = f"{config['jira_url'].rstrip('/')}/rest/api/3/{path}"
    headers = get_auth_headers(config)
    try:
        resp = requests.get(url, headers=headers, params=params, timeout=30, proxies=NO_PROXY)
        resp.raise_for_status()
        return resp.json() if resp.text else {}
    except requests.exceptions.HTTPError as e:
        if resp.status_code == 401:
            print("Error: Invalid credentials (email or API token).", file=sys.stderr)
        elif resp.status_code == 404:
            print("Error: Resource not found.", file=sys.stderr)
        else:
            print(f"Error HTTP {resp.status_code}: {e}", file=sys.stderr)
        if resp.text:
            try:
                err = resp.json()
                print(err.get("errorMessages", resp.text)[:200], file=sys.stderr)
            except Exception:
                print(resp.text[:200], file=sys.stderr)
        sys.exit(1)
    except requests.exceptions.RequestException as e:
        print(f"Error calling Jira: {e}", file=sys.stderr)
        sys.exit(1)


def jira_delete(config, path):
    """DELETE request to Jira REST API v3."""
    url = f"{config['jira_url'].rstrip('/')}/rest/api/3/{path}"
    headers = get_auth_headers(config)
    try:
        resp = requests.delete(url, headers=headers, timeout=30, proxies=NO_PROXY)
        resp.raise_for_status()
        return True
    except requests.exceptions.HTTPError as e:
        resp = e.response
        if resp.status_code == 404:
            print("Error: Issue not found.", file=sys.stderr)
        else:
            print(f"Error HTTP {resp.status_code}: {e}", file=sys.stderr)
        if resp.text:
            try:
                err = resp.json()
                print(err.get("errorMessages", resp.text)[:200], file=sys.stderr)
            except Exception:
                print(resp.text[:200], file=sys.stderr)
        sys.exit(1)
    except requests.exceptions.RequestException as e:
        print(f"Error calling Jira: {e}", file=sys.stderr)
        sys.exit(1)


def jira_put(config, path, data):
    """PUT request to Jira REST API v3."""
    url = f"{config['jira_url'].rstrip('/')}/rest/api/3/{path}"
    headers = get_auth_headers(config)
    try:
        resp = requests.put(url, headers=headers, json=data, timeout=30, proxies=NO_PROXY)
        resp.raise_for_status()
        return resp.json() if resp.text else {}
    except requests.exceptions.HTTPError as e:
        resp = e.response
        print(f"Error HTTP {resp.status_code}: {e}", file=sys.stderr)
        if resp.text:
            try:
                err = resp.json()
                msgs = err.get("errorMessages", []) + [err.get("errors", {})]
                print(msgs, file=sys.stderr)
            except Exception:
                print(resp.text[:300], file=sys.stderr)
        sys.exit(1)
    except requests.exceptions.RequestException as e:
        print(f"Error calling Jira: {e}", file=sys.stderr)
        sys.exit(1)


def jira_post(config, path, data):
    """POST request to Jira REST API v3."""
    url = f"{config['jira_url'].rstrip('/')}/rest/api/3/{path}"
    headers = get_auth_headers(config)
    try:
        resp = requests.post(url, headers=headers, json=data, timeout=30, proxies=NO_PROXY)
        resp.raise_for_status()
        return resp.json() if resp.text else {}
    except requests.exceptions.HTTPError as e:
        print(f"Error HTTP {resp.status_code}: {e}", file=sys.stderr)
        if resp.text:
            try:
                err = resp.json()
                msgs = err.get("errorMessages", []) + [err.get("errors", {})]
                print(msgs, file=sys.stderr)
            except Exception:
                print(resp.text[:300], file=sys.stderr)
        sys.exit(1)
    except requests.exceptions.RequestException as e:
        print(f"Error calling Jira: {e}", file=sys.stderr)
        sys.exit(1)


def jira_raw_get(config, path, params=None):
    """GET to an arbitrary Jira REST path (e.g. 'agile/1.0/board'), under /rest/."""
    url = f"{config['jira_url'].rstrip('/')}/rest/{path.lstrip('/')}"
    headers = get_auth_headers(config)
    try:
        resp = requests.get(url, headers=headers, params=params, timeout=30, proxies=NO_PROXY)
        resp.raise_for_status()
        return resp.json() if resp.text else {}
    except requests.exceptions.HTTPError as e:
        print(f"Error HTTP {resp.status_code}: {e}", file=sys.stderr)
        if resp.text:
            print(resp.text[:300], file=sys.stderr)
        sys.exit(1)
    except requests.exceptions.RequestException as e:
        print(f"Error calling Jira: {e}", file=sys.stderr)
        sys.exit(1)


def jira_raw_post(config, path, data):
    """POST to an arbitrary Jira REST path (e.g. 'agile/1.0/sprint/123/issue'), under /rest/."""
    url = f"{config['jira_url'].rstrip('/')}/rest/{path.lstrip('/')}"
    headers = get_auth_headers(config)
    try:
        resp = requests.post(url, headers=headers, json=data, timeout=30, proxies=NO_PROXY)
        resp.raise_for_status()
        return resp.json() if resp.text else {}
    except requests.exceptions.HTTPError as e:
        print(f"Error HTTP {resp.status_code}: {e}", file=sys.stderr)
        if resp.text:
            print(resp.text[:300], file=sys.stderr)
        sys.exit(1)
    except requests.exceptions.RequestException as e:
        print(f"Error calling Jira: {e}", file=sys.stderr)
        sys.exit(1)


def cmd_list_projects(config):
    """List all projects."""
    data = jira_get(config, "project")
    projects = data if isinstance(data, list) else data.get("values", data)
    if not projects:
        print("No projects found.")
        return
    print(f"{'Key':<12} {'Name'}")
    print("-" * 50)
    for p in projects:
        key = p.get("key", "?")
        name = p.get("name", "?")
        print(f"{key:<12} {name}")


def cmd_search(config, jql, max_results=50, output_format="text"):
    """Search issues by JQL."""
    body = {"jql": jql, "maxResults": max_results, "fields": ["key", "summary", "status"]}
    data = jira_post(config, "search/jql", body)
    issues = data.get("issues", [])
    if output_format == "json":
        print(json.dumps({"issues": issues, "total": data.get("total", len(issues))}, indent=2, ensure_ascii=False, default=str))
        return
    if not issues:
        print("No issues found.")
        return
    print(f"{'Key':<14} {'Summary':<50} Status")
    print("-" * 80)
    for i in issues:
        key = i.get("key", "?")
        fields = i.get("fields", {})
        summary = (fields.get("summary") or "?")[:48]
        status = (fields.get("status", {}).get("name") or "?")
        print(f"{key:<14} {summary:<50} {status}")


def cmd_get_issue(config, issue_key, output_format="text"):
    """Get a single issue by key."""
    data = jira_get(config, f"issue/{issue_key}")
    if output_format == "json":
        print(json.dumps(data, indent=2, ensure_ascii=False, default=str))
        return
    fields = data.get("fields", {})
    summary = fields.get("summary", "?")
    status = (fields.get("status", {}).get("name") or "?")
    issue_type = (fields.get("issuetype", {}).get("name") or "?")
    assignee = fields.get("assignee")
    assignee_name = assignee.get("displayName", assignee.get("emailAddress", "?")) if assignee else "Unassigned"
    print(f"Key:    {data.get('key')}")
    print(f"Summary: {summary}")
    print(f"Type:   {issue_type}")
    print(f"Status: {status}")
    print(f"Assignee: {assignee_name}")
    desc = fields.get("description")
    if desc:
        if isinstance(desc, dict) and desc.get("content"):
            parts = []
            for block in desc.get("content", []):
                if block.get("type") == "paragraph" and block.get("content"):
                    parts.append("".join(c.get("text", "") for c in block["content"]))
            print("Description:", " ".join(parts)[:500] or "(empty)")
        else:
            print("Description:", str(desc)[:500])


def _text_to_inline_nodes(text):
    """Convert a line of text to ADF inline content; **bold** -> strong mark."""
    if not text:
        return [{"type": "text", "text": ""}]
    parts = []
    rest = text
    while rest:
        i = rest.find("**")
        if i == -1:
            if rest:
                parts.append({"type": "text", "text": rest})
            break
        if i > 0:
            parts.append({"type": "text", "text": rest[:i]})
        rest = rest[i + 2:]
        j = rest.find("**")
        if j == -1:
            parts.append({"type": "text", "text": "**" + rest})
            break
        parts.append({"type": "text", "text": rest[:j], "marks": [{"type": "strong"}]})
        rest = rest[j + 2:]
    return parts if parts else [{"type": "text", "text": ""}]


def markdown_like_to_adf(description):
    """
    Convert markdown-like description to Jira ADF so format is preserved in the UI.
    Supports: ## heading 2, **bold**, - list items, - [ ] checklist. Always uses ADF.
    """
    if not (description or "").strip():
        return {"type": "doc", "version": 1, "content": []}
    content = []
    lines = description.replace("\r\n", "\n").replace("\r", "\n").split("\n")
    i = 0
    while i < len(lines):
        line = lines[i]
        stripped = line.strip()
        if stripped.startswith("## "):
            title = stripped[3:].strip()
            content.append({
                "type": "heading",
                "attrs": {"level": 2},
                "content": [{"type": "text", "text": title}]
            })
            i += 1
            continue
        if stripped.startswith("- [ ] ") or stripped.startswith("- [ ]"):
            task_items = []
            while i < len(lines):
                s = lines[i].strip()
                if s.startswith("- [ ] "):
                    item_text = s[6:].strip()
                elif s.startswith("- [ ]"):
                    item_text = s[5:].strip()
                else:
                    break
                task_items.append({
                    "type": "taskItem",
                    "attrs": {"localId": f"task-{i}", "state": "TODO"},
                    "content": _text_to_inline_nodes(item_text),
                })
                i += 1
            content.append({
                "type": "taskList",
                "attrs": {"localId": f"tasklist-{len(content)}"},
                "content": task_items,
            })
            continue
        if stripped.startswith("- "):
            list_items = []
            while i < len(lines):
                s = lines[i].strip()
                if not s.startswith("- ") or s.startswith("- [ ]"):
                    break
                list_items.append({
                    "type": "listItem",
                    "content": [{
                        "type": "paragraph",
                        "content": _text_to_inline_nodes(s[2:])
                    }]
                })
                i += 1
            content.append({"type": "bulletList", "content": list_items})
            continue
        if stripped.startswith("**") and stripped.endswith("**") and stripped.count("**") == 2:
            content.append({
                "type": "paragraph",
                "content": [{"type": "text", "text": stripped[2:-2], "marks": [{"type": "strong"}]}]
            })
            i += 1
            continue
        if stripped:
            content.append({
                "type": "paragraph",
                "content": _text_to_inline_nodes(stripped)
            })
        i += 1
    return {"type": "doc", "version": 1, "content": content}


def cmd_create_issue(config, project_key, summary, description=None, description_adf_path=None, issuetype="Story", mini_app=None, parent_key=None, components=None, priority=None):
    """Create an issue. Use --description-adf to send raw ADF (path or '-' for stdin); otherwise --description/--description-file is converted to ADF (## = h2, ** = bold, - = list). Use --parent for subtasks. Subtasks are always created as type Dev Task."""
    if parent_key:
        issuetype = "Dev Task"
    body = {
        "fields": {
            "project": {"key": project_key},
            "summary": summary,
            "issuetype": {"name": issuetype},
        }
    }
    if parent_key:
        body["fields"]["parent"] = {"key": parent_key}
    if description_adf_path:
        body["fields"]["description"] = _read_adf(description_adf_path)
    elif description:
        body["fields"]["description"] = markdown_like_to_adf(description)
    if mini_app:
        _mini_app_ids = {
            "servicemanagement": "10223",
            "servicemgmt": "10223",
            "workflows": "10279",
        }
        key = (mini_app or "").strip().lower().replace(" ", "")
        opt_id = _mini_app_ids.get(key) or key
        body["fields"]["customfield_10071"] = [{"id": opt_id}]
    if components:
        body["fields"]["components"] = [{"name": c} for c in components]
    if priority:
        body["fields"]["priority"] = {"name": priority}
    data = jira_post(config, "issue", body)
    key = data.get("key")
    if key:
        print(f"Created: {key}")
        print(f"URL: {config['jira_url'].rstrip('/')}/browse/{key}")
    else:
        print("Issue created (key not in response):", json.dumps(data, indent=2))


def cmd_update_issue(config, issue_key, description_adf_path=None, description_file=None, description_text=None):
    """Update an issue's description. Use --description-adf (path or '-' for stdin), or --description/--description-file (markdown-like, converted to ADF)."""
    if description_adf_path:
        adf = _read_adf(description_adf_path)
    elif description_file:
        with open(description_file, "r", encoding="utf-8") as f:
            adf = markdown_like_to_adf(f.read())
    elif description_text is not None:
        adf = markdown_like_to_adf(description_text)
    else:
        print("Error: use --description-adf, --description-file or --description for update-issue.", file=sys.stderr)
        sys.exit(1)
    body = {"fields": {"description": adf}}
    jira_put(config, f"issue/{issue_key}", body)
    print(f"Updated: {issue_key}")


def cmd_raw_get(config, path):
    """Hit an arbitrary GET /rest/<path> and print the JSON response."""
    data = jira_raw_get(config, path)
    print(json.dumps(data, indent=2, ensure_ascii=False, default=str))


def cmd_raw_post(config, path, data_str):
    """Hit an arbitrary POST /rest/<path>. --data is a JSON string (use '-' for stdin)."""
    if data_str == "-":
        try:
            payload = json.load(sys.stdin)
        except json.JSONDecodeError as e:
            print(f"Error: stdin is not valid JSON: {e}", file=sys.stderr)
            sys.exit(1)
    elif data_str:
        try:
            payload = json.loads(data_str)
        except json.JSONDecodeError as e:
            print(f"Error: --data is not valid JSON: {e}", file=sys.stderr)
            sys.exit(1)
    else:
        payload = {}
    resp = jira_raw_post(config, path, payload)
    print(json.dumps(resp, indent=2, ensure_ascii=False, default=str))


def cmd_list_sprints(config, board_id, state=None):
    """List sprints of a Jira agile board. Optional --state (active, closed, future)."""
    path = f"agile/1.0/board/{board_id}/sprint"
    params = {"maxResults": 200}
    if state:
        params["state"] = state
    data = jira_raw_get(config, path, params=params)
    sprints = data.get("values", [])
    if not sprints:
        print("No sprints found.")
        return
    print(f"{'Id':<8} {'State':<10} {'Name'}")
    print("-" * 60)
    for s in sprints:
        sid = s.get("id", "?")
        sstate = s.get("state", "?")
        name = s.get("name", "?")
        print(f"{str(sid):<8} {sstate:<10} {name}")


def cmd_delete_issue(config, issue_key):
    """Delete an issue by key."""
    jira_delete(config, f"issue/{issue_key}")
    print(f"Deleted: {issue_key}")


def main():
    parser = argparse.ArgumentParser(description="Jira Cloud API client (REST v3, Basic auth)")
    sub = parser.add_subparsers(dest="command", required=True)

    sub.add_parser("list-projects", help="List all projects")

    search_parser = sub.add_parser("search", help="Search issues by JQL")
    search_parser.add_argument("jql", help="JQL query string")
    search_parser.add_argument("--max-results", type=int, default=50)
    search_parser.add_argument("--format", choices=("text", "json"), default="text")

    get_parser = sub.add_parser("get-issue", help="Get an issue by key")
    get_parser.add_argument("issue_key")
    get_parser.add_argument("--format", choices=("text", "json"), default="text")

    delete_parser = sub.add_parser("delete-issue", help="Delete an issue by key")
    delete_parser.add_argument("issue_key")

    update_parser = sub.add_parser("update-issue", help="Update an issue description (markdown-like or ADF file)")
    update_parser.add_argument("issue_key")
    update_parser.add_argument("--description-adf", dest="description_adf_path", default=None, help="Path to JSON file with ADF for description")
    update_parser.add_argument("--description-file", dest="description_file", default=None, help="Path to text file (##, **, -) converted to ADF")
    update_parser.add_argument("--description", dest="description_text", default=None, help="Description text (markdown-like) converted to ADF")

    create_parser = sub.add_parser("create-issue", help="Create an issue")
    create_parser.add_argument("--project", required=True, help="Project key")
    create_parser.add_argument("--summary", required=True, help="Issue summary")
    create_parser.add_argument("--description", default=None)
    create_parser.add_argument("--description-file", dest="description_file", default=None, help="Path to text file for description (overrides --description)")
    create_parser.add_argument("--description-adf", dest="description_adf_path", default=None, help="Path to JSON file with ADF for description (supports links); use '-' to read from stdin")
    create_parser.add_argument("--type", dest="issuetype", default="Story", help="Issue type name (default: Story; si se usa --parent, se ignora y se usa siempre Dev Task)")
    create_parser.add_argument("--parent", dest="parent_key", default=None, help="Parent issue key (e.g. SQPD-123) para crear subtarea como Dev Task")
    create_parser.add_argument("--mini-app", dest="mini_app", default=None, help="Mini App value (e.g. Service Management) for projects that require it")
    create_parser.add_argument("--components", dest="components", nargs="+", default=None, help="One or more component names (e.g. --components Admin Backend)")
    create_parser.add_argument("--priority", dest="priority", default=None, help="Priority name (e.g. Highest, High, Medium, Low, Lowest)")

    raw_get_parser = sub.add_parser("raw-get", help="GET /rest/<path> (escape hatch para endpoints fuera de api/3, ej. agile/1.0)")
    raw_get_parser.add_argument("path", help="Path relativo a /rest/, ej. 'agile/1.0/board?projectKeyOrId=SQPD'")

    raw_post_parser = sub.add_parser("raw-post", help="POST /rest/<path> con body JSON")
    raw_post_parser.add_argument("path", help="Path relativo a /rest/, ej. 'agile/1.0/sprint/123/issue'")
    raw_post_parser.add_argument("--data", dest="data", default=None, help="JSON string para el body (use '-' para leer de stdin)")

    sprints_parser = sub.add_parser("list-sprints", help="Listar sprints de un board")
    sprints_parser.add_argument("board_id", help="Board ID numérico del proyecto")
    sprints_parser.add_argument("--state", choices=("active", "closed", "future"), default=None, help="Filtrar por estado")

    args = parser.parse_args()
    config = load_config()

    if args.command == "list-projects":
        cmd_list_projects(config)
    elif args.command == "search":
        cmd_search(config, args.jql, max_results=args.max_results, output_format=args.format)
    elif args.command == "get-issue":
        cmd_get_issue(config, args.issue_key, output_format=args.format)
    elif args.command == "delete-issue":
        cmd_delete_issue(config, args.issue_key)
    elif args.command == "update-issue":
        cmd_update_issue(config, args.issue_key, args.description_adf_path, args.description_file, args.description_text)
    elif args.command == "create-issue":
        desc = args.description
        if args.description_file:
            with open(args.description_file, "r", encoding="utf-8") as f:
                desc = f.read()
        cmd_create_issue(config, args.project, args.summary, desc, args.description_adf_path, args.issuetype, args.mini_app, args.parent_key, args.components, args.priority)
    elif args.command == "raw-get":
        cmd_raw_get(config, args.path)
    elif args.command == "raw-post":
        cmd_raw_post(config, args.path, args.data)
    elif args.command == "list-sprints":
        cmd_list_sprints(config, args.board_id, state=args.state)


if __name__ == "__main__":
    main()
