"""
Agent 5: QA Jira Manager — MCP Server

Tools:
  fetch_jira_issue           — Fetch a Jira issue with acceptance criteria and coverage hints
  create_and_link_test_cases — Create test case issues in Jira and link them to the source issue
  get_linked_test_cases      — Return test cases already linked to a Jira issue

Authentication:
  Jira OAuth 2.0 (3LO). Silent from ~/.jira-cache/jira-token.json.
  If no cached token exists, the browser opens once for interactive login.
  Refresh tokens keep the session alive without re-prompting.
  No credentials appear in the UI or in configuration files.

Required env vars (in .env):
  JIRA_CLIENT_ID        Atlassian OAuth 2.0 app client ID
  JIRA_CLIENT_SECRET    Atlassian OAuth 2.0 app client secret
  JIRA_CLOUD_ID         Your Atlassian cloud ID

Skills: see skills/qa-jira-manager.md
"""
import asyncio
import json
import sys
import time

from dotenv import load_dotenv
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp import types

from stlc_agents.shared_jira.auth import get_auth_headers, get_cloud_id, get_signed_in_user
from stlc_agents.agent_jira_manager.tools.jira_workitem import (
    fetch_work_item as _fetch_work_item,
    create_test_case as _create_test_case,
    link_test_cases_to_issue as _link_test_cases,
    get_linked_test_cases as _get_linked_test_cases,
    attach_gherkin_to_issue as _attach_gherkin,
)
from stlc_agents.shared.cost_tracker import track

load_dotenv()

app = Server("qa-jira-manager")


# ---------------------------------------------------------------------------
# Pre-output validation helpers
# ---------------------------------------------------------------------------

def _validate_test_case_inputs(test_cases: list[dict]) -> dict:
    """Validate test case payloads before sending to Jira.

    Checks:
      1. Every test case has a non-empty summary.
      2. Every test case has at least one step.
      3. Every step has both action and expected_result fields.
      4. No duplicate summaries within the batch.
      5. Priority is one of the valid Jira values when specified.

    Returns { valid: bool, errors: list[str], warnings: list[str] }.
    """
    errors: list[str] = []
    warnings: list[str] = []
    seen_summaries: set[str] = set()
    valid_priorities = {"Highest", "High", "Medium", "Low", "Lowest"}

    for i, tc in enumerate(test_cases, start=1):
        summary = (tc.get("summary") or tc.get("title") or "").strip()
        if not summary:
            errors.append(f"Test case #{i}: summary is empty or missing.")
        else:
            normalised = summary.lower()
            if normalised in seen_summaries:
                errors.append(f"Test case #{i}: duplicate summary '{summary}'.")
            seen_summaries.add(normalised)

        steps = tc.get("steps", [])
        if not steps:
            errors.append(f"Test case #{i} ('{summary}'): no steps defined.")
        for j, step in enumerate(steps, start=1):
            if not (step.get("action") or "").strip():
                errors.append(f"Test case #{i} ('{summary}'), step #{j}: 'action' is empty.")
            if not (step.get("expected_result") or "").strip():
                errors.append(
                    f"Test case #{i} ('{summary}'), step #{j}: 'expected_result' is empty."
                )

        priority = tc.get("priority", "Medium")
        if priority and priority not in valid_priorities:
            errors.append(
                f"Test case #{i} ('{summary}'): priority '{priority}' is not valid. "
                f"Use one of: {', '.join(sorted(valid_priorities))}."
            )

    if len(test_cases) > 25:
        warnings.append(
            f"Large batch: {len(test_cases)} test cases. Consider splitting "
            "into smaller batches for easier review."
        )

    return {"valid": len(errors) == 0, "errors": errors, "warnings": warnings}


def _validate_fetch_response(result: dict) -> dict:
    errors: list[str] = []
    warnings: list[str] = []

    if "error" in result:
        return {"valid": True, "errors": [], "warnings": []}

    wi = result.get("work_item", {})
    if not wi.get("summary"):
        errors.append("Issue has no summary — Jira data may be incomplete.")

    if not wi.get("acceptance_criteria") and not wi.get("description"):
        warnings.append(
            "Issue has no acceptance criteria or description. "
            "Test case generation may produce low-quality results — "
            "ask the user to add acceptance criteria in Jira."
        )

    if result.get("existing_test_cases_count", 0) > 0:
        warnings.append(
            f"Issue already has {result['existing_test_cases_count']} linked test case(s). "
            "Use get_linked_test_cases to check for duplicates before creating new ones."
        )

    return {"valid": len(errors) == 0, "errors": errors, "warnings": warnings}


