"""Push generated Playwright test cases to Maeris as manual tests."""

from __future__ import annotations

import json
from typing import Any

import structlog
from mcp.types import TextContent, Tool

from maeris_mcp.auth.token_store import TokenStore
from maeris_mcp.maeris.client import AuthenticationError, MaerisClient
from maeris_mcp.storage.execution_store import TestExecutionStore
from maeris_mcp.storage.test_store import TestGenerationStore

logger = structlog.get_logger()


def push_tests_to_maeris_tool() -> Tool:
    """Return the tool definition for push_tests_to_maeris."""
    return Tool(
        name="push_tests_to_maeris",
        description=(
            "Push generated Playwright test cases to Maeris as manual tests via "
            "POST /tests/manual-create. Uses the generation session to build the payload. "
            "When exec_session_id is provided, only tests that PASSED execution are pushed.\n\n"
            "If the user specified a folder name, pass it as test_folder. "
            "If the user said 'new folder', create one named after the flow. "
            "If no folder is specified, ask the user which folder to use."
        ),
        inputSchema={
            "type": "object",
            "required": ["session_id"],
            "properties": {
                "session_id": {
                    "type": "string",
                    "description": "Test generation session ID from generate_tests.",
                },
                "exec_session_id": {
                    "type": "string",
                    "description": (
                        "Optional execution session ID from run_and_fix_tests. "
                        "When provided, only tests that PASSED execution are pushed."
                    ),
                },
                "base_url": {
                    "type": "string",
                    "description": "Optional override for the Maeris API base URL.",
                },
                "test_folder": {
                    "type": "string",
                    "description": (
                        "Folder name or testcase_group_id to save tests in. "
                        "If omitted, a folder named after the flow will be created automatically."
                    ),
                },
                "testcase_type": {
                    "type": "string",
                    "description": "Optional override for testcase_type (applies to all tests).",
                },
                "labels": {
                    "type": "array",
                    "description": (
                        "Optional labels to add to all tests (strings or {tag_name: ...} objects)."
                    ),
                    "items": {},
                },
                "browser": {
                    "type": "array",
                    "description": "Optional list of browsers to set (default: ['chrome']).",
                    "items": {"type": "string"},
                },
                "mark_as": {
                    "type": "string",
                    "description": "Optional mark_as value for all tests (default: 'pass').",
                },
                "page_name": {
                    "type": "string",
                    "description": "Optional page name to attach to steps (default: flow description).",
                },
            },
        },
    )


_KEY_INTENTS: dict[str, str] = {
    "enter": "Press Enter to confirm",
    "return": "Press Enter to confirm",
    "tab": "Press Tab to move to next field",
    "escape": "Press Escape to cancel",
    "esc": "Press Escape to cancel",
    "space": "Press Space to toggle",
    "backspace": "Press Backspace to delete",
}

def _friendly_target(sel: str) -> str:
    """Turn a raw CSS/Playwright selector into a short human-readable label.

    Examples:
      text=Login          → 'Login'
      #email              → 'email'
      button[type='submit'] → submit button
      a[href='/signup']   → 'Sign Up' link  (fallback: '/signup' link)
      [name="password"]   → 'password' field
      input[name='name']  → 'Name' field
    """
    import re
    if not sel:
        return ""
    # text=... selectors — use the text directly
    m = re.match(r'^text[=]"?([^"]+)"?$', sel, re.IGNORECASE)
    if m:
        return f"'{m.group(1)}'"
    # [data-testid="value"] or [data-testid='value'] → 'value'
    m = re.search(r"""\[data-testid[=]["']([^"']+)["']\]""", sel)
    if m:
        return f"'{m.group(1).replace('-', ' ')}'"
    # [aria-label="value"] or [aria-label='value'] → 'value'
    m = re.search(r"""\[aria-label[=]["']([^"']+)["']\]""", sel)
    if m:
        return f"'{m.group(1)}'"
    # [name="value"] or [name='value'] → 'Value' field
    m = re.search(r"""\[name[=]["']([^"']+)["']\]""", sel)
    if m:
        # Capitalize and humanize the name attribute value
        raw = m.group(1)
        # camelCase → space-separated words (e.g. saveAddressAs → Save Address As)
        label = re.sub(r'([a-z])([A-Z])', r'\1 \2', raw)
        label = label.replace('_', ' ').replace('-', ' ').title()
        return f"'{label}' field"
    # [placeholder="value"] or [placeholder='value'] → 'value' field
    m = re.search(r"""\[placeholder[=]["']([^"']+)["']\]""", sel)
    if m:
        return f"'{m.group(1)}' field"
    # #id → 'id'
    m = re.match(r'^#([\w-]+)$', sel)
    if m:
        return f"'{m.group(1).replace('-', ' ')}'"
    # input[type="email"] or input[type='email'] → email input
    m = re.search(r'input\[type[=]["\']([^"\']+)["\']\]', sel)
    if m:
        return f"{m.group(1)} input"
    # button[type="submit"] or button[type='submit'] → submit button
    m = re.search(r'button\[type[=]["\']([^"\']+)["\']\]', sel)
    if m:
        return f"{m.group(1)} button"
    # a[href='/path'] → 'path' link
    m = re.search(r'a\[href[=]["\']([^"\']+)["\']\]', sel)
    if m:
        return f"'{m.group(1)}' link"
    # role=button[name="X"] or role=button[name='X'] → 'X' button
    m = re.search(r"""role=(\w+)\[name=["']([^"']+)["']\]""", sel)
    if m:
        return f"'{m.group(2)}' {m.group(1)}"
    # Generic element[name='value'] (e.g. input[name='city']) → 'City' field
    m = re.search(r"""\w+\[name[=]["']([^"']+)["']\]""", sel)
    if m:
        raw = m.group(1)
        label = re.sub(r'([a-z])([A-Z])', r'\1 \2', raw)
        label = label.replace('_', ' ').replace('-', ' ').title()
        return f"'{label}' field"
    # Fallback: return shortened selector in quotes
    if len(sel) > 40:
        return f"'{sel[:37]}...'"
    return f"'{sel}'"


