"""
Story/behaviour tool handlers.

Handles DSL spec extraction, story proposal, saving, and retrieval.
"""

from __future__ import annotations

import json
from pathlib import Path
from typing import TYPE_CHECKING, Any

from .common import error_response, extract_progress, load_project_appspec, wrap_handler_errors
from .serializers import (
    serialize_entity_detail,
    serialize_entity_summary,
    serialize_story,
    serialize_story_summary,
    serialize_surface_detail,
    serialize_surface_summary,
    serialize_test_design_summary,
)

if TYPE_CHECKING:
    from dazzle.core.ir.stories import StorySpec

# =============================================================================
# Constants
# =============================================================================

# Maximum number of constraints to include per story (prevents overwhelming test cases)
MAX_CONSTRAINTS_PER_STORY = 3

# Maximum number of state machine transitions to generate stories for (per entity)
MAX_TRANSITIONS_PER_ENTITY = 3


# =============================================================================
# Trigger Mappings
# =============================================================================

# Lazy-initialized trigger mapping (imports aren't available at module load time)
_TRIGGER_MAP: dict[Any, Any] | None = None


def _get_trigger_map() -> dict[Any, Any]:
    """Get or create the StoryTrigger to TestDesignTrigger mapping.

    Uses lazy initialization since the IR types aren't available at module load.
    """
    global _TRIGGER_MAP  # noqa: PLW0603  # lazy-init, immutable once created
    if _TRIGGER_MAP is None:
        from dazzle.core.ir.stories import StoryTrigger
        from dazzle.core.ir.test_design import TestDesignTrigger

        _TRIGGER_MAP = {
            StoryTrigger.FORM_SUBMITTED: TestDesignTrigger.FORM_SUBMITTED,
            StoryTrigger.STATUS_CHANGED: TestDesignTrigger.STATUS_CHANGED,
            StoryTrigger.TIMER_ELAPSED: TestDesignTrigger.TIMER_ELAPSED,
            StoryTrigger.EXTERNAL_EVENT: TestDesignTrigger.EXTERNAL_EVENT,
            StoryTrigger.USER_CLICK: TestDesignTrigger.USER_CLICK,
            StoryTrigger.CRON_DAILY: TestDesignTrigger.CRON_DAILY,
            StoryTrigger.CRON_HOURLY: TestDesignTrigger.CRON_HOURLY,
        }
    return _TRIGGER_MAP


@wrap_handler_errors
def get_dsl_spec_handler(project_root: Path, args: dict[str, Any]) -> str:
    """Get DSL specification with lazy loading support.

    Default: returns compact summaries of all entities and surfaces.
    With entity_names/surface_names: returns full details for those items only.
    """
    progress = extract_progress(args)
    progress.log_sync("Loading DSL specification...")
    app_spec = load_project_appspec(project_root)

    entity_names: list[str] = args.get("entity_names") or []
    surface_names: list[str] = args.get("surface_names") or []
    detail_requested = bool(entity_names or surface_names)

    if detail_requested:
        # Return full details for requested items only
        result: dict[str, Any] = {
            "project_path": str(project_root),
            "app_name": app_spec.name,
        }

        if entity_names:
            entity_set = set(entity_names)
            result["entities"] = [
                serialize_entity_detail(e) for e in app_spec.domain.entities if e.name in entity_set
            ]

        if surface_names:
            surface_set = set(surface_names)
            result["surfaces"] = [
                serialize_surface_detail(s) for s in app_spec.surfaces if s.name in surface_set
            ]

        return json.dumps(result, indent=2)

    # Default: compact summaries
    spec: dict[str, Any] = {
        "project_path": str(project_root),
        "app_name": app_spec.name,
        "entities": [serialize_entity_summary(e) for e in app_spec.domain.entities],
        "surfaces": [serialize_surface_summary(s) for s in app_spec.surfaces],
        "personas": [
            {"id": p.id, "label": p.label, "description": p.description} for p in app_spec.personas
        ],
        "workspaces": [
            {
                "name": w.name,
                "title": w.title,
                "purpose": w.purpose,
                "regions": [r.name for r in w.regions],
            }
            for w in app_spec.workspaces
        ],
        "guidance": (
            "Use dsl(operation='get_spec', entity_names=['EntityName']) "
            "or dsl(operation='get_spec', surface_names=['surface_name']) "
            "for full field and section details."
        ),
    }

    return json.dumps(spec, indent=2)