def _validate_linked_test_cases_response(result: dict) -> dict:
    errors: list[str] = []
    warnings: list[str] = []

    if "error" in result:
        return {"valid": True, "errors": [], "warnings": []}

    test_cases = result.get("test_cases", [])
    if not test_cases:
        warnings.append("No linked test cases found. This issue has no 'is tested by' links.")
        return {"valid": True, "errors": [], "warnings": warnings}

    for i, tc in enumerate(test_cases, start=1):
        if not tc.get("key"):
            errors.append(f"Test case #{i}: missing key.")
        if not tc.get("summary"):
            warnings.append(f"Test case #{i} (key={tc.get('key', '?')}): missing summary.")

    return {"valid": len(errors) == 0, "errors": errors, "warnings": warnings}


# ---------------------------------------------------------------------------
# Deduplication helper
# ---------------------------------------------------------------------------

_TC_STOP_WORDS = frozenset({
    "verify", "ensure", "validate", "check", "test", "the", "a", "an",
    "that", "is", "are", "can", "user", "should", "able", "to", "with",
})


def _normalise_summary(summary: str) -> str:
    """Lowercase, strip punctuation, remove stop words — for dedup comparison."""
    import re
    cleaned = re.sub(r"[^a-z0-9\s]", "", summary.lower())
    tokens = [w for w in cleaned.split() if w not in _TC_STOP_WORDS]
    return " ".join(tokens)




