"""
ado_gherkin.py — Azure DevOps REST API calls for the Gherkin Generator agent.

Public API:
  fetch_feature_hierarchy(org_url, project, feature_id)              -> dict
  fetch_work_item_for_gherkin(org_url, project, work_item_id)        -> dict  ← NEW
  attach_feature_file(org_url, project, feature_id, title, content)  -> dict
  attach_work_item_file(org_url, project, work_item_id, title, content) -> dict  ← NEW
  validate_gherkin_content(gherkin_content)                           -> dict  ← NEW
"""
from __future__ import annotations

import json
import re
import xml.etree.ElementTree as ET
from typing import List

import requests

from stlc_agents.shared.auth import get_auth_headers

_API = "7.1"

# Work item types that can parent a Gherkin file directly
_PBI_TYPES = {"Product Backlog Item", "Bug", "User Story", "Task"}

# Scenario count bounds per scope
_SCENARIO_MIN_FEATURE = 5
_SCENARIO_MAX_FEATURE = 10
_SCENARIO_MIN_WI = 3
_SCENARIO_MAX_WI = 9


# ---------------------------------------------------------------------------
# validate_gherkin_content  (NEW — structural enforcement gate)
# ---------------------------------------------------------------------------

def validate_gherkin_content(
    gherkin_content: str,
    scope: str = "feature",
) -> dict:
    """
    Structurally validate a .feature file before it is attached to ADO.

    Checks enforced:
      1. At least one @smoke tag is present.
      2. At least one @regression tag is present.
      3. Scenario count is within the allowed range for the scope.
         scope="feature" → 5–10 scenarios
         scope="work_item" → 3–9 scenarios
      4. Every scenario has at least one When step.
      5. No duplicate scenario titles.

    Returns:
      {
        "valid": bool,
        "errors": [str],            # empty list when valid
        "warnings": [str],          # non-blocking observations
        "stats": {
          "scenario_count": int,
          "has_smoke": bool,
          "has_regression": bool,
          "duplicate_titles": [str],
          "scenarios_missing_when": [str],
        }
      }
    """
    errors: list[str] = []
    warnings: list[str] = []

    lines = gherkin_content.splitlines()

    # ── Collect scenario blocks ──────────────────────────────────────────────
    scenario_titles: list[str] = []
    scenarios_missing_when: list[str] = []
    current_title: str | None = None
    current_has_when = False

    tag_lines: list[str] = []  # lines that start with @

    for line in lines:
        stripped = line.strip()

        if stripped.startswith("@"):
            tag_lines.append(stripped)
            continue

        scenario_match = re.match(
            r"^\s*(Scenario Outline|Scenario):\s*(.+)$", line
        )
        if scenario_match:
            # Flush previous scenario
            if current_title is not None and not current_has_when:
                scenarios_missing_when.append(current_title)

            current_title = scenario_match.group(2).strip()
            current_has_when = False
            scenario_titles.append(current_title)
            continue

        if stripped.startswith("When "):
            current_has_when = True

    # Flush last scenario
    if current_title is not None and not current_has_when:
        scenarios_missing_when.append(current_title)

    scenario_count = len(scenario_titles)

    # ── Tag checks ───────────────────────────────────────────────────────────
    all_tags_text = " ".join(tag_lines) + " " + gherkin_content
    has_smoke = bool(re.search(r"@smoke\b", all_tags_text))
    has_regression = bool(re.search(r"@regression\b", all_tags_text))

    if not has_smoke:
        errors.append(
            "Missing @smoke tag: every feature file must contain at least one "
            "@smoke scenario marking the primary happy path."
        )

    if not has_regression:
        errors.append(
            "Missing @regression tag: every feature file must contain at least one "
            "@regression scenario. Use @smoke only for the single primary happy path."
        )

    # ── Scenario count ───────────────────────────────────────────────────────
    if scope == "feature":
        min_s, max_s = _SCENARIO_MIN_FEATURE, _SCENARIO_MAX_FEATURE
        scope_label = "Feature-scoped"
    else:
        min_s, max_s = _SCENARIO_MIN_WI, _SCENARIO_MAX_WI
        scope_label = "Work-item-scoped"

    if scenario_count < min_s:
        errors.append(
            f"Too few scenarios: {scenario_count} found, minimum is {min_s} "
            f"for a {scope_label} feature file. Add more coverage for error "
            f"cases, boundary conditions, or cancel flows."
        )
    elif scenario_count > max_s:
        errors.append(
            f"Too many scenarios: {scenario_count} found, maximum is {max_s} "
            f"for a {scope_label} feature file. Split into multiple feature "
            f"files or merge related scenarios into Scenario Outlines."
        )

    # ── When steps ───────────────────────────────────────────────────────────
    for title in scenarios_missing_when:
        errors.append(
            f"Scenario '{title}' has no When step. Every scenario must contain "
            f"at least one When step describing the user action."
        )

    # ── Duplicate titles ─────────────────────────────────────────────────────
    seen: set[str] = set()
    duplicate_titles: list[str] = []
    for title in scenario_titles:
        if title in seen:
            duplicate_titles.append(title)
        seen.add(title)

    for title in duplicate_titles:
        errors.append(
            f"Duplicate scenario title: '{title}'. Every scenario title must be unique."
        )

    # ── Warnings (non-blocking) ───────────────────────────────────────────────
    smoke_count = sum(
        1 for i, line in enumerate(lines)
        if "@smoke" in line
        and i + 1 < len(lines)
        and re.match(r"^\s*Scenario", lines[i + 1])
    )
    if smoke_count > 1:
        warnings.append(
            f"Multiple @smoke scenarios found ({smoke_count}). Convention is "
            f"one @smoke per feature file — the single primary happy path."
        )

    if not re.search(r"^\s*Background:", gherkin_content, re.MULTILINE):
        warnings.append(
            "No Background block present. If 2 or more scenarios share the "
            "same Given preconditions, consider extracting them into a Background."
        )

    return {
        "valid": len(errors) == 0,
        "errors": errors,
        "warnings": warnings,
        "stats": {
            "scenario_count": scenario_count,
            "has_smoke": has_smoke,
            "has_regression": has_regression,
            "duplicate_titles": duplicate_titles,
            "scenarios_missing_when": scenarios_missing_when,
        },
    }


