"""
Agent 1: QA Test Case Manager — MCP Server

Tools:
  fetch_work_item           — Fetch a PBI or Bug with acceptance criteria
  create_and_link_test_cases — Create test cases in ADO and link to work item
  get_linked_test_cases     — Return existing test cases linked to a work item

Authentication:
  Silent from ~/.msal-cache/msal-cache.json (shared with microsoft/azure-devops-mcp).
  If no cached token exists, the browser opens once for interactive login.
  No credentials appear in the UI or in configuration files.

Skills: see skills/generate-test-cases.md
"""
import asyncio
import json
import os
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.auth import get_auth_headers, get_signed_in_user
from stlc_agents.agent_test_case_manager.tools.ado_workitem import (
    fetch_work_item as _fetch_work_item,
    create_test_case as _create_test_case,
    link_test_cases_to_work_item as _link_test_cases,
    get_linked_test_cases as _get_linked_test_cases,
    add_tag_to_work_item as _add_tag,
)
from stlc_agents.shared.cost_tracker import track

load_dotenv()

app = Server("qa-test-case-manager")


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

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

    Checks:
      1. Every test case has a non-empty title.
      2. Every test case has at least one step.
      3. Every step has both action and expected_result fields.
      4. No duplicate titles within the batch.
      5. Priority is in range 1-4 when specified.

    Returns { valid: bool, errors: list[str], warnings: list[str] }.
    """
    errors: list[str] = []
    warnings: list[str] = []
    seen_titles: set[str] = set()

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

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

        priority = tc.get("priority")
        if priority is not None and priority not in (1, 2, 3, 4):
            errors.append(
                f"Test case #{i} ('{title}'): priority {priority} is out of range (1-4)."
            )

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

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


def _validate_fetch_response(result: dict) -> dict:
    """Validate the response from fetch_work_item before returning to user.

    Checks:
      1. Work item has a title.
      2. Acceptance criteria is not empty (warns if missing).
      3. Work item type is recognised.

    Returns { valid: bool, errors: list[str], warnings: list[str] }.
    """
    errors: list[str] = []
    warnings: list[str] = []

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

    wi = result.get("work_item", {})
    if not wi.get("title"):
        errors.append("Work item has no title — ADO data may be incomplete.")

    if not wi.get("acceptance_criteria"):
        warnings.append(
            "Work item has no acceptance criteria. Test case generation may "
            "produce low-quality results — ask the user for acceptance criteria."
        )

    wi_type = wi.get("type", "")
    known_types = {"Product Backlog Item", "Bug", "Feature", "User Story", "Task", "Epic"}
    if wi_type and wi_type not in known_types:
        warnings.append(f"Unrecognised work item type: '{wi_type}'.")

    if result.get("existing_test_cases_count", 0) > 0:
        warnings.append(
            f"Work item already has {result['existing_test_cases_count']} linked test case(s). "
            f"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:
    """Validate the response from get_linked_test_cases before returning to user.

    Checks:
      1. Response is not an error.
      2. Test case list is present.
      3. Each test case has an ID and title.
      4. Warns about test cases with no steps.

    Returns { valid: bool, errors: list[str], warnings: list[str] }.
    """
    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 work item has no TestedBy relations."
        )
        return {"valid": True, "errors": [], "warnings": warnings}

    for i, tc in enumerate(test_cases, start=1):
        if not tc.get("id"):
            errors.append(f"Test case #{i}: missing ID.")
        if not tc.get("title"):
            warnings.append(f"Test case #{i} (ID={tc.get('id', '?')}): missing title.")
        steps = tc.get("steps", [])
        if not steps:
            warnings.append(
                f"Test case '{tc.get('title', '?')}' (ID={tc.get('id', '?')}): has no steps defined."
            )

    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_title(title: str) -> str:
    """Lowercase, strip punctuation, remove stop words — for dedup comparison.

    Also strips LLM-generated numeric prefixes such as TC_1_, TC-2_, 1., 1)
    so that the same scenario generates a stable key across runs even when the
    LLM varies its numbering scheme.
    """
    import re
    s = title.strip()
    # Remove common LLM prefix patterns: TC_1_, TC-2_, 1., 1), (1), #1
    s = re.sub(r"^(?:TC[-_]?\d+[-_:]?|\(\d+\)|#?\d+[.):]?)\s*", "", s, flags=re.IGNORECASE)
    cleaned = re.sub(r"[^a-z0-9\s]", "", s.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_work_item",
            description=(
                "Fetch a Product Backlog Item or Bug from Azure DevOps. "
                "Returns work item fields (title, acceptance criteria, description, "
                "story points, priority, area path, iteration path), parent feature, "
                "count of existing linked test cases, coverage hints derived from "
                "the acceptance criteria text, and design_links (Figma, Miro, Sketch, InVision URLs "
                "extracted from description and acceptance criteria)."
            ),
            inputSchema={
                "type": "object",
                "properties": {
                    "work_item_id": {"type": "integer", "description": "ADO work item ID (PBI or Bug)"},
                    "organization_url": {"type": "string", "description": "e.g. https://dev.azure.com/myorg"},
                    "project_name": {"type": "string", "description": "ADO project name"},
                },
                "required": ["work_item_id", "organization_url", "project_name"],
            },
        ),
        types.Tool(
            name="create_and_link_test_cases",
            description=(
                "Create one or more test cases in Azure DevOps and link them to a work item "
                "via a TestedBy-Forward relation. Area path and iteration path are automatically "
                "inherited from the parent work item when not specified. "
                "Priority: 1=Critical, 2=High (default), 3=Medium, 4=Low.\n\n"
                "Feature confirmation gate:\n"
                "  When the target work item is a Feature, the first call returns "
                "confirmation_required=true and does NOT create any test cases. "
                "Surface the message to the user and wait for their reply. "
                "Once the user confirms, retry the EXACT same call with confirmed=true added — "
                "do NOT change work_item_id, work_item_type, or any other parameter. "
                "Reply 'no' / 'cancel' → do not retry; abort the operation entirely."
            ),
            inputSchema={
                "type": "object",
                "properties": {
                    "work_item_id": {"type": "integer", "description": "Work item to link test cases to"},
                    "organization_url": {"type": "string"},
                    "project_name": {"type": "string"},
                    "test_cases": {
                        "type": "array",
                        "description": "Test cases to create",
                        "items": {
                            "type": "object",
                            "properties": {
                                "title": {"type": "string"},
                                "priority": {"type": "integer", "description": "1-4, default 2"},
                                "steps": {
                                    "type": "array",
                                    "items": {
                                        "type": "object",
                                        "properties": {
                                            "action": {"type": "string"},
                                            "expected_result": {"type": "string"},
                                        },
                                        "required": ["action", "expected_result"],
                                    },
                                },
                            },
                            "required": ["title", "steps"],
                        },
                    },
                    "confirmed": {
                        "type": "boolean",
                        "description": (
                            "Set to true only when retrying after the user has explicitly confirmed "
                            "a Feature-scoped test case upload in response to a confirmation_required "
                            "response. Omit (or false) on the first call — the gate will fire "
                            "automatically if the work item is a Feature."
                        ),
                    },
                },
                "required": ["work_item_id", "organization_url", "project_name", "test_cases"],
            },
        ),
        types.Tool(
            name="get_linked_test_cases",
            description=(
                "Return all test cases already linked to a work item via TestedBy. "
                "Use this before creating new test cases to avoid duplicates."
            ),
            inputSchema={
                "type": "object",
                "properties": {
                    "work_item_id": {"type": "integer"},
                    "organization_url": {"type": "string"},
                    "project_name": {"type": "string"},
                },
                "required": ["work_item_id", "organization_url", "project_name"],
            },
        ),
        types.Tool(
            name="create_deduped_test_cases",
            description=(
                "Create test cases in ADO, skipping any whose title already exists as a "
                "linked test case on the work item. "
                "Internally calls get_linked_test_cases, filters the incoming batch against "
                "existing titles (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": {
                    "work_item_id": {"type": "integer", "description": "Work item to link test cases to"},
                    "organization_url": {"type": "string"},
                    "project_name": {"type": "string"},
                    "test_cases": {
                        "type": "array",
                        "description": "Full proposed test case batch (duplicates will be filtered out)",
                        "items": {
                            "type": "object",
                            "properties": {
                                "title": {"type": "string"},
                                "priority": {"type": "integer", "description": "1-4, default 2"},
                                "steps": {
                                    "type": "array",
                                    "items": {
                                        "type": "object",
                                        "properties": {
                                            "action": {"type": "string"},
                                            "expected_result": {"type": "string"},
                                        },
                                        "required": ["action", "expected_result"],
                                    },
                                },
                            },
                            "required": ["title", "steps"],
                        },
                    },
                },
                "required": ["work_item_id", "organization_url", "project_name", "test_cases"],
            },
        ),
    ]


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

@app.call_tool()
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
    t0 = time.monotonic()
    try:
        if name == "fetch_work_item":
            result = await asyncio.to_thread(
                _fetch_work_item,
                arguments["organization_url"],
                arguments["project_name"],
                arguments["work_item_id"],
            )
            # ── Pre-output validation ─────────────────────────────────────
            validation = _validate_fetch_response(result)
            result["_validation"] = validation

        elif name == "create_and_link_test_cases":
            org = arguments["organization_url"]
            project = arguments["project_name"]
            wi_id = arguments["work_item_id"]
            test_cases = arguments["test_cases"]

            # ── Input validation — reject before hitting ADO ──────────────
            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 ADO."
                    ),
                }
                return track(result, tool_name=name, server="qa-test-case-manager", t0=t0)

            # ── Epic guard — always check work item type before creating TCs ─
            try:
                wi_check = await asyncio.to_thread(_fetch_work_item, org, project, wi_id)
                if wi_check.get("error") == "epic_not_supported":
                    result = {
                        "error": "epic_not_supported",
                        "work_item_id": wi_id,
                        "message": (
                            f"Work item {wi_id} is an Epic. "
                            "Manual test cases cannot be created or linked to an Epic. "
                            "ADO test cases are always scoped to PBIs, Bugs, or Features — "
                            "never to Epics. Please look up the child Features and "
                            "PBIs/Bugs of this Epic in Azure DevOps, then call "
                            "create_and_link_test_cases on each individual "
                            "Feature or PBI/Bug ID instead."
                        ),
                    }
                    return track(result, tool_name=name, server="qa-test-case-manager", t0=t0)
            except Exception:
                pass  # if the peek fails, continue and let the real call surface the error

            # Inherit area_path / iteration_path from parent work item
            parent_area_path = None
            parent_iteration_path = None
            try:
                wi_data = await asyncio.to_thread(_fetch_work_item, org, project, wi_id)
                parent_area_path = wi_data["work_item"].get("area_path")
                parent_iteration_path = wi_data["work_item"].get("iteration_path")
            except Exception:
                pass

            # ── Feature confirmation gate ─────────────────────────────────────
            # When the target work item is a Feature, pause and ask the user
            # to confirm before creating and uploading test cases.  The
            # server cannot block here (it is async / stateless), so we rely
            # on the skill / agent layer to enforce this.  The server signals
            # the need for confirmation by returning a structured response that
            # the LLM skill interprets as a hard stop.
            # Retry protocol: after the user confirms, call this tool again
            # with the EXACT same arguments plus confirmed=true.  The gate
            # is then bypassed and creation proceeds normally.
            try:
                wi_type_check = wi_data.get("work_item", {}).get("type", "")
                if wi_type_check == "Feature" and not arguments.get("confirmed", False):
                    feature_title = wi_data.get("work_item", {}).get("title", str(wi_id))
                    result = {
                        "confirmation_required": True,
                        "work_item_id": wi_id,
                        "work_item_type": "Feature",
                        "feature_title": feature_title,
                        "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 work_item_id or any other parameter. "
                            "If they decline, abort — do not call any other tool."
                        ),
                        "message": (
                            f"Work item {wi_id} is a Feature: \"{feature_title}\". "
                            f"You are about to create and upload {len(test_cases)} manual "
                            f"test case(s) linked to this Feature in ADO. "
                            "Please confirm before proceeding. "
                            "Reply 'yes' or 'confirm' to proceed, or 'no' / 'cancel' to abort."
                        ),
                    }
                    return track(result, tool_name=name, server="qa-test-case-manager", t0=t0)
            except Exception:
                pass  # wi_data may not be set if the earlier peek failed; continue normally


            failed = []
            created = []
            for tc in test_cases:
                try:
                    r = await asyncio.to_thread(
                        _create_test_case,
                        org, project,
                        tc["title"],
                        tc.get("steps", []),
                        tc.get("priority", 2),
                        tc.get("area_path") or parent_area_path,
                        tc.get("iteration_path") or parent_iteration_path,
                    )
                    created.append(r)
                except Exception as e:
                    failed.append({"title": tc["title"], "error": str(e)})

            link_result = {}
            if created:
                tc_ids = [r["test_case_id"] for r in created]
                try:
                    link_result = await asyncio.to_thread(
                        _link_test_cases, org, project, wi_id, tc_ids
                    )
                except Exception as e:
                    link_result = {"error": str(e)}

            # Add tag to the parent work item after linking
            tag_result = {}
            if created:
                try:
                    tag_result = await asyncio.to_thread(
                        _add_tag, org, project, wi_id, "STLCAgentTestCases"
                    )
                except Exception as e:
                    tag_result = {"error": str(e)}

            result = {
                "summary": {
                    "requested": len(test_cases),
                    "created": len(created),
                    "failed": len(failed),
                    "linked_to_work_item": wi_id,
                },
                "created_test_cases": created,
                "failed": failed,
                "link_result": link_result,
                "tag_result": tag_result,
                "_validation": {
                    "valid": len(failed) == 0 and bool(link_result.get("success", True)),
                    "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) if link_result else False,
                    },
                },
            }

        elif name == "get_linked_test_cases":
            result = await asyncio.to_thread(
                _get_linked_test_cases,
                arguments["organization_url"],
                arguments["project_name"],
                arguments["work_item_id"],
            )
            # ── Pre-output validation ─────────────────────────────────────
            result["_validation"] = _validate_linked_test_cases_response(result)

        elif name == "create_deduped_test_cases":
            org = arguments["organization_url"]
            project = arguments["project_name"]
            wi_id = arguments["work_item_id"]
            proposed = arguments["test_cases"]

            # Step 1: fetch existing linked TCs
            existing_result = await asyncio.to_thread(
                _get_linked_test_cases, org, project, wi_id
            )
            existing_titles: set[str] = {
                _normalise_title(tc.get("title", ""))
                for tc in existing_result.get("linked_test_cases", [])
            }

            # Step 2: filter — keep only net-new
            net_new = [
                tc for tc in proposed
                if _normalise_title(tc.get("title", "")) not in existing_titles
            ]
            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 work item. Nothing was created."
                    ),
                }
            else:
                # Step 3: create net-new batch via the existing tool logic
                # Re-dispatch through call_tool to reuse input validation + Feature gate
                create_result_raw = await call_tool("create_and_link_test_cases", {
                    "work_item_id":     wi_id,
                    "organization_url": org,
                    "project_name":     project,
                    "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,
                }

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

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

    except Exception as exc:
        err_result = {"error": str(exc), "tool": name}
        return track(err_result, tool_name=name, server="qa-test-case-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-test-case-manager] Auth error: {e}", file=sys.stderr)
        sys.exit(1)

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

    asyncio.run(_run())


if __name__ == "__main__":
    main()