@app.list_tools()
async def list_tools() -> list[types.Tool]:
    return [
        types.Tool(
            name="fetch_jira_issue",
            description=(
                "Fetch a Jira issue (Story, Bug, Task, Sub-task) from Jira Cloud. "
                "Returns issue fields (summary, description, acceptance criteria, "
                "status, priority, assignee, labels, story points, fix versions, components), "
                "parent issue, count of existing linked test cases, and coverage hints "
                "derived from the issue text. Returns epic_use_hierarchy error for Epics."
            ),
            inputSchema={
                "type": "object",
                "properties": {
                    "issue_key": {
                        "type": "string",
                        "description": "Jira issue key, e.g. 'PROJ-123'",
                    },
                    "cloud_id": {
                        "type": "string",
                        "description": (
                            "Atlassian cloud ID. Leave blank to use JIRA_CLOUD_ID env var."
                        ),
                    },
                },
                "required": ["issue_key"],
            },
        ),
        types.Tool(
            name="create_and_link_test_cases",
            description=(
                "Create one or more test case issues in Jira and link them to the source issue "
                "via an 'is tested by' / 'Test' link. "
                "Steps are stored as a formatted ADF table in the issue description — "
                "compatible with both Jira Software and Xray-free environments.\n\n"
                "Priority values: Highest, High (default), Medium, Low, Lowest.\n\n"
                "Epic confirmation gate:\n"
                "  When the target issue is an Epic, return confirmation_required=true "
                "and do NOT create any test cases. Surface the message to the user and wait "
                "for their reply. Once confirmed, retry with confirmed=true."
            ),
            inputSchema={
                "type": "object",
                "properties": {
                    "issue_key": {
                        "type": "string",
                        "description": "Jira issue key to link test cases to, e.g. 'PROJ-123'",
                    },
                    "project_key": {
                        "type": "string",
                        "description": (
                            "Jira project key where test cases will be created, e.g. 'PROJ'. "
                            "Defaults to the project of the source issue when omitted."
                        ),
                    },
                    "cloud_id": {
                        "type": "string",
                        "description": "Atlassian cloud ID. Leave blank to use JIRA_CLOUD_ID env var.",
                    },
                    "test_cases": {
                        "type": "array",
                        "description": "Test cases to create",
                        "items": {
                            "type": "object",
                            "properties": {
                                "summary": {
                                    "type": "string",
                                    "description": "Test case title / summary",
                                },
                                "priority": {
                                    "type": "string",
                                    "description": "Highest|High|Medium|Low|Lowest (default: High)",
                                },
                                "labels": {
                                    "type": "array",
                                    "items": {"type": "string"},
                                    "description": "Optional labels to apply to the test case issue",
                                },
                                "steps": {
                                    "type": "array",
                                    "items": {
                                        "type": "object",
                                        "properties": {
                                            "action": {"type": "string"},
                                            "expected_result": {"type": "string"},
                                        },
                                        "required": ["action", "expected_result"],
                                    },
                                },
                            },
                            "required": ["summary", "steps"],
                        },
                    },
                    "confirmed": {
                        "type": "boolean",
                        "description": (
                            "Set to true only when retrying after the user has explicitly confirmed "
                            "an Epic-scoped test case upload. Omit (or false) on the first call."
                        ),
                    },
                },
                "required": ["issue_key", "test_cases"],
            },
        ),
        types.Tool(
            name="get_linked_test_cases",
            description=(
                "Return all test case issues already linked to a Jira issue via "
                "'is tested by' / 'Test' link type. "
                "Use this before creating new test cases to avoid duplicates."
            ),
            inputSchema={
                "type": "object",
                "properties": {
                    "issue_key": {"type": "string"},
                    "cloud_id": {
                        "type": "string",
                        "description": "Atlassian cloud ID. Leave blank to use JIRA_CLOUD_ID env var.",
                    },
                },
                "required": ["issue_key"],
            },
        ),
        types.Tool(
            name="attach_gherkin_to_jira_issue",
            description=(
                "Upload a validated .feature file as an attachment on a Jira issue. "
                "Use this after generate_gherkin + validate_gherkin_content to link the "
                "Gherkin artefact back to the source Jira issue — mirrors the ADO "
                "attach_gherkin_to_work_item / attach_gherkin_to_feature tools. "
                "File is named {issue_key}_{summary_kebab}_regression.feature automatically. "
                "Requires the Jira OAuth token to have 'write:jira-work' scope."
            ),
            inputSchema={
                "type": "object",
                "properties": {
                    "issue_key": {
                        "type": "string",
                        "description": "Jira issue key, e.g. 'PROJ-123'",
                    },
                    "gherkin_content": {
                        "type": "string",
                        "description": "Complete validated .feature file content",
                    },
                    "cloud_id": {
                        "type": "string",
                        "description": "Atlassian cloud ID. Leave blank to use JIRA_CLOUD_ID env var.",
                    },
                },
                "required": ["issue_key", "gherkin_content"],
            },
        ),
        types.Tool(
            name="create_deduped_test_cases",            description=(
                "Create test cases in Jira, skipping any whose summary already exists as a "
                "linked test case on the issue. "
                "Internally calls get_linked_test_cases, filters the incoming batch against "
                "existing summaries (case-insensitive, stop-word-normalised), then calls "
                "create_and_link_test_cases on the net-new subset only. "
                "Use this instead of create_and_link_test_cases for webhook/headless runs "
                "where re-triggers would otherwise produce duplicates. "
                "Returns skipped_count, created_count, and the full create result."
            ),
            inputSchema={
                "type": "object",
                "properties": {
                    "issue_key": {
                        "type": "string",
                        "description": "Jira issue key to link test cases to, e.g. 'PROJ-123'",
                    },
                    "cloud_id": {
                        "type": "string",
                        "description": "Atlassian cloud ID. Leave blank to use JIRA_CLOUD_ID env var.",
                    },
                    "test_cases": {
                        "type": "array",
                        "description": "Full proposed test case batch (duplicates will be filtered out)",
                        "items": {
                            "type": "object",
                            "properties": {
                                "summary": {"type": "string", "description": "Test case title/summary"},
                                "priority": {"type": "string", "description": "Highest|High|Medium|Low|Lowest"},
                                "steps": {
                                    "type": "array",
                                    "items": {
                                        "type": "object",
                                        "properties": {
                                            "action": {"type": "string"},
                                            "expected_result": {"type": "string"},
                                        },
                                        "required": ["action", "expected_result"],
                                    },
                                },
                            },
                            "required": ["summary", "steps"],
                        },
                    },
                },
                "required": ["issue_key", "test_cases"],
            },
        ),
        types.Tool(
            name="attach_gherkin_to_issue",
            description=(
                "Upload and attach a validated .feature file to a Jira issue as an attachment. "
                "File is named {issue_key}_{summary_kebab}_regression.feature automatically. "
                "Use this after validate_gherkin_content passes. "
                "Called by the pipeline's Jira Gherkin path to link the .feature back to "
                "the Jira issue instead of saving to disk only."
            ),
            inputSchema={
                "type": "object",
                "properties": {
                    "issue_key": {
                        "type": "string",
                        "description": "Jira issue key, e.g. 'PROJ-123'",
                    },
                    "gherkin_content": {
                        "type": "string",
                        "description": "Validated .feature file content",
                    },
                    "issue_summary": {
                        "type": "string",
                        "description": "Issue summary used in the filename (optional)",
                    },
                    "cloud_id": {
                        "type": "string",
                        "description": "Atlassian cloud ID. Leave blank to use JIRA_CLOUD_ID env var.",
                    },
                },
                "required": ["issue_key", "gherkin_content"],
            },
        ),
    ]