def story_propose_impl(
    project_root: Path,
    max_stories: int = 30,
    filter_entities: list[str] | None = None,
) -> dict[str, Any]:
    """Analyze DSL and propose behavioural user stories.

    Pure function — no MCP types. Loads the project appspec, generates draft
    stories from entities and unmapped rhythm scenes, saves them to
    ``dsl/stories.dsl``, and returns a plain dict with summaries.
    """
    from dazzle.core.ir.stories import StoryCondition, StorySpec, StoryStatus, StoryTrigger
    from dazzle.core.story_emitter import append_stories_to_dsl, get_next_story_id_from_appspec

    app_spec = load_project_appspec(project_root)

    stories: list[StorySpec] = []
    story_count = 0

    # Get starting story ID from existing stories in appspec
    base_id = get_next_story_id_from_appspec(app_spec.stories)
    base_num = int(base_id[3:])

    def next_id() -> str:
        nonlocal story_count
        result = f"ST-{base_num + story_count:03d}"
        story_count += 1
        return result

    # Default persona
    default_actor = "User"
    if app_spec.personas:
        default_actor = app_spec.personas[0].label or app_spec.personas[0].id

    # Generate stories from entities
    for entity in app_spec.domain.entities:
        if filter_entities and entity.name not in filter_entities:
            continue

        if story_count >= max_stories:
            break

        # Find persona for this entity (from workspace regions or UX variants)
        actor = default_actor
        for ws in app_spec.workspaces:
            if any(
                r.name == entity.name or entity.name.lower() in r.name.lower() for r in ws.regions
            ):
                # Workspace doesn't have persona directly, use default
                break

        # Story: Create entity via form
        required_field_conditions = [
            StoryCondition(expression=f"{f.name} must be valid")
            for f in entity.fields
            if f.is_required
        ][:MAX_CONSTRAINTS_PER_STORY]

        stories.append(
            StorySpec(
                story_id=next_id(),
                title=f"{actor} creates a new {entity.title or entity.name}",
                actor=actor,
                trigger=StoryTrigger.FORM_SUBMITTED,
                scope=[entity.name],
                given=[
                    StoryCondition(expression=f"{actor} has permission to create {entity.name}")
                ],
                then=[
                    StoryCondition(expression=f"New {entity.name} is saved to database"),
                    StoryCondition(expression=f"{actor} sees confirmation message"),
                    *required_field_conditions,
                ],
                status=StoryStatus.DRAFT,
            )
        )

        # Story: State machine transitions
        if entity.state_machine and story_count < max_stories:
            sm = entity.state_machine
            for transition in sm.transitions[:MAX_TRANSITIONS_PER_ENTITY]:
                if story_count >= max_stories:
                    break

                stories.append(
                    StorySpec(
                        story_id=next_id(),
                        title=f"{actor} changes {entity.name} from {transition.from_state} to {transition.to_state}",
                        actor=actor,
                        trigger=StoryTrigger.STATUS_CHANGED,
                        scope=[entity.name],
                        given=[
                            StoryCondition(
                                expression=f"{entity.name}.{sm.status_field} is '{transition.from_state}'"
                            )
                        ],
                        then=[
                            StoryCondition(
                                expression=f"{entity.name}.{sm.status_field} becomes '{transition.to_state}'"
                            ),
                            StoryCondition(expression="Timestamp is recorded"),
                        ],
                        status=StoryStatus.DRAFT,
                    )
                )

    # Planning inversion: generate stories for unmapped scenes
    for rhythm in app_spec.rhythms:
        for phase in rhythm.phases:
            for scene in phase.scenes:
                if scene.story:
                    continue  # already mapped to a story
                if story_count >= max_stories:
                    break

                actor = default_actor
                # Resolve persona label from rhythm
                for p in app_spec.personas:
                    if p.id == rhythm.persona:
                        actor = p.label or p.id
                        break

                scope = [scene.entity] if scene.entity else []
                action_desc = ", ".join(scene.actions) if scene.actions else "interact"

                stories.append(
                    StorySpec(
                        story_id=next_id(),
                        title=f"{actor} {action_desc}s on {scene.surface}",
                        actor=actor,
                        trigger=StoryTrigger.USER_CLICK,
                        scope=scope,
                        given=[StoryCondition(expression=f"{actor} is on {scene.surface} surface")],
                        then=[
                            StoryCondition(
                                expression=scene.expects or f"Action completes on {scene.surface}"
                            ),
                        ],
                        status=StoryStatus.DRAFT,
                    )
                )

    # Auto-save draft stories to DSL file
    append_stories_to_dsl(project_root, stories)

    return {
        "proposed_count": len(stories),
        "max_stories": max_stories,
        "note": "Draft stories saved to dsl/stories.dsl. Use story(operation='get', story_ids=['ST-001']) for full details.",
        "stories": [serialize_story_summary(s) for s in stories],
    }