# ---------------------------------------------------------------------------
# fetch_work_item_for_gherkin  (NEW — PBI/Bug-scoped fetch)
# ---------------------------------------------------------------------------

def fetch_work_item_for_gherkin(
    org_url: str,
    project: str,
    work_item_id: int,
) -> dict:
    """
    Fetch a single PBI or Bug work item with all fields needed to generate a
    scoped Gherkin feature file. Unlike fetch_feature_hierarchy (which requires
    a Feature ID), this accepts any PBI or Bug ID directly.

    Returns the work item's own acceptance criteria, its linked test cases with
    full steps, its parent Feature metadata, and the Gherkin best-practice
    reference example — everything the LLM needs to write 3–9 scoped scenarios.
    """
    org_url = org_url.rstrip("/")
    headers = get_auth_headers()

    # ── Fetch target work item ───────────────────────────────────────────────
    resp = requests.get(
        f"{org_url}/{project}/_apis/wit/workitems/{work_item_id}",
        headers=headers,
        params={"$expand": "relations", "api-version": _API},
        timeout=30,
    )
    resp.raise_for_status()
    data = resp.json()
    fields = data.get("fields", {})

    wi_type = fields.get("System.WorkItemType", "")
    if wi_type not in _PBI_TYPES:
        return {
            "error": (
                f"Work item {work_item_id} is a '{wi_type}'. "
                f"fetch_work_item_for_gherkin only accepts: "
                f"{', '.join(sorted(_PBI_TYPES))}. "
                f"For Feature work items use fetch_feature_hierarchy instead."
            ),
            "work_item_type": wi_type,
            "work_item_id": work_item_id,
        }

    work_item = {
        "id": data["id"],
        "type": wi_type,
        "title": fields.get("System.Title", ""),
        "description": _strip_html(fields.get("System.Description", "") or ""),
        "acceptance_criteria": _strip_html(
            fields.get("Microsoft.VSTS.Common.AcceptanceCriteria", "") or ""
        ),
        "repro_steps": _strip_html(
            fields.get("Microsoft.VSTS.TCM.ReproSteps", "") or ""
        ),
        "state": fields.get("System.State", ""),
    }

    # ── Collect linked TC IDs ────────────────────────────────────────────────
    tc_ids = [
        int(r["url"].split("/")[-1])
        for r in data.get("relations", [])
        if r.get("rel") == "Microsoft.VSTS.Common.TestedBy-Forward"
    ]
    test_cases = _fetch_test_cases(org_url, project, tc_ids)

    # ── Fetch parent Feature (if present) ───────────────────────────────────
    parent_feature: dict | None = None
    for rel in data.get("relations", []):
        if rel.get("rel") == "System.LinkTypes.Hierarchy-Reverse":
            try:
                pr = requests.get(
                    rel["url"] + f"?api-version={_API}",
                    headers=get_auth_headers(),
                    timeout=30,
                )
                if pr.ok:
                    pf = pr.json().get("fields", {})
                    parent_feature = {
                        "id": pr.json().get("id"),
                        "type": pf.get("System.WorkItemType", ""),
                        "title": pf.get("System.Title", ""),
                        "description": _strip_html(
                            pf.get("System.Description", "") or ""
                        ),
                        "acceptance_criteria": _strip_html(
                            pf.get("Microsoft.VSTS.Common.AcceptanceCriteria", "") or ""
                        ),
                    }
            except Exception:
                pass
            break

    # ── Derive suggested file name ───────────────────────────────────────────
    safe_title = re.sub(r"[^a-zA-Z0-9\-]", "-", work_item["title"])
    safe_title = re.sub(r"-+", "-", safe_title).strip("-").lower()
    suggested_file_name = f"{work_item_id}_{safe_title}.feature"

    return {
        "work_item": work_item,
        "parent_feature": parent_feature,
        "existing_test_cases": test_cases,
        "existing_test_cases_count": len(test_cases),
        "suggested_file_name": suggested_file_name,
        "scope": "work_item",
    }