# ---------------------------------------------------------------------------
# Tool dispatcher
# ---------------------------------------------------------------------------

@app.call_tool()
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
    t0 = time.monotonic()
    try:
        cloud_id = (arguments.get("cloud_id") or "").strip() or get_cloud_id()

        if name == "fetch_jira_issue":
            result = await asyncio.to_thread(
                _fetch_work_item,
                cloud_id,
                arguments["issue_key"],
            )
            result["_validation"] = _validate_fetch_response(result)

        elif name == "create_and_link_test_cases":
            issue_key = arguments["issue_key"]
            test_cases = arguments["test_cases"]

            # Normalise: accept either 'summary' or 'title' on input
            for tc in test_cases:
                if "title" in tc and "summary" not in tc:
                    tc["summary"] = tc.pop("title")

            # ── Input validation — reject before hitting Jira ─────────────
            input_validation = _validate_test_case_inputs(test_cases)
            if not input_validation["valid"]:
                result = {
                    "error": "input_validation_failed",
                    "validation": input_validation,
                    "message": (
                        "Test case inputs failed validation. Fix the errors "
                        "below and retry. No test cases were created in Jira."
                    ),
                }
                return track(result, tool_name=name, server="qa-jira-manager", t0=t0)

            # ── Peek at the issue to get type and project_key ─────────────
            issue_data: dict = {}
            try:
                peek = await asyncio.to_thread(_fetch_work_item, cloud_id, issue_key)
                if not peek.get("error"):
                    issue_data = peek
            except Exception:
                pass

            issue_type = (issue_data.get("work_item") or {}).get("type", "")
            project_key = (
                (arguments.get("project_key") or "").strip()
                or (issue_data.get("work_item") or {}).get("project_key", "")
            )
            epic_key_for_link = (issue_data.get("work_item") or {}).get("epic_key", "")

            # ── Epic confirmation gate ────────────────────────────────────
            if issue_type == "Epic" and not arguments.get("confirmed", False):
                issue_summary = (issue_data.get("work_item") or {}).get("summary", issue_key)
                result = {
                    "confirmation_required": True,
                    "issue_key": issue_key,
                    "issue_type": "Epic",
                    "issue_summary": issue_summary,
                    "proposed_test_case_count": len(test_cases),
                    "retry_instructions": (
                        "Surface this message to the user. "
                        "If they confirm, retry this tool call with the IDENTICAL "
                        "arguments and add confirmed=true. "
                        "Do NOT change issue_key or any other parameter. "
                        "If they decline, abort."
                    ),
                    "message": (
                        f"Issue {issue_key} is an Epic: \"{issue_summary}\". "
                        f"You are about to create and upload {len(test_cases)} test "
                        f"case(s) linked to this Epic in Jira. "
                        "Please confirm before proceeding. "
                        "Reply 'yes' or 'confirm' to proceed, or 'no' / 'cancel' to abort."
                    ),
                }
                return track(result, tool_name=name, server="qa-jira-manager", t0=t0)

            if not project_key:
                result = {
                    "error": "project_key_required",
                    "message": (
                        "Could not determine the Jira project key. "
                        "Pass project_key explicitly, e.g. 'PROJ'."
                    ),
                }
                return track(result, tool_name=name, server="qa-jira-manager", t0=t0)

            # ── Create test cases ─────────────────────────────────────────
            created = []
            failed = []
            for tc in test_cases:
                try:
                    r = await asyncio.to_thread(
                        _create_test_case,
                        cloud_id,
                        project_key,
                        tc["summary"],
                        tc.get("steps", []),
                        tc.get("priority", "High"),
                        tc.get("labels"),
                        epic_key_for_link or None,
                    )
                    created.append(r)
                except Exception as e:
                    failed.append({"summary": tc["summary"], "error": str(e)})

            link_result: dict = {}
            if created:
                tc_keys = [r["issue_key"] for r in created]
                try:
                    link_result = await asyncio.to_thread(
                        _link_test_cases, cloud_id, issue_key, tc_keys
                    )
                except Exception as e:
                    link_result = {"error": str(e)}

            result = {
                "summary": {
                    "requested": len(test_cases),
                    "created": len(created),
                    "failed": len(failed),
                    "linked_to_issue": issue_key,
                },
                "created_test_cases": created,
                "failed": failed,
                "link_result": link_result,
                "_validation": {
                    "valid": len(failed) == 0 and link_result.get("success", False),
                    "input_validation": input_validation,
                    "errors": [f["error"] for f in failed] if failed else [],
                    "warnings": input_validation.get("warnings", []),
                    "post_creation_check": {
                        "all_created": len(created) == len(test_cases),
                        "all_linked": link_result.get("success", False),
                    },
                },
            }

        elif name == "attach_gherkin_to_jira_issue":
            issue_key = arguments["issue_key"]
            gherkin_content = arguments["gherkin_content"]

            # Fetch issue summary for filename (best-effort)
            issue_summary = ""
            try:
                peek = await asyncio.to_thread(_fetch_work_item, cloud_id, issue_key)
                issue_summary = (peek.get("work_item") or {}).get("summary", "")
            except Exception:
                pass

            result = await asyncio.to_thread(
                _attach_gherkin,
                cloud_id,
                issue_key,
                gherkin_content,
                issue_summary,
            )

        elif name == "get_linked_test_cases":
            result = await asyncio.to_thread(
                _get_linked_test_cases,
                cloud_id,
                arguments["issue_key"],
            )
            result["_validation"] = _validate_linked_test_cases_response(result)

        elif name == "create_deduped_test_cases":
            issue_key = arguments["issue_key"]
            proposed = arguments["test_cases"]

            # Normalise title/summary on input
            for tc in proposed:
                if "title" in tc and "summary" not in tc:
                    tc["summary"] = tc.pop("title")

            # Step 1: fetch existing linked TCs
            existing_result = await asyncio.to_thread(
                _get_linked_test_cases, cloud_id, issue_key
            )
            existing_summaries: set[str] = {
                _normalise_summary(tc.get("summary", ""))
                for tc in existing_result.get("test_cases", [])
            }

            # Step 2: filter — keep only net-new
            net_new = [
                tc for tc in proposed
                if _normalise_summary(tc.get("summary", "")) not in existing_summaries
            ]
            skipped = len(proposed) - len(net_new)

            if not net_new:
                result = {
                    "status": "all_duplicates",
                    "skipped_count": skipped,
                    "created_count": 0,
                    "message": (
                        f"All {skipped} proposed test case(s) already exist as linked "
                        "test cases on this issue. Nothing was created."
                    ),
                }
            else:
                # Step 3: delegate to create_and_link_test_cases
                create_result_raw = await call_tool("create_and_link_test_cases", {
                    "issue_key":  issue_key,
                    "cloud_id":   cloud_id,
                    "test_cases": net_new,
                    "confirmed":  True,
                })
                inner = json.loads(create_result_raw[0].text)
                result = {
                    "status": "ok",
                    "skipped_count": skipped,
                    "created_count": len(net_new),
                    "create_result": inner,
                }

        elif name == "attach_gherkin_to_issue":
            result = await asyncio.to_thread(
                _attach_gherkin,
                cloud_id,
                arguments["issue_key"],
                arguments["gherkin_content"],
                arguments.get("issue_summary", ""),
            )

        else:
            result = {"error": f"Unknown tool: {name}"}

        return track(result, tool_name=name, server="qa-jira-manager", t0=t0)

    except Exception as exc:
        err_result = {"error": str(exc), "tool": name}
        return track(err_result, tool_name=name, server="qa-jira-manager", t0=t0)


# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------

async def _run():
    async with stdio_server() as (r, w):
        await app.run(r, w, app.create_initialization_options())


def main():
    # Validate auth on startup — triggers interactive login if needed
    try:
        get_auth_headers()
    except Exception as e:
        print(f"[qa-jira-manager] Auth error: {e}", file=sys.stderr)
        sys.exit(1)

    user = get_signed_in_user()
    if user:
        print(f"[qa-jira-manager] Authenticated as: {user}", file=sys.stderr)

    asyncio.run(_run())


if __name__ == "__main__":
    main()