def _path_to_page_name(url: str) -> str:
    """Convert a URL or path to a short human-readable page name.

    Examples:
      /login              → 'login page'
      /register           → 'registration page'
      /products/new       → 'new product form'
      /products/5/edit    → 'edit product form'
      /dashboard          → 'dashboard'
      /                   → 'home page'
    """
    from urllib.parse import urlparse

    try:
        path = urlparse(url).path or "/"
    except Exception:
        path = url or "/"

    parts = [p for p in path.split("/") if p]

    _SEGMENT_MAP: dict[str, str] = {
        "login": "login page",
        "signin": "login page",
        "sign-in": "login page",
        "register": "registration page",
        "signup": "registration page",
        "sign-up": "registration page",
        "dashboard": "dashboard",
        "home": "home page",
        "checkout": "checkout page",
        "cart": "shopping cart",
        "basket": "shopping cart",
        "profile": "profile page",
        "account": "account page",
        "settings": "settings page",
        "preferences": "preferences page",
        "forgot-password": "password reset page",
        "reset-password": "password reset page",
        "forgot_password": "password reset page",
        "reset_password": "password reset page",
        "search": "search results page",
    }

    if not parts:
        return "home page"

    last = parts[-1].lower()

    # /resources/new → "new resource form"
    if last in ("new", "create") and len(parts) >= 2:
        parent = parts[-2].rstrip("s").replace("-", " ").replace("_", " ")
        return f"new {parent} form"

    # /resources/:id/edit → "edit resource form"
    if last in ("edit", "update") and len(parts) >= 3:
        parent = parts[-3].rstrip("s").replace("-", " ").replace("_", " ")
        return f"edit {parent} form"

    if last in _SEGMENT_MAP:
        return _SEGMENT_MAP[last]

    # Humanize the last segment
    humanized = last.replace("-", " ").replace("_", " ")
    return f"{humanized} page"


def _semantic_click_name(target: str, selector: str) -> str:
    """Generate a meaningful step name for a click action.

    Detects common button/link intents from the target label and selector
    to produce purpose-driven names rather than raw 'Click on X' descriptions.
    """
    combined = f"{target} {selector}".lower()

    _INTENTS: list[tuple[list[str], str]] = [
        (["submit"], "Submit the form"),
        (["save changes"], "Save the changes"),
        (["save"], "Save the changes"),
        (["log in", "login", "sign in", "signin"], "Click Sign In"),
        (["register", "sign up", "signup", "create account", "get started"], "Click Register"),
        (["delete", "remove", "trash"], "Click Delete"),
        (["cancel"], "Click Cancel"),
        (["close", "dismiss", "×", "✕"], "Close the dialog"),
        (["add to cart", "add to bag"], "Add item to cart"),
        (["checkout", "place order"], "Proceed to checkout"),
        (["continue", "next", "proceed"], "Click Continue"),
        (["search", "find"], "Click Search"),
        (["edit", "modify"], "Click Edit"),
        (["add", "create", "+ new"], "Click Add"),
        (["confirm", "ok", "yes", "agree"], "Confirm the action"),
        (["upload", "attach", "browse"], "Open the file picker"),
    ]

    for keywords, name in _INTENTS:
        if any(kw in combined for kw in keywords):
            return name

    return f"Click the {target}" if target else "Click the element"