# ---------------------------------------------------------------------------
# attach_work_item_file  (NEW — attach .feature directly to PBI/Bug)
# ---------------------------------------------------------------------------

def attach_work_item_file(
    org_url: str,
    project: str,
    work_item_id: int,
    work_item_title: str,
    gherkin_content: str,
) -> dict:
    """
    Validate, upload, and attach a scoped .feature file to a PBI or Bug
    work item in ADO.

    Validation runs first — if the file fails structural checks the attachment
    is NOT uploaded and the errors are returned so the LLM can correct them
    before retrying.

    File is named: {work_item_id}_{title_kebab}.feature
    """
    # ── Structural validation gate ───────────────────────────────────────────
    validation = validate_gherkin_content(gherkin_content, scope="work_item")
    if not validation["valid"]:
        return {
            "success": False,
            "reason": "validation_failed",
            "validation": validation,
            "message": (
                "The .feature file was NOT uploaded to ADO. Fix the errors "
                "below and call attach_work_item_file again with corrected content."
            ),
        }

    org_url = org_url.rstrip("/")

    safe_title = re.sub(r"[^a-zA-Z0-9\-]", "-", work_item_title)
    safe_title = re.sub(r"-+", "-", safe_title).strip("-").lower()
    file_name = f"{work_item_id}_{safe_title}.feature"

    # ── Upload to attachment store ───────────────────────────────────────────
    upload_resp = requests.post(
        f"{org_url}/{project}/_apis/wit/attachments",
        headers=get_auth_headers("application/octet-stream"),
        params={"fileName": file_name, "api-version": _API},
        data=gherkin_content.encode("utf-8"),
        timeout=30,
    )
    upload_resp.raise_for_status()
    attachment_url = upload_resp.json()["url"]

    # ── Link to work item ────────────────────────────────────────────────────
    link_resp = requests.patch(
        f"{org_url}/{project}/_apis/wit/workitems/{work_item_id}",
        headers=get_auth_headers("application/json-patch+json"),
        params={"api-version": _API},
        json=[{
            "op": "add",
            "path": "/relations/-",
            "value": {
                "rel": "AttachedFile",
                "url": attachment_url,
                "attributes": {
                    "comment": (
                        "BDD regression feature file (work-item scope) — "
                        "generated by QA Gherkin Generator"
                    )
                },
            },
        }],
        timeout=30,
    )
    link_resp.raise_for_status()

    return {
        "success": True,
        "work_item_id": work_item_id,
        "file_name": file_name,
        "attachment_url": attachment_url,
        "warnings": validation["warnings"],
    }