@wrap_handler_errors
def propose_stories_from_dsl_handler(project_root: Path, args: dict[str, Any]) -> str:
    """Analyze DSL and propose behavioural user stories."""
    progress = extract_progress(args)
    progress.log_sync("Parsing DSL and building app spec...")

    max_stories = args.get("max_stories", 30)
    filter_entities = args.get("entities")

    progress.log_sync("Generating stories from entities...")
    result = story_propose_impl(
        project_root,
        max_stories=max_stories,
        filter_entities=filter_entities,
    )

    progress.log_sync(f"Saving {result['proposed_count']} draft stories to dsl/stories.dsl...")
    return json.dumps(result, indent=2)


def story_save_impl(
    project_root: Path,
    stories_data: list[dict[str, Any]],
) -> dict[str, Any]:
    """Validate and save a list of story dicts to dsl/stories.dsl.

    Pure function — no MCP types. Converts raw story dicts (including legacy
    field names) to ``StorySpec`` objects, appends them to the stories DSL
    file, and returns a plain dict with save metadata.

    Raises ``ValueError`` when ``stories_data`` is empty.
    """
    from dazzle.core.ir.stories import StoryCondition, StorySpec, StoryStatus, StoryTrigger
    from dazzle.core.story_emitter import append_stories_to_dsl

    if not stories_data:
        raise ValueError("No stories provided")

    # Convert to StorySpec objects with validation
    stories: list[StorySpec] = []
    for s in stories_data:
        # Handle Gherkin fields, with legacy field conversion for backward compat
        if not s.get("given") and s.get("preconditions"):
            story_given = [StoryCondition(expression=c) for c in s["preconditions"]]
        else:
            story_given = [StoryCondition(expression=c) for c in s.get("given", [])]

        story_when = [StoryCondition(expression=c) for c in s.get("when", [])]

        if not s.get("then") and s.get("happy_path_outcome"):
            story_then = [StoryCondition(expression=c) for c in s["happy_path_outcome"]]
        else:
            story_then = [StoryCondition(expression=c) for c in s.get("then", [])]

        story = StorySpec(
            story_id=s["story_id"],
            title=s["title"],
            actor=s["actor"],
            trigger=StoryTrigger(s["trigger"]),
            scope=s.get("scope", []),
            given=story_given,
            when=story_when,
            then=story_then,
            status=StoryStatus(s.get("status", "draft")),
        )
        stories.append(story)

    stories_file = append_stories_to_dsl(project_root, stories)

    return {
        "status": "saved",
        "file": str(stories_file),
        "saved_count": len(stories),
    }


@wrap_handler_errors
def save_stories_handler(project_root: Path, args: dict[str, Any]) -> str:
    """Save stories to dsl/stories.dsl."""
    progress = extract_progress(args)
    stories_data = args.get("stories", [])

    if not stories_data:
        return error_response("No stories provided")

    progress.log_sync("Validating stories...")
    progress.log_sync(f"Saving {len(stories_data)} stories to dsl/stories.dsl...")
    result = story_save_impl(project_root, stories_data)

    return json.dumps(result, indent=2)