def _fill_field_name(target: str, selector: str, value: str) -> str:
    """Generate a meaningful step name for a send_keys (fill) action.

    Uses the field name from the selector or target label to describe the
    purpose of the input rather than exposing raw selector syntax.
    """
    sel_lower = (selector or "").lower()
    target_clean = (target or "").lower().strip("' ")

    _FIELD_MAP: dict[str, str] = {
        "confirm-password": "Confirm the password",
        "confirm_password": "Confirm the password",
        "confirmpassword": "Confirm the password",
        "new-password": "Enter the new password",
        "new_password": "Enter the new password",
        "newpassword": "Enter the new password",
        "current-password": "Enter the current password",
        "current_password": "Enter the current password",
        "email": "Enter the email address",
        "password": "Enter the password",
        "username": "Enter the username",
        "user_name": "Enter the username",
        "user-name": "Enter the username",
        "firstname": "Enter the first name",
        "first_name": "Enter the first name",
        "first-name": "Enter the first name",
        "lastname": "Enter the last name",
        "last_name": "Enter the last name",
        "last-name": "Enter the last name",
        "fullname": "Enter the full name",
        "full_name": "Enter the full name",
        "full-name": "Enter the full name",
        "name": "Enter the name",
        "phone": "Enter the phone number",
        "mobile": "Enter the mobile number",
        "tel": "Enter the phone number",
        "address": "Enter the address",
        "street": "Enter the street address",
        "city": "Enter the city",
        "state": "Enter the state",
        "zip": "Enter the ZIP code",
        "zipcode": "Enter the ZIP code",
        "postal": "Enter the postal code",
        "country": "Enter the country",
        "search": "Type the search query",
        "query": "Type the search query",
        "message": "Enter the message",
        "comment": "Enter the comment",
        "description": "Enter the description",
        "title": "Enter the title",
        "subject": "Enter the subject",
        "url": "Enter the URL",
        "website": "Enter the website URL",
        "amount": "Enter the amount",
        "price": "Enter the price",
        "quantity": "Enter the quantity",
        "coupon": "Enter the coupon code",
        "promo": "Enter the promo code",
        "code": "Enter the code",
        "otp": "Enter the one-time code",
        "token": "Enter the token",
    }

    # Longer/more specific keys must be checked before shorter ones — map is
    # insertion-ordered so specific entries (e.g. confirm_password) appear first.
    for field_key, label in _FIELD_MAP.items():
        if field_key in sel_lower or field_key == target_clean:
            return label

    if target:
        return f"Fill in the {target}"
    return "Fill in the field"


import re as _re_module

# ---------------------------------------------------------------------------
# CSS Selector validation
# ---------------------------------------------------------------------------
# HTML attributes that are typically short programmatic identifiers, not
# human-readable display text.  When an LLM writes e.g. button[name='Sign in'],
# it almost certainly confused a Playwright API parameter (accessible name)
# with the HTML attribute.  This map defines, per attribute, the max token
# count a legitimate value would normally have.  Values with MORE tokens are
# flagged as likely display text mistakenly used as an attribute selector.
_ATTR_MAX_WORD_COUNT: dict[str, int] = {
    "name": 3,        # HTML name attrs are short identifiers (e.g. "email", "user_name")
    "id": 3,          # IDs are short identifiers
    "class": 4,       # individual class names are short
    "type": 2,        # e.g. "submit", "button", "text"
    "role": 2,        # ARIA roles are single words
    "for": 3,         # label for= targets an id
}


def _validate_css_selector(selector: str, action: str = "") -> list[str]:
    """Validate a CSS selector and return a list of warning messages.

    Designed to catch the general class of errors where LLMs confuse
    Playwright API parameters (accessible names, text content) with actual
    HTML attributes in CSS selectors.  The checks are attribute-agnostic —
    they flag ANY attribute whose value looks like display text rather than
    a programmatic identifier.
    """
    warnings: list[str] = []
    sel = selector.strip()
    if not sel:
        return warnings

    # Find all attribute selectors: [attr='value'] or [attr="value"]
    for match in _re_module.finditer(r"""\[([a-zA-Z_-]+)=['"]([^'"]+)['"]\]""", sel):
        attr = match.group(1).lower()
        value = match.group(2)

        # Skip attributes that commonly hold long human-readable values
        if attr in ("placeholder", "aria-label", "title", "alt", "data-testid",
                     "data-automation-id", "data-onboarding", "href", "src", "action",
                     "value", "content"):
            continue

        max_words = _ATTR_MAX_WORD_COUNT.get(attr)
        if max_words is None:
            # Unknown attribute — skip (could be a custom data-* attr with long values)
            if attr.startswith("data-"):
                continue
            # For non-data attributes not in our map, apply a generous default
            max_words = 4

        word_count = len(value.split())
        if word_count > max_words:
            warnings.append(
                f"[{attr}='{value}'] looks like display text ({word_count} words), "
                f"not a valid HTML '{attr}' attribute value.  HTML '{attr}' attributes "
                f"are typically short programmatic identifiers.  This may be a "
                f"Playwright API parameter (e.g. accessible name) mistakenly used "
                f"as a CSS attribute selector."
            )

    return warnings


def _slugify(value: str) -> str:
    """Convert a string to a slug matching playwright_generator.py naming."""
    cleaned = "".join(ch.lower() if ch.isalnum() or ch.isspace() else "_" for ch in value)
    cleaned = "_".join([p for p in cleaned.split() if p])
    return cleaned or "test"