# ─────────────────────────────────────────────────────────────────────────────
# INTERNAL HELPER — used by fetch_feature_hierarchy
# ─────────────────────────────────────────────────────────────────────────────

def _fetch_feature_data(org_url: str, project: str, feature_id: int) -> dict:
    """
    Core logic: fetch one Feature + its child PBIs/Bugs + their test cases.
    Returns the same shape as the old fetch_feature_hierarchy body.
    Called by fetch_feature_hierarchy.
    """
    org_url = org_url.rstrip("/")
    headers = get_auth_headers()

    # ── Fetch the Feature work item ──────────────────────────────────────────
    resp = requests.get(
        f"{org_url}/{project}/_apis/wit/workitems/{feature_id}",
        headers=headers,
        params={"$expand": "relations", "api-version": _API},
        timeout=30,
    )
    resp.raise_for_status()
    data = resp.json()
    fields = data.get("fields", {})

    feature = {
        "id": data["id"],
        "type": fields.get("System.WorkItemType", ""),
        "title": fields.get("System.Title", ""),
        "description": _strip_html(fields.get("System.Description", "") or ""),
        "acceptance_criteria": _strip_html(
            fields.get("Microsoft.VSTS.Common.AcceptanceCriteria", "") or ""
        ),
        "state": fields.get("System.State", ""),
    }

    # ── Fetch child PBIs / Bugs ──────────────────────────────────────────────
    children: list[dict] = []
    child_tc_map: dict[int, list[int]] = {}

    for rel in data.get("relations", []):
        if rel.get("rel") != "System.LinkTypes.Hierarchy-Forward":
            continue
        child_url = rel["url"]
        child_id = int(child_url.split("/")[-1])
        try:
            cr = requests.get(
                f"{child_url}?$expand=relations&api-version={_API}",
                headers=get_auth_headers(),
                timeout=30,
            )
            if not cr.ok:
                continue
            cr_data = cr.json()
            cf = cr_data.get("fields", {})
            child_type = cf.get("System.WorkItemType", "")
            if child_type not in _PBI_TYPES:
                continue

            children.append({
                "id": child_id,
                "type": child_type,
                "title": cf.get("System.Title", ""),
                "description": _strip_html(cf.get("System.Description", "") or ""),
                "acceptance_criteria": _strip_html(
                    cf.get("Microsoft.VSTS.Common.AcceptanceCriteria", "") or ""
                ),
                "state": cf.get("System.State", ""),
            })

            child_tc_map[child_id] = [
                int(r["url"].split("/")[-1])
                for r in cr_data.get("relations", [])
                if r.get("rel") == "Microsoft.VSTS.Common.TestedBy-Forward"
            ]
        except Exception:
            continue

    # ── Fetch test cases with full steps ─────────────────────────────────────
    all_tc_ids = list({tc_id for ids in child_tc_map.values() for tc_id in ids})
    test_cases = _fetch_test_cases(org_url, project, all_tc_ids)

    return {
        "feature": feature,
        "child_work_items": children,
        "child_work_items_count": len(children),
        "existing_test_cases": test_cases,
        "existing_test_cases_count": len(test_cases),
    }


# ─────────────────────────────────────────────────────────────────────────────
# PUBLIC TOOL — Feature entry point (unchanged contract)
# ─────────────────────────────────────────────────────────────────────────────