@wrap_handler_errors
def get_stories_handler(project_root: Path, args: dict[str, Any]) -> str:
    """Retrieve stories filtered by status.

    Returns compact summaries by default. When ``story_ids`` is provided,
    returns full content for those specific stories only, keeping context
    usage proportional to what the caller actually needs.
    """
    from dazzle.core.ir.stories import StoryStatus

    progress = extract_progress(args)
    status_filter = args.get("status_filter", "all")
    story_ids = args.get("story_ids")

    progress.log_sync("Loading stories from appspec...")
    app_spec = load_project_appspec(project_root)
    stories = list(app_spec.stories)

    # Filter by status
    if status_filter != "all":
        status = StoryStatus(status_filter)
        stories = [s for s in stories if s.status == status]

    if story_ids:
        # Return full content for requested stories only
        id_set = set(story_ids)
        filtered = [s for s in stories if s.story_id in id_set]
        return json.dumps(
            {
                "source": "dsl",
                "filter": status_filter,
                "count": len(filtered),
                "stories": [serialize_story(s) for s in filtered],
            },
            indent=2,
        )

    # Default: return compact summaries
    return json.dumps(
        {
            "source": "dsl",
            "filter": status_filter,
            "count": len(stories),
            "stories": [serialize_story_summary(s) for s in stories],
            "guidance": "Use story(operation='get', story_ids=['ST-001']) to fetch full story details.",
        },
        indent=2,
    )


@wrap_handler_errors
def wall_stories_handler(project_root: Path, args: dict[str, Any]) -> str:
    """Story Wall — founder-friendly board grouped by implementation status.

    Groups stories into three columns based on process coverage:
      - Working: stories fully covered by implementing processes
      - Needs polish: stories with partial process coverage
      - Not started: stories with no implementing process

    Optionally filtered by persona (actor).
    """
    from dazzle.core.ir.stories import StoryStatus

    from .process import stories_coverage_handler

    progress = extract_progress(args)
    actor_filter_str: str | None = args.get("persona")

    progress.log_sync("Loading stories for wall view...")
    app_spec = load_project_appspec(project_root)

    # Get accepted stories (the founder's approved work)
    stories = [s for s in app_spec.stories if s.status == StoryStatus.ACCEPTED]
    if not stories:
        # Fall back to all stories
        stories = list(app_spec.stories)

    progress.log_sync("Calculating coverage data...")
    # Get coverage data
    coverage_raw = stories_coverage_handler(project_root, {"limit": 500})
    coverage_data: dict[str, Any] = json.loads(coverage_raw)
    coverage_map: dict[str, str] = {}
    for item in coverage_data.get("stories", []):
        coverage_map[item.get("story_id", "")] = item.get("status", "uncovered")

    # Filter by persona if requested
    if actor_filter_str:
        stories = [
            s
            for s in stories
            if s.actor.lower() == actor_filter_str.lower()
            or actor_filter_str.lower() in s.actor.lower()
        ]

    # Collect unique personas for filter UI
    personas = sorted({s.actor for s in stories if s.actor})

    # Group by coverage status
    working: list[dict[str, Any]] = []
    needs_polish: list[dict[str, Any]] = []
    not_started: list[dict[str, Any]] = []

    for story in stories:
        summary = serialize_story_summary(story)
        cov_status = coverage_map.get(story.story_id, "uncovered")
        summary["coverage_status"] = cov_status
        if cov_status == "covered":
            working.append(summary)
        elif cov_status == "partial":
            needs_polish.append(summary)
        else:
            not_started.append(summary)

    total = len(stories)

    progress.log_sync("Rendering story wall...")
    # Render wall markdown
    md_lines = ["Story Wall", ""]
    if actor_filter_str:
        md_lines.append(f"Filtered by: {actor_filter_str}")
        md_lines.append("")

    md_lines.append(f"Working ({len(working)})")
    for s in working:
        md_lines.append(f"  [ok] {s['title']}  ({s['actor']})")
    md_lines.append("")
    md_lines.append(f"Needs polish ({len(needs_polish)})")
    for s in needs_polish:
        md_lines.append(f"  [..] {s['title']}  ({s['actor']})")
    md_lines.append("")
    md_lines.append(f"Not started ({len(not_started)})")
    for s in not_started:
        md_lines.append(f"  [  ] {s['title']}  ({s['actor']})")

    return json.dumps(
        {
            "view": "wall",
            "total": total,
            "working": working,
            "needs_polish": needs_polish,
            "not_started": not_started,
            "personas": personas,
            "filtered_by": actor_filter_str,
            "markdown": "\n".join(md_lines),
        },
        indent=2,
    )