def _is_local_url(url: str) -> bool:
    """Check if a URL points to a local development server."""
    from urllib.parse import urlparse

    try:
        host = urlparse(url).hostname or ""
    except Exception:
        return False
    return host in ("localhost", "127.0.0.1", "0.0.0.0", "::1")


def _discover_app_url(scan_root: str) -> str | None:
    """Auto-detect the deployed application URL from repo config files.

    Searches common config locations (.env, Dockerfile, next.config.js, etc.)
    for URL/NEXTAUTH_URL/BASE_URL/SITE_URL environment variables that contain
    a non-localhost http(s) URL.
    """
    import os
    import re

    # Patterns to match URL assignments in config files
    # Matches: URL="https://...", NEXTAUTH_URL=https://..., BASE_URL='https://...'
    url_pattern = re.compile(
        r"""(?:NEXTAUTH_URL|(?:APP_|SITE_|BASE_|PUBLIC_|NEXT_PUBLIC_(?:BASE_|SITE_|APP_)?)?URL)"""
        r"""[\s]*=[\s]*["']?(https?://[^\s"']+)["']?""",
        re.IGNORECASE,
    )

    # Config files to check, in priority order
    candidates = [
        ".env.production",
        ".env.production.local",
        ".env",
        ".env.local",
        "Dockerfile",
        "docker-compose.yml",
        "docker-compose.yaml",
        "vercel.json",
        "netlify.toml",
    ]

    for filename in candidates:
        filepath = os.path.join(scan_root, filename)
        if not os.path.isfile(filepath):
            continue
        try:
            with open(filepath, encoding="utf-8", errors="ignore") as f:
                for line in f:
                    match = url_pattern.search(line)
                    if match:
                        found = match.group(1).rstrip("/")
                        if not _is_local_url(found):
                            return found
        except OSError:
            continue

    return None


def _normalize_page_url(url: str | None, base_url: str | None) -> str | None:
    if not url:
        return None
    # If the URL itself is a localhost URL, extract the path and prepend the
    # real base_url so Maeris always stores a full production URL.
    if _is_local_url(url):
        from urllib.parse import urlparse

        path = urlparse(url).path or "/"
        if base_url and not _is_local_url(base_url):
            return base_url.rstrip("/") + path
        return path
    if base_url and url.startswith("/"):
        return base_url.rstrip("/") + url
    return url


def _labels_contains(labels: list[Any], value: str) -> bool:
    value_lower = value.lower()
    for label in labels:
        if isinstance(label, str) and label.lower() == value_lower:
            return True
        if isinstance(label, dict):
            tag_name = label.get("tag_name") or label.get("tagName")
            if isinstance(tag_name, str) and tag_name.lower() == value_lower:
                return True
    return False



import re as _re_cred

_EMAIL_SELECTOR_HINTS = ('[type="email"]', "[type='email']", 'input[type="email"]', '[name="email"]', '[name="username"]')
_PASSWORD_SELECTOR_HINTS = ('[type="password"]', "[type='password']", 'input[type="password"]', '[name="password"]')

_NEGATIVE_TEST_RE = _re_cred.compile(
    r"invalid|wrong|incorrect|bad.cred|fail|nonexistent|non.exist|fake|bogus",
    _re_cred.IGNORECASE,
)


def _is_negative_test_name(name: str) -> bool:
    return bool(_NEGATIVE_TEST_RE.search(name))


def _inject_mock_input(step: dict, test_email: str, test_password: str) -> dict:
    """Return a copy of the step with mock_input substituted if this is a credential field."""
    action = step.get("action_taken") or step.get("action") or ""
    if action != "send_keys":
        return step
    sel = (step.get("css_selector") or step.get("selector") or "").lower()
    desc = (step.get("description") or "").lower()
    is_email_field = any(hint.lower() in sel for hint in _EMAIL_SELECTOR_HINTS) or "email" in desc or "username" in desc
    is_password_field = any(hint.lower() in sel for hint in _PASSWORD_SELECTOR_HINTS) or "password" in desc
    if test_email and is_email_field and not is_password_field:
        return {**step, "mock_input": test_email}
    if test_password and is_password_field:
        return {**step, "mock_input": test_password}
    return step