def fetch_feature_hierarchy(org_url: str, project: str, feature_id: int) -> dict:
    """
    Fetch a Feature work item with all child PBIs/Bugs and their linked test
    cases (including full test steps). Also returns a Gherkin best-practice
    reference example.
    """
    result = _fetch_feature_data(org_url, project, feature_id)
    return {**result, "scope": "feature"}


# ---------------------------------------------------------------------------
# attach_feature_file  (extended with validation gate)
# ---------------------------------------------------------------------------

def attach_feature_file(
    org_url: str,
    project: str,
    feature_id: int,
    feature_title: str,
    gherkin_content: str,
) -> dict:
    """
    Validate, upload, and attach a .feature file to a Feature work item.

    Validation runs first — if the file fails structural checks the attachment
    is NOT uploaded and the errors are returned so the LLM can correct them
    before retrying.
    """
    # ── Structural validation gate ───────────────────────────────────────────
    validation = validate_gherkin_content(gherkin_content, scope="feature")
    if not validation["valid"]:
        return {
            "success": False,
            "reason": "validation_failed",
            "validation": validation,
            "message": (
                "The .feature file was NOT uploaded to ADO. Fix the errors "
                "below and call attach_feature_file again with corrected content."
            ),
        }

    org_url = org_url.rstrip("/")

    safe_title = re.sub(r"[^a-zA-Z0-9_\-]", "_", feature_title)
    safe_title = re.sub(r"_+", "_", safe_title).strip("_")
    file_name = f"{feature_id}_{safe_title}_regression.feature"

    # ── Upload to attachment store ───────────────────────────────────────────
    upload_resp = requests.post(
        f"{org_url}/{project}/_apis/wit/attachments",
        headers=get_auth_headers("application/octet-stream"),
        params={"fileName": file_name, "api-version": _API},
        data=gherkin_content.encode("utf-8"),
        timeout=30,
    )
    upload_resp.raise_for_status()
    attachment_url = upload_resp.json()["url"]

    # ── Link to work item ────────────────────────────────────────────────────
    link_resp = requests.patch(
        f"{org_url}/{project}/_apis/wit/workitems/{feature_id}",
        headers=get_auth_headers("application/json-patch+json"),
        params={"api-version": _API},
        json=[{
            "op": "add",
            "path": "/relations/-",
            "value": {
                "rel": "AttachedFile",
                "url": attachment_url,
                "attributes": {
                    "comment": (
                        "BDD regression feature file — generated by QA Gherkin Generator"
                    )
                },
            },
        }],
        timeout=30,
    )
    link_resp.raise_for_status()

    return {
        "success": True,
        "feature_id": feature_id,
        "file_name": file_name,
        "attachment_url": attachment_url,
        "warnings": validation["warnings"],
    }


# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------

def _fetch_test_cases(org_url: str, project: str, tc_ids: List[int]) -> list:
    results = []
    for tc_id in tc_ids:
        try:
            resp = requests.get(
                f"{org_url}/{project}/_apis/wit/workitems/{tc_id}",
                headers=get_auth_headers(),
                params={"api-version": _API},
                timeout=30,
            )
            if not resp.ok:
                continue
            f = resp.json().get("fields", {})
            results.append({
                "id": tc_id,
                "title": f.get("System.Title", ""),
            })
        except Exception:
            continue
    return results


def _parse_steps_xml(xml_str: str) -> list:
    if not xml_str:
        return []
    steps = []
    try:
        root = ET.fromstring(xml_str)
        for step in root.findall(".//step"):
            params = step.findall("parameterizedString")
            action = params[0].text or "" if params else ""
            expected = params[1].text or "" if len(params) > 1 else ""
            steps.append({"action": action, "expected_result": expected})
    except Exception:
        pass
    return steps