def story_generate_tests_impl(
    project_root: Path,
    story_ids: list[str] | None = None,
    include_draft: bool = False,
) -> dict[str, Any]:
    """Generate test designs from accepted (and optionally draft) stories.

    Pure function — no MCP types. Converts behavioural stories into
    ``TestDesignSpec`` objects, saves them via
    ``add_test_designs``, and returns a plain dict with summaries.

    Returns a dict with ``status='no_stories'`` when no matching stories exist.
    """
    from dazzle.core.ir.stories import StoryStatus, StoryTrigger
    from dazzle.core.ir.test_design import (
        TestDesignAction,
        TestDesignSpec,
        TestDesignStatus,
        TestDesignStep,
        TestDesignTrigger,
    )

    app_spec = load_project_appspec(project_root)
    all_stories = list(app_spec.stories)

    # Get accepted stories
    stories = [s for s in all_stories if s.status == StoryStatus.ACCEPTED]

    # Optionally include draft stories
    if include_draft:
        draft_stories = [s for s in all_stories if s.status == StoryStatus.DRAFT]
        stories = stories + draft_stories

    # Filter by specific story IDs if provided
    if story_ids:
        id_set = set(story_ids)
        stories = [s for s in stories if s.story_id in id_set]

    if not stories:
        return {
            "status": "no_stories",
            "message": "No stories found. Use propose_stories_from_dsl or save_stories first.",
        }

    # Get trigger mapping (lazily initialized)
    trigger_map = _get_trigger_map()

    def story_to_test_design(story: StorySpec, index: int) -> TestDesignSpec:
        """Convert a single story to a test design."""
        # Convert story ID format: ST-001 -> TD-001
        test_id = story.story_id.replace("ST-", "TD-")

        # Build steps from story structure
        steps: list[TestDesignStep] = []

        # Step 1: Login as the actor
        steps.append(
            TestDesignStep(
                action=TestDesignAction.LOGIN_AS,
                target=story.actor,
                rationale=f"Test from {story.actor}'s perspective",
            )
        )

        # Step 2: Setup steps from given conditions
        for condition in [c.expression for c in story.given]:
            # Parse condition to determine appropriate action
            if "is set" in condition.lower() or "exists" in condition.lower():
                # Existence check - create or navigate
                entity = _extract_entity_from_condition(condition, story.scope)
                steps.append(
                    TestDesignStep(
                        action=TestDesignAction.ASSERT_VISIBLE,
                        target=entity,
                        rationale=f"Precondition: {condition}",
                    )
                )
            elif "is '" in condition or 'is "' in condition:
                # State check - assert current state
                entity = _extract_entity_from_condition(condition, story.scope)
                steps.append(
                    TestDesignStep(
                        action=TestDesignAction.ASSERT_TEXT,
                        target=entity,
                        data={"condition": condition},
                        rationale=f"Precondition: {condition}",
                    )
                )
            else:
                # Generic precondition - navigate to entity
                entity = _extract_entity_from_condition(condition, story.scope)
                if entity:
                    steps.append(
                        TestDesignStep(
                            action=TestDesignAction.NAVIGATE_TO,
                            target=entity,
                            rationale=f"Setup: {condition}",
                        )
                    )

        # Step 3: Action steps from when conditions
        when_conditions = [c.expression for c in story.when] if story.when else []
        for condition in when_conditions:
            if "changes to" in condition.lower():
                # State transition
                entity = _extract_entity_from_condition(condition, story.scope)
                steps.append(
                    TestDesignStep(
                        action=TestDesignAction.TRIGGER_TRANSITION,
                        target=entity,
                        data={"transition": condition},
                        rationale=f"Trigger: {condition}",
                    )
                )
            elif "submit" in condition.lower() or "form" in condition.lower():
                steps.append(
                    TestDesignStep(
                        action=TestDesignAction.CLICK,
                        target="submit_button",
                        rationale=f"Action: {condition}",
                    )
                )
            elif "click" in condition.lower():
                steps.append(
                    TestDesignStep(
                        action=TestDesignAction.CLICK,
                        target=_extract_target_from_condition(condition),
                        rationale=f"Action: {condition}",
                    )
                )

        # If no when conditions, infer action from trigger
        if not when_conditions:
            if story.trigger == StoryTrigger.FORM_SUBMITTED:
                # Navigate to create form and submit
                entity = story.scope[0] if story.scope else "form"
                steps.append(
                    TestDesignStep(
                        action=TestDesignAction.NAVIGATE_TO,
                        target=f"{entity}_create",
                        rationale="Navigate to creation form",
                    )
                )
                steps.append(
                    TestDesignStep(
                        action=TestDesignAction.FILL,
                        target="form",
                        data={"fields": "required_fields"},
                        rationale="Fill form with test data",
                    )
                )
                steps.append(
                    TestDesignStep(
                        action=TestDesignAction.CLICK,
                        target="submit_button",
                        rationale="Submit the form",
                    )
                )
            elif story.trigger == StoryTrigger.STATUS_CHANGED:
                entity = story.scope[0] if story.scope else "entity"
                steps.append(
                    TestDesignStep(
                        action=TestDesignAction.TRIGGER_TRANSITION,
                        target=entity,
                        rationale="Trigger status change",
                    )
                )
            elif story.trigger == StoryTrigger.USER_CLICK:
                steps.append(
                    TestDesignStep(
                        action=TestDesignAction.CLICK,
                        target="action_button",
                        rationale="Perform user action",
                    )
                )

        # Expected outcomes from then conditions
        expected_outcomes = [c.expression for c in story.then]

        return TestDesignSpec(
            test_id=test_id,
            title=f"Verify: {story.title}",
            description=f"Test generated from story {story.story_id}",
            persona=story.actor,
            trigger=trigger_map.get(story.trigger, TestDesignTrigger.USER_CLICK),
            steps=steps,
            expected_outcomes=expected_outcomes,
            entities=story.scope.copy(),
            tags=[f"story:{story.story_id}"],
            status=TestDesignStatus.PROPOSED,
        )

    # Convert all stories to test designs
    test_designs = [story_to_test_design(s, i) for i, s in enumerate(stories)]

    # Auto-save generated test designs to avoid a separate save round-trip
    from dazzle.testing.test_design_persistence import add_test_designs

    add_test_designs(project_root, test_designs, overwrite=False)

    return {
        "status": "generated",
        "count": len(test_designs),
        "note": "Test designs saved. Use test_design(operation='get', status_filter='proposed') for full details.",
        "test_designs": [serialize_test_design_summary(td) for td in test_designs],
    }