def _build_steps_from_mirabilis_steps(
    steps: list[dict],
    flow_name: str,
    app_base_url: str | None,
    page_name_override: str | None,
    test_email: str = "",
    test_password: str = "",
    is_negative_test: bool = False,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
    """Convert Mirabilis component step dicts directly to Maeris API step payloads.

    Action steps  → {"name", "actionType", "cssSelector", "mockInput", …}
    Assertion steps → {"type": "verification", "name", "assertion_nature", …}

    Steps are already in Mirabilis format — no field translation is needed.

    Returns a tuple of (built_steps, dropped_steps) where dropped_steps is a list
    of dicts describing each skipped step and why it was dropped.
    """
    current_url: str | None = None
    page_name = page_name_override or flow_name
    result: list[dict[str, Any]] = []
    dropped: list[dict[str, Any]] = []

    for step_index, step in enumerate(steps):
        if not isinstance(step, dict):
            continue

        # Inject real credentials into send_keys steps before processing,
        # but only for positive tests — negative tests keep their intentionally wrong values.
        if (test_email or test_password) and not is_negative_test:
            step = _inject_mock_input(step, test_email, test_password)

        description = (step.get("description") or "").strip()
        # Normalize field name aliases — the LLM sometimes submits steps with
        # "action"/"selector" instead of "action_taken"/"css_selector".
        css_selector = step.get("css_selector") or step.get("selector") or ""
        action = step.get("action_taken") or step.get("action")
        assertion_nature = step.get("assertion_nature")

        # ── Assertion / verification step ───────────────────────────────────
        if assertion_nature:
            assertion_type = step.get("assertion_type") or ""
            assertion_value = step.get("assertion_value")

            # Normalize URL assertion values
            if assertion_nature == "page" and assertion_type in ("to have url", "to contain url") and assertion_value:
                assertion_value = _normalize_page_url(assertion_value, app_base_url)
                if assertion_value and assertion_value.startswith("/") and assertion_type == "to have url":
                    assertion_type = "to contain url"

            # Skip empty URL assertions
            if assertion_nature == "page" and assertion_type in ("to have url", "to contain url") and not assertion_value:
                continue

            # Build a concise verification name
            if assertion_value and assertion_nature == "existence":
                ver_name = description or f"Verify '{assertion_value}' {assertion_type}"
            elif assertion_value and assertion_nature == "page":
                ver_name = description or f"Verify page {assertion_type}"
            else:
                ver_name = description or f"Verify {assertion_nature} {assertion_type}"

            payload: dict[str, Any] = {
                "type": "verification",
                "name": ver_name,
                "assertion_nature": assertion_nature,
                "assertion_type": assertion_type,
                "cssSelector": css_selector,
            }
            if assertion_value is not None:
                payload["assertion_value"] = assertion_value
            if assertion_nature != "page" and current_url:
                payload["page_url"] = current_url
            if page_name:
                payload["page_name"] = page_name
            result.append(payload)
            continue

        # ── Action step ─────────────────────────────────────────────────────
        if not action:
            dropped.append({
                "testName": flow_name,
                "stepIndex": step_index,
                "reason": "missing action_taken — step has no action and no assertion_nature",
                "actionTaken": None,
            })
            continue

        # Translate common LLM synonym action names to supported Maeris equivalents.
        # The LLM sometimes uses Playwright-style or natural-language action names.
        _ACTION_SYNONYMS: dict[str, str] = {
            "navigate":           "go_to",
            "goto":               "go_to",
            "open":               "go_to",
            "fill":               "send_keys",
            "type":               "send_keys",
            "input":              "send_keys",
            "enter":              "send_keys",
            "clear":              "send_keys",   # send_keys with empty mock_input
            "tap":                "click",
            "press":              "click",
            "select":             "click_select",
        }
        if action in _ACTION_SYNONYMS:
            mapped = _ACTION_SYNONYMS[action]
            # For go_to: accept "value", "url", "href" as mock_input aliases
            if mapped == "go_to":
                url_val = step.get("mock_input") or step.get("value") or step.get("url") or step.get("href") or ""
                step = {**step, "action_taken": "go_to", "mock_input": url_val}
            elif mapped == "send_keys":
                text_val = step.get("mock_input") or step.get("value") or step.get("text") or ("" if action == "clear" else None)
                step = {**step, "action_taken": "send_keys", "mock_input": text_val if text_val is not None else ""}
            else:
                step = {**step, "action_taken": mapped}
            action = mapped

        # Convert Playwright-style assertion actions to Maeris assertion steps.
        # These will be re-evaluated at the top of the loop as assertion steps.
        _ASSERT_VISIBLE_ACTIONS = {"assert_visible", "assert_exists", "wait_for_selector", "waitforselector"}
        _ASSERT_NOT_VISIBLE_ACTIONS = {"assert_not_visible", "assert_hidden", "assert_not_exists"}
        _ASSERT_TEXT_ACTIONS = {"assert_text", "assert_contains", "assert_value"}
        _ASSERT_URL_ACTIONS = {"assert_url", "assert_url_contains", "assert_url_not_contains"}
        if action in _ASSERT_VISIBLE_ACTIONS:
            # Convert to existence or input assertion
            _anat = "input" if css_selector else "existence"
            _aval = step.get("assertion_value") or step.get("value") or step.get("text") or description
            step = {**step, "action_taken": None, "assertion_nature": _anat,
                    "assertion_type": "to be visible", "assertion_value": _aval}
            assertion_nature = _anat
            # Re-route to the assertion handler above by processing inline
            assertion_type = "to be visible"
            assertion_value = _aval
            ver_name = description or f"Verify visible"
            payload_a: dict[str, Any] = {
                "type": "verification", "name": ver_name,
                "assertion_nature": _anat, "assertion_type": assertion_type,
                "cssSelector": css_selector,
            }
            if assertion_value:
                payload_a["assertion_value"] = assertion_value
            if current_url:
                payload_a["page_url"] = current_url
            if page_name:
                payload_a["page_name"] = page_name
            result.append(payload_a)
            continue
        if action in _ASSERT_NOT_VISIBLE_ACTIONS:
            _anat2 = "input" if css_selector else "existence"
            _aval2 = step.get("assertion_value") or step.get("value") or description
            ver_name = description or "Verify not visible"
            payload_b: dict[str, Any] = {
                "type": "verification", "name": ver_name,
                "assertion_nature": _anat2, "assertion_type": "not to be visible",
                "cssSelector": css_selector,
            }
            if _aval2:
                payload_b["assertion_value"] = _aval2
            if current_url:
                payload_b["page_url"] = current_url
            result.append(payload_b)
            continue
        if action in _ASSERT_URL_ACTIONS:
            _aval3 = step.get("assertion_value") or step.get("value") or step.get("url") or ""
            _atype3 = "to contain url" if "not" not in action else "not to contain url"
            payload_c: dict[str, Any] = {
                "type": "verification", "name": description or "Verify URL",
                "assertion_nature": "page", "assertion_type": _atype3,
                "cssSelector": "", "assertion_value": _aval3,
            }
            result.append(payload_c)
            continue
        # Generic "assert" — try to infer from context
        if action in {"assert", "verify", "expect"}:
            _anat4 = "input" if css_selector else "existence"
            _aval4 = step.get("assertion_value") or step.get("value") or step.get("text")
            ver_name = description or "Verify"
            payload_d: dict[str, Any] = {
                "type": "verification", "name": ver_name,
                "assertion_nature": _anat4, "assertion_type": "to be visible",
                "cssSelector": css_selector,
            }
            if _aval4:
                payload_d["assertion_value"] = _aval4
            if current_url:
                payload_d["page_url"] = current_url
            result.append(payload_d)
            continue
        # Skip non-mappable wait/timing actions silently
        if action in {"wait", "sleep", "wait_for_timeout", "pause", "wait_for_load_state",
                      "wait_for_navigation", "wait_for_url"}:
            continue

        _SUPPORTED_ACTIONS = frozenset({"go_to", "click", "send_keys", "hover", "set_range", "check", "click_select"})
        if action not in _SUPPORTED_ACTIONS:
            logger.warning("unsupported action_taken skipped", action=action, step_description=description)
            dropped.append({
                "testName": flow_name,
                "stepIndex": step_index,
                "reason": f"unsupported action_taken: '{action}'",
                "actionTaken": action,
            })
            continue

        mock_input = step.get("mock_input")

        if action == "go_to":
            nav_url = mock_input or ""
            if nav_url:
                current_url = _normalize_page_url(nav_url, app_base_url)
            display_url = current_url or nav_url
            step_name = description or (f"Open {_path_to_page_name(display_url)}" if display_url else "Open page")
            payload = {
                "name": step_name,
                "actionType": "go_to",
                "cssSelector": display_url,
                "mockInput": display_url or None,
            }
        else:
            target = _friendly_target(css_selector)
            if not description:
                if action == "click":
                    description = _semantic_click_name(target, css_selector)
                elif action == "send_keys":
                    description = _fill_field_name(target, css_selector, mock_input or "")
                else:
                    description = f"{action} {target}" if target else action
            payload = {
                "name": description,
                "actionType": action,
                "cssSelector": css_selector,
            }
            if mock_input is not None:
                payload["mockInput"] = mock_input
            if current_url:
                payload["page_url"] = current_url

        if page_name:
            payload["page_name"] = page_name
        result.append(payload)

    return result, dropped


async def handle_push_tests_to_maeris(
    test_store: TestGenerationStore,
    exec_store: TestExecutionStore,
    arguments: dict,
) -> list[TextContent]:
    """Handle the push_tests_to_maeris tool call."""
    session_id = arguments.get("session_id") or arguments.get("sessionId")
    exec_session_id = arguments.get("exec_session_id") or arguments.get("execSessionId")
    base_url = arguments.get("base_url") or arguments.get("baseUrl")
    test_folder = arguments.get("test_folder") or arguments.get("testFolder")
    testcase_type_override = arguments.get("testcase_type") or arguments.get("testcaseType")
    labels_override = arguments.get("labels") or []
    browser_override = arguments.get("browser") or arguments.get("browsers") or []
    mark_as = arguments.get("mark_as") or arguments.get("markAs") or "pass"
    page_name_override = arguments.get("page_name") or arguments.get("pageName")

    if not session_id:
        return [TextContent(type="text", text="Error: session_id is required.")]

    gen_session = test_store.get_session(session_id)
    if not gen_session:
        return [TextContent(type="text", text=f"Error: generation session not found: {session_id}")]

    if not gen_session.test_cases:
        return [TextContent(type="text", text="Error: generation session has no test cases.")]

    token_store = TokenStore()
    if not token_store.load_token():
        return [TextContent(
            type="text",
            text="Error: not authenticated. Run 'maeris login' first.",
        )]
    if token_store.is_token_expired():
        return [TextContent(
            type="text",
            text="Error: your session has expired. Run 'maeris login' to re-authenticate.",
        )]

    stored_creds = token_store.get_token_data() or {}
    stored_app_id = stored_creds.get("app_id") or ""
    if not stored_app_id:
        return [TextContent(
            type="text",
            text="Error: missing application_id. Run 'maeris switch-app' to link an application.",
        )]

    passed_files: set[str] = set()
    exec_filter_active = False
    no_passed_warning = ""
    if exec_session_id:
        exec_session = exec_store.get_session(exec_session_id)
        if not exec_session:
            return [TextContent(
                type="text",
                text=f"Error: execution session not found: {exec_session_id}",
            )]
        passed_files = {r.test_file for r in exec_session.results if r.status == "passed"}
        if passed_files:
            exec_filter_active = True
        else:
            no_passed_warning = (
                "Warning: no tests passed execution — pushing all generated test cases."
            )

    # Pre-load fixed test steps from disk (written by run_and_fix_tests).
    # Prefer exec_session.output_dir when available; otherwise fall back to
    # gen_session.output_dir so fixes are picked up even without exec_session_id.
    fixed_steps_cache: dict[str, list] = {}
    _disk_output_dir: str | None = None
    if exec_session_id and exec_session:
        _disk_output_dir = exec_session.output_dir
    elif gen_session.output_dir:
        _disk_output_dir = gen_session.output_dir
    if _disk_output_dir:
        from pathlib import Path as _Path
        for _fpath in _Path(_disk_output_dir).glob("*.json"):
            try:
                _data = json.loads(_fpath.read_text(encoding="utf-8"))
                if isinstance(_data.get("steps"), list):
                    fixed_steps_cache[_fpath.name] = _data["steps"]
            except Exception:
                pass

    flow_name = gen_session.flow_description or "Flow"
    maeris_api_url = base_url  # explicit override for Maeris API; None = use DEFAULT_BASE_URL
    app_base_url = gen_session.base_url  # app's URL, used only for normalising step page_urls

    # Retrieve stored credentials from the generation session (set during run_and_fix_tests)
    _stored_creds = gen_session.test_credentials or {}
    _push_email = _stored_creds.get("email") or ""
    _push_password = _stored_creds.get("password") or ""

    # If the base URL is localhost, auto-detect the real app URL from repo config
    if not app_base_url or _is_local_url(app_base_url):
        discovered = _discover_app_url(gen_session.scan_root)
        if discovered:
            logger.info(
                "replaced local base_url with discovered app URL",
                local=app_base_url,
                discovered=discovered,
            )
            app_base_url = discovered

    if not test_folder:
        test_folder = _slugify(flow_name)

    if browser_override and isinstance(browser_override, str):
        browser_override = [browser_override]
    browsers = browser_override or ["chrome"]

    payloads: list[dict[str, Any]] = []
    parse_errors = 0
    parse_error_details: list[str] = []
    skipped = 0
    quality_skipped = 0
    quality_skip_details: list[str] = []
    all_dropped_steps: list[dict[str, Any]] = []

    for raw_tc in gen_session.test_cases:
        tc_name = raw_tc.get("name") or raw_tc.get("title") or "Unnamed Test"
        tc_description = raw_tc.get("description") or tc_name
        tc_priority = raw_tc.get("priority") or "medium"
        tc_tags = raw_tc.get("tags") or []
        test_file_key = f"{_slugify(tc_name)}.json"
        raw_steps = fixed_steps_cache.get(test_file_key) or raw_tc.get("steps") or []

        if not raw_steps:
            parse_errors += 1
            parse_error_details.append(f"{tc_name}: no steps found")
            logger.warning("test case skipped: no steps", test_name=tc_name)
            continue

        if exec_filter_active:
            if test_file_key not in passed_files:
                skipped += 1
                continue

        labels: list[Any] = []
        if isinstance(labels_override, list):
            labels.extend(labels_override)
        if isinstance(tc_tags, list):
            labels.extend(tc_tags)
        if tc_priority and not _labels_contains(labels, tc_priority):
            labels.append({"tag_name": tc_priority})

        # Infer testcase_type inline from tags and name
        _tags_lower = {t.lower() for t in tc_tags if isinstance(t, str)}
        _name_lower = tc_name.lower()
        if "smoke" in _tags_lower or "happy_path" in _tags_lower or tc_priority in ("critical", "high"):
            if (
                "negative" in _tags_lower or "validation" in _tags_lower or "error_path" in _tags_lower
                or "negative" in _name_lower or "invalid" in _name_lower or "error" in _name_lower
            ):
                testcase_type = "regression"
            else:
                testcase_type = "smoke"
        else:
            testcase_type = "regression"
        testcase_type = testcase_type_override or testcase_type

        steps_payload, tc_dropped = _build_steps_from_mirabilis_steps(
            raw_steps,
            tc_name,
            app_base_url,
            page_name_override,
            test_email=_push_email,
            test_password=_push_password,
            is_negative_test=_is_negative_test_name(tc_name),
        )
        if tc_dropped:
            all_dropped_steps.extend(tc_dropped)

        # Quality gate — enforce minimum step and assertion requirements.
        # Verification steps (type=="verification") are counted separately
        # and do NOT count toward the minimum action step threshold.
        action_step_count = sum(1 for s in steps_payload if s.get("type") != "verification")
        verification_count = sum(1 for s in steps_payload if s.get("type") == "verification")

        if verification_count == 0:
            quality_skipped += 1
            quality_skip_details.append(
                f"{tc_name}: no verification steps — assertions are missing or could not be mapped to Maeris"
            )
            logger.warning("test case skipped: no verifications", test_name=tc_name)
            continue

        if action_step_count < 2:
            quality_skipped += 1
            quality_skip_details.append(
                f"{tc_name}: only {action_step_count} action step(s) — minimum required is 2"
            )
            logger.warning(
                "test case skipped: insufficient action steps",
                test_name=tc_name,
                action_steps=action_step_count,
            )
            continue

        payloads.append({
            "name": tc_name,
            "description": tc_description,
            "testcase_type": testcase_type,
            "test_folder": test_folder,
            "labels": labels,
            "browser": browsers,
            "mark_as": mark_as,
            "steps": steps_payload,
        })

    if not payloads:
        reasons = []
        if parse_errors:
            reasons.append(f"{parse_errors} test case(s) failed validation")
        if skipped:
            reasons.append(f"{skipped} test case(s) skipped (not in passed_files)")
        if quality_skipped:
            reasons.append(
                f"{quality_skipped} test case(s) skipped (quality gate: missing verifications or fewer than 3 action steps)"
            )
        detail = "; ".join(reasons) if reasons else "no matching test cases found"
        msg = f"Error: no test cases to push after filtering/parsing ({detail})."
        if parse_error_details:
            msg += "\n\nValidation errors:\n" + "\n".join(
                f"  - {d}" for d in parse_error_details
            )
        if quality_skip_details:
            msg += "\n\nQuality gate failures:\n" + "\n".join(
                f"  - {d}" for d in quality_skip_details
            )
        return [TextContent(type="text", text=msg)]

    async with MaerisClient(base_url=maeris_api_url) as client:
        try:
            # Step 1: Resolve the folder — find existing group by name or create it
            folder_name = test_folder  # human-readable slug derived from flow name
            resolved_group_id = folder_name  # fallback: pass the slug as-is

            existing_groups = await client.get_testcase_groups()
            matched = next(
                (g for g in existing_groups if (g.get("name") or "").lower() == folder_name.lower()),
                None,
            )
            if matched:
                resolved_group_id = matched.get("testcase_group_id") or matched.get("id") or folder_name
                logger.info("reusing existing testcase group", id=resolved_group_id, name=folder_name)
            else:
                resolved_group_id = await client.create_testcase_group(folder_name)
                logger.info("created new testcase group", id=resolved_group_id, name=folder_name)

            # Step 2: Stamp every payload with the resolved group ID
            for p in payloads:
                p["test_folder"] = resolved_group_id

            # Step 3: Push tests one at a time to avoid backend cross-linking
            # verifications across test cases when sent in a single batch.
            merged_result: dict[str, Any] = {
                "created_tests": [],
                "added_flus": [],
                "added_test_results": [],
                "added_verifications": [],
                "total_count": 0,
                "message": "",
            }
            for payload in payloads:
                single_result = await client.create_manual_tests([payload])
                for key in ("created_tests", "added_flus", "added_test_results", "added_verifications"):
                    merged_result[key].extend(single_result.get(key, []))
                merged_result["total_count"] += single_result.get("total_count", 0)
            merged_result["message"] = f"Successfully created {merged_result['total_count']} manual tests"
            result = merged_result
        except AuthenticationError as e:
            return [TextContent(type="text", text=f"Error: {e}")]
        except Exception as e:
            return [TextContent(type="text", text=f"Error: failed to push tests: {e}")]

    response = {
        "status": "pushed",
        "sessionId": session_id,
        "execSessionId": exec_session_id,
        "testFolder": resolved_group_id,
        "testsPushed": len(payloads),
        "parseErrors": parse_errors,
        "skipped": skipped,
        "qualitySkipped": quality_skipped,
        "droppedSteps": all_dropped_steps,
        "maerisResponse": result,
    }
    if no_passed_warning:
        response["warning"] = no_passed_warning
    if quality_skip_details:
        response["qualitySkipDetails"] = quality_skip_details
    return [TextContent(type="text", text=json.dumps(response, indent=2))]