def _strip_html(html: str) -> str:
    if not html:
        return ""
    text = re.sub(r"<br\s*/?>", "\n", html, flags=re.IGNORECASE)
    text = re.sub(r"</p>|</li>", "\n", text, flags=re.IGNORECASE)
    text = re.sub(r"<li[^>]*>", "• ", text, flags=re.IGNORECASE)
    text = re.sub(r"<[^>]+>", "", text)
    for old, new in [
        ("&nbsp;", " "), ("&amp;", "&"), ("&lt;", "<"),
        ("&gt;", ">"), ("&quot;", '"'),
    ]:
        text = text.replace(old, new)
    return re.sub(r"\n{3,}", "\n\n", text).strip()


# ---------------------------------------------------------------------------
# Gherkin best-practice reference (embedded — no file dependency)
# ---------------------------------------------------------------------------

_GHERKIN_EXAMPLE = """\
# Best-practice Gherkin reference — follow this style exactly.
#
# Rules:
#   • Feature title matches the ADO Feature work item title
#   • Tags: @smoke for critical happy paths, @regression for all others
#   • Background only when 2+ scenarios share the exact same preconditions
#   • Scenario titles: verb phrase describing the user action and outcome
#   • Steps: Given = state, When = action, Then = observable outcome
#   • No "And" at the start of a scenario — use Given/When/Then
#   • One Then assertion per scenario where possible
#   • Scenario Outline + Examples for data-driven boundary tests
#
# Scenario count:
#   • Feature-scoped file: 5–10 scenarios
#   • Work-item-scoped file (PBI or Bug): 3–9 scenarios

@user_profile @regression
Feature: User Profile Photo Management
  As a registered user
  I want to manage my profile photo
  So that my account accurately reflects my identity

  Background:
    Given the user is authenticated with their Microsoft account
    And the user is on the My Profile slide-out

  @smoke
  Scenario: User uploads a valid profile photo and sees confirmation
    When the user clicks "Update Photo" and selects a valid JPG under 5 MB
    And the user adjusts the crop area and clicks Save
    Then the profile photo is updated and a success toast is displayed
    And the new photo is visible in the application header

  @regression
  Scenario: Upload is rejected when file exceeds the maximum size
    When the user attempts to upload a file of 5.1 MB
    Then an error message "File size exceeds 5 MB limit" is displayed
    And no upload request is sent to the server

  @regression
  Scenario Outline: Upload is rejected for unsupported file formats
    When the user attempts to upload a "<format>" file
    Then an error message indicating the unsupported format is displayed

    Examples:
      | format |
      | BMP    |
      | TIFF   |
      | EXE    |

  @regression
  Scenario: Profile photo persists after page refresh
    Given the user has successfully updated their profile photo
    When the user refreshes the page
    Then the updated profile photo is still displayed

  @regression
  Scenario: Initials are shown as fallback when no photo is uploaded
    Given the user has not uploaded a profile photo
    When the user views any page showing their avatar
    Then their initials are displayed in place of a photo

  @regression
  Scenario: Cancelling the crop editor discards changes
    Given the user has opened the photo crop editor and made adjustments
    When the user clicks Cancel
    Then no changes are saved and the previous photo is displayed
"""

# ---------------------------------------------------------------------------
# Public aliases — satisfy the test contract and the skill routing rule.
#
# The MCP server imports attach_work_item_file (the canonical name).
# Test suite and skill documentation reference the more descriptive names below.
# Both names point to the same implementation — no duplication of logic.
# ---------------------------------------------------------------------------

def _gherkin_filename_for_work_item(work_item_id: int, title: str) -> str:
    """
    Build a safe, kebab-case filename for a PBI/Bug-scoped feature file.

    Convention: {work_item_id}_{title_kebab}.feature

    Examples:
        42, "User can Upload a Profile Photo"
            -> "42_user-can-upload-a-profile-photo.feature"
        99, "Fix: Bug #123 — crash on login!"
            -> "99_fix-bug-123-crash-on-login.feature"
    """
    kebab = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
    return f"{work_item_id}_{kebab}.feature"


#: Descriptive alias for attach_work_item_file.
#: Imported by tests and referenced in the generate-gherkin.md skill routing rule.
attach_gherkin_to_work_item = attach_work_item_file