@wrap_handler_errors
def generate_tests_from_stories_handler(project_root: Path, args: dict[str, Any]) -> str:
    """Generate test designs from accepted stories.

    Converts behavioural stories (what should happen) into test designs
    (how to verify it happens). This bridges the gap between story
    acceptance criteria and executable test cases.
    """
    progress = extract_progress(args)
    story_ids = args.get("story_ids")
    include_draft = args.get("include_draft", False)

    progress.log_sync("Loading stories for test generation...")
    result = story_generate_tests_impl(
        project_root,
        story_ids=story_ids,
        include_draft=include_draft,
    )

    if result.get("status") == "no_stories":
        return json.dumps(result)

    total = result["count"]
    progress.log_sync(f"Converting {total} stories to test designs...")
    progress.log_sync("Saving test designs...")

    # Return summaries only to reduce context usage
    return json.dumps(result, indent=2)


def _extract_entity_from_condition(condition: str, scope: list[str]) -> str:
    """Extract entity name from a condition string."""
    # Check if any scope entity is mentioned
    for entity in scope:
        if entity.lower() in condition.lower():
            return entity
    # Look for Entity.field pattern
    if "." in condition:
        return condition.split(".")[0].strip()
    # Default to first entity in scope
    return scope[0] if scope else "entity"


def _extract_target_from_condition(condition: str) -> str:
    """Extract target element from a condition like 'click the submit button'."""
    # Simple extraction - look for quoted strings or common patterns
    import re

    # Look for quoted strings
    quoted: list[str] = re.findall(r"['\"]([^'\"]+)['\"]", condition)
    if quoted:
        return quoted[0]
    # Look for "the X" pattern
    the_match = re.search(r"the\s+(\w+)", condition.lower())
    if the_match:
        return the_match.group(1)
    return "button"
