"""Senior-tester pre-write discipline.

Encodes the eight principles a senior test engineer applies BEFORE
writing tests. Sister tool to `audit_test_quality` (post-write
audit). Together they form the senior-tester loop: plan → write
→ audit.

The eight principles
--------------------

1. **AC-first**. Start from Acceptance Criteria of the user story.
   For each AC: happy path + minimum 2–3 negative/boundary cases
   using Equivalence Partitioning + Boundary Value Analysis.

2. **Atomic**. One assertion per test. Independent of every other
   test, so the suite passes in any sharded CI order.

3. **Naming**: `should_X_when_Y` so the CI report reads like
   English when something fails.

4. **Test data**: Builder pattern / test data factory. Never a
   hardcoded shared "the user" leaking state between tests.

5. **Gherkin discretion**: Gherkin only if business reads the
   scenarios. In dev-heavy teams plain `testWidgets` (Flutter)
   or `test()` (Dart) is cheaper to maintain.

6. **Exploratory ≠ ad-hoc**: charter format with mission +
   time-box + notes. Each session yields 2–3 new automated cases.

7. **Cross-cutting = first-class**: a11y, l10n, lifecycle are
   requirements, not afterthought.

8. **Gap protocol**: when ACs are missing, proceed BUT log
   "proceeded without ACs; reverse-engineer from implementation
   and re-run design_test_plan against them as afterwork." Never
   silently fake having ACs.

What this tool does
-------------------

Given any combination of:

  - user_story (free-text)
  - acceptance_criteria (list of strings)
  - source_paths (where the feature lives, for reverse-engineering)
  - feature_kind (auth/form/list/payment/settings/onboarding)
  - team_style (developer_heavy | mixed_with_business)
  - time_box_min (for the exploratory charter)

Returns a structured test plan:

  - ac_coverage[] — per-AC happy/negative/boundary cases with
    should_X_when_Y names and arrange/act/assert hints
  - cross_cutting_required[] — a11y/l10n/lifecycle rules to cover
  - test_layers[] — unit / widget / integration / e2e with rationale
  - test_data_factories[] — what builders/factories to create
  - exploratory_charter — when warranted, with mission + areas +
    notes guidance
  - gaps[] — missing ACs, edges unprovable from source
  - notes_for_afterwork[] — populated when proceeding without ACs
  - advice — paste-ready story-comment summary

Pure compute. No LLM call. The plan is generated by applying the
discipline rules to the input shape.
"""

from __future__ import annotations

import re
from dataclasses import dataclass
from enum import Enum
from pathlib import Path

from ..failures import FilesystemFailure
from ..result import Result, err, ok
from .base import BaseUseCase


class TeamStyle(str, Enum):
    DEVELOPER_HEAVY = "developer_heavy"      # Gherkin off, testWidgets/test()
    MIXED_WITH_BUSINESS = "mixed_with_business"  # Gherkin on for E2E


class FeatureKind(str, Enum):
    AUTH = "auth"
    FORM = "form"
    LIST = "list"
    DETAIL = "detail"
    PAYMENT = "payment"
    SETTINGS = "settings"
    ONBOARDING = "onboarding"
    NAVIGATION = "navigation"
    GENERIC = "generic"


class TestLayer(str, Enum):
    UNIT = "unit"
    WIDGET = "widget"
    INTEGRATION = "integration"
    E2E = "e2e"


@dataclass(frozen=True, slots=True)
class TestCase:
    name: str                # should_X_when_Y formatted
    description: str
    arrange: str             # "given a User built by UserFactory.signedIn()"
    act: str                 # "when the user taps the login button"
    assert_str: str          # "then a HomeRoute event is dispatched"


@dataclass(frozen=True, slots=True)
class ACCoverage:
    ac_id: str                          # "AC1", "AC2", etc.
    ac_text: str                        # the AC verbatim, or reverse-engineered
    happy_path: tuple[TestCase, ...]
    negative_cases: tuple[TestCase, ...]   # EP — equivalence partitions
    boundary_cases: tuple[TestCase, ...]   # BVA — min, min+1, max, max-1
    primary_layer: str                  # which layer the tests live in
    rationale: str


@dataclass(frozen=True, slots=True)
class CrossCuttingRequirement:
    concern: str             # a11y / l10n / lifecycle / error_handling
    rule: str                # short slug
    description: str
    must_include_in_layer: str   # 'widget' / 'integration' / etc.
    fix_hint: str | None


@dataclass(frozen=True, slots=True)
class TestLayerRecommendation:
    layer: str               # unit/widget/integration/e2e
    rationale: str
    estimated_test_count: int
    framework_note: str | None   # 'use testWidgets', 'patrol for cross-process'


@dataclass(frozen=True, slots=True)
class TestDataFactoryRecommendation:
    factory_name: str        # 'UserFactory'
    purpose: str             # 'build User entities for auth tests'
    methods: tuple[str, ...]    # ['anonymous()', 'signedIn()', 'banned()']
    rationale: str


@dataclass(frozen=True, slots=True)
class ExploratoryCharter:
    mission: str
    areas_to_explore: tuple[str, ...]
    time_box_min: int
    what_to_record: tuple[str, ...]
    expected_outcomes: str   # "2-3 new automated cases per session"


@dataclass(frozen=True, slots=True)
class DesignTestPlanParams:
    # Free-text user story description.
    user_story: str = ""
    # Acceptance criteria, ideally numbered.
    acceptance_criteria: tuple[str, ...] = ()
    # Source paths to scan if reverse-engineering ACs.
    source_paths: tuple[str, ...] = ()
    # Project root (used to validate source_paths).
    project_path: Path | None = None
    # Shape of the feature — drives cross-cutting defaults.
    feature_kind: str = "generic"
    # Whether business stakeholders read scenarios (Gherkin gate).
    team_style: str = "developer_heavy"
    # Charter time-box in minutes.
    time_box_min: int = 60


@dataclass(frozen=True, slots=True)
class DesignTestPlanResult:
    grade: str                                    # complete / partial / needs_acceptance_criteria
    feature_kind: str
    ac_coverage: tuple[ACCoverage, ...]
    cross_cutting_required: tuple[CrossCuttingRequirement, ...]
    test_layers: tuple[TestLayerRecommendation, ...]
    test_data_factories: tuple[TestDataFactoryRecommendation, ...]
    exploratory_charter: ExploratoryCharter | None
    naming_convention: str                        # "should_X_when_Y"
    use_gherkin: bool                             # team-style + business-readable
    gaps: tuple[str, ...]                         # missing ACs, unverifiable edges
    notes_for_afterwork: tuple[str, ...]          # logged when ACs missing
    advice: str                                   # paste-ready summary


class DesignTestPlan(
    BaseUseCase[DesignTestPlanParams, DesignTestPlanResult]
):
    """Generates a senior-tester-discipline test plan.

    Pure compute. No LLM. Takes user_story + ACs (or reverse-
    engineers from source) and emits a structured plan with
    happy/negative/boundary cases per AC, cross-cutting
    requirements, factories, an exploratory charter, and a
    gap-protocol note set when ACs are missing.
    """

    async def execute(
        self, params: DesignTestPlanParams
    ) -> Result[DesignTestPlanResult]:
        # Validate enums
        try:
            kind = FeatureKind(params.feature_kind)
        except ValueError:
            return err(FilesystemFailure(
                message=(
                    f"unknown feature_kind {params.feature_kind!r}. "
                    "Valid: " + ", ".join(p.value for p in FeatureKind)
                ),
                next_action="fix_arguments",
            ))
        try:
            team = TeamStyle(params.team_style)
        except ValueError:
            return err(FilesystemFailure(
                message=(
                    f"unknown team_style {params.team_style!r}. "
                    "Valid: " + ", ".join(p.value for p in TeamStyle)
                ),
                next_action="fix_arguments",
            ))

        if params.project_path is not None and not params.project_path.is_dir():
            return err(FilesystemFailure(
                message=f"project_path not found: {params.project_path}",
                next_action="fix_arguments",
            ))

        # Determine ACs: explicit or reverse-engineered
        acs = list(params.acceptance_criteria)
        gaps: list[str] = []
        notes: list[str] = []
        reverse_engineered = False

        if not acs:
            # No ACs provided — gap protocol engaged.
            gaps.append(
                "No acceptance_criteria provided. Plan generated "
                "from heuristic defaults for the feature_kind."
            )
            notes.append(
                "Proceeded without ACs. Afterwork: reverse-engineer "
                "ACs from the merged implementation, then re-run "
                "design_test_plan against them to surface gaps."
            )
            # Synthesise placeholder ACs from user_story / source
            acs = _synthesize_acs(
                params.user_story, kind, params.source_paths,
            )
            reverse_engineered = True

        # Build per-AC coverage
        coverage: list[ACCoverage] = []
        for idx, ac_text in enumerate(acs, start=1):
            coverage.append(_build_ac_coverage(
                ac_id=f"AC{idx}",
                ac_text=ac_text,
                feature_kind=kind,
                reverse_engineered=reverse_engineered,
            ))

        # Cross-cutting requirements (always present)
        cross_cutting = _cross_cutting_for(kind)

        # Test layers + framework recommendation
        use_gherkin = team == TeamStyle.MIXED_WITH_BUSINESS
        test_layers = _test_layers_for(kind, len(acs), use_gherkin)

        # Test data factories
        factories = _factories_for(kind)

        # Exploratory charter (always — discipline says every feature gets one)
        charter = _charter_for(
            kind, params.time_box_min, params.user_story
        )

        # Grade
        if reverse_engineered:
            grade = "needs_acceptance_criteria"
        elif not params.user_story.strip():
            grade = "partial"
            gaps.append(
                "No user_story provided. Plan is AC-driven only — "
                "missing the 'who/why' context that informs cross-"
                "cutting scope."
            )
        else:
            grade = "complete"

        advice = _build_advice(
            grade, len(acs), len(coverage),
            sum(
                len(c.happy_path) + len(c.negative_cases)
                + len(c.boundary_cases)
                for c in coverage
            ),
            len(cross_cutting), use_gherkin, reverse_engineered,
        )

        return ok(DesignTestPlanResult(
            grade=grade,
            feature_kind=kind.value,
            ac_coverage=tuple(coverage),
            cross_cutting_required=tuple(cross_cutting),
            test_layers=tuple(test_layers),
            test_data_factories=tuple(factories),
            exploratory_charter=charter,
            naming_convention="should_X_when_Y",
            use_gherkin=use_gherkin,
            gaps=tuple(gaps),
            notes_for_afterwork=tuple(notes),
            advice=advice,
        ))


# ============================================================
# AC synthesis (reverse engineering)
# ============================================================


def _synthesize_acs(
    user_story: str,
    kind: FeatureKind,
    source_paths: tuple[str, ...],
) -> list[str]:
    """Generate placeholder ACs when none were provided.

    These are intentionally generic — they exist so the rest of
    the planner has something to operate on. The gap protocol
    surfaces the fact that they're synthesised.
    """
    base = _heuristic_acs_for_kind(kind)
    if user_story.strip():
        # Prepend a story-derived header AC so the agent sees the
        # link to whatever it was given.
        story_snippet = user_story.strip()[:140]
        base = [
            f"(synthesised from user_story) {story_snippet}",
            *base,
        ]
    if source_paths:
        base.append(
            "(synthesised from source_paths) Implementation behavior "
            "matches the merged code in: "
            + ", ".join(source_paths)
        )
    return base


def _heuristic_acs_for_kind(kind: FeatureKind) -> list[str]:
    if kind == FeatureKind.AUTH:
        return [
            "User can sign in with valid credentials",
            "User cannot sign in with invalid credentials",
            "User session persists across app restart",
            "User can sign out",
        ]
    if kind == FeatureKind.FORM:
        return [
            "Submitting valid input succeeds",
            "Submitting invalid input shows field-level errors",
            "Submit button is disabled while submitting",
            "Cancelling discards user input",
        ]
    if kind == FeatureKind.LIST:
        return [
            "List renders items from the data source",
            "Empty state shows when no items exist",
            "List handles loading + error states",
            "Pull-to-refresh re-fetches data",
        ]
    if kind == FeatureKind.PAYMENT:
        return [
            "Successful payment finalises the order",
            "Failed payment surfaces a recoverable error",
            "Payment can be cancelled before confirmation",
            "Network error during payment is retriable",
            "Idempotency: re-attempt does not double-charge",
        ]
    if kind == FeatureKind.SETTINGS:
        return [
            "Settings are persisted across app restart",
            "Invalid settings cannot be saved",
            "Default settings can be restored",
        ]
    if kind == FeatureKind.ONBOARDING:
        return [
            "First-launch user sees onboarding",
            "User can skip onboarding",
            "Onboarding state persists once completed",
        ]
    if kind == FeatureKind.NAVIGATION:
        return [
            "Navigating to the feature lands on the right screen",
            "Back navigation restores the previous state",
            "Deep links route to the correct screen with state",
        ]
    if kind == FeatureKind.DETAIL:
        return [
            "Detail view renders the selected item",
            "Detail handles loading + error states",
            "Edit/back actions navigate correctly",
        ]
    return [
        "Happy path: the feature behaves as the user expects",
        "Edge case: invalid or missing inputs are handled",
        "Error case: failures surface a recoverable state",
    ]


# ============================================================
# Per-AC coverage builder
# ============================================================


def _build_ac_coverage(
    ac_id: str,
    ac_text: str,
    feature_kind: FeatureKind,
    reverse_engineered: bool,
) -> ACCoverage:
    """Apply EP + BVA discipline: happy path + 2-3 negatives +
    2-3 boundaries."""
    # Build cases from the AC text using heuristic patterns
    happy = (_make_happy_case(ac_id, ac_text, feature_kind),)
    negatives = _make_negative_cases(ac_id, ac_text, feature_kind)
    boundaries = _make_boundary_cases(ac_id, ac_text, feature_kind)

    primary_layer = _primary_layer_for(feature_kind, ac_text)
    rationale = (
        "EP+BVA applied: happy path validates the expected flow; "
        "negative cases cover equivalence partitions (invalid/"
        "empty/null); boundary cases cover min, max, off-by-one. "
        + (
            "AC reverse-engineered — confirm with PO before merge."
            if reverse_engineered else ""
        )
    )

    return ACCoverage(
        ac_id=ac_id,
        ac_text=ac_text,
        happy_path=happy,
        negative_cases=negatives,
        boundary_cases=boundaries,
        primary_layer=primary_layer,
        rationale=rationale.strip(),
    )


def _make_happy_case(
    ac_id: str, ac_text: str, kind: FeatureKind,
) -> TestCase:
    name = _to_should_name(ac_text, prefix="should_happy_path")
    return TestCase(
        name=name,
        description=f"Happy path verification of {ac_id}.",
        arrange=(
            "given the SUT in its valid default state, built via the "
            "configured TestDataFactory"
        ),
        act=_act_hint_for(kind, ac_text),
        assert_str=(
            "then the expected end state is observable via a single "
            "assertion"
        ),
    )


def _make_negative_cases(
    ac_id: str, ac_text: str, kind: FeatureKind,
) -> tuple[TestCase, ...]:
    """Equivalence partitioning — pick representatives from
    distinct invalid classes."""
    out: list[TestCase] = []
    base = _to_should_name(ac_text, prefix="should_fail")
    for partition, desc in (
        ("when_input_is_null", "input is null/missing"),
        ("when_input_is_empty", "input is empty string/empty list"),
        ("when_underlying_call_throws", "the data source throws"),
    ):
        out.append(TestCase(
            name=f"{base}_{partition}",
            description=f"EP partition: {desc}.",
            arrange=(
                "given the SUT with the corresponding invalid "
                "input from TestDataFactory.invalid_xxx()"
            ),
            act=_act_hint_for(kind, ac_text),
            assert_str=(
                "then a typed Failure (or matching error envelope) "
                "is returned — single isA<Failure> assertion"
            ),
        ))
    return tuple(out)


def _make_boundary_cases(
    ac_id: str, ac_text: str, kind: FeatureKind,
) -> tuple[TestCase, ...]:
    """Boundary value analysis — min, min+1, max, max-1, off-by-one."""
    base = _to_should_name(ac_text, prefix="should_handle_boundary")
    cases = [
        ("at_minimum", "input is exactly the minimum allowed value"),
        ("just_above_minimum", "input is min+1 (smallest valid)"),
        ("at_maximum", "input is exactly the maximum allowed value"),
        ("just_above_maximum", "input is max+1 (first invalid)"),
    ]
    # Cap at 3 boundary cases for a typical AC; payment/lists get the 4th
    limit = 4 if kind in (FeatureKind.PAYMENT, FeatureKind.LIST) else 3
    out: list[TestCase] = []
    for suffix, desc in cases[:limit]:
        out.append(TestCase(
            name=f"{base}_{suffix}",
            description=f"BVA: {desc}.",
            arrange=(
                "given the SUT with the boundary input built via "
                f"TestDataFactory.boundary_{suffix}()"
            ),
            act=_act_hint_for(kind, ac_text),
            assert_str=(
                "then behavior matches the spec at this boundary "
                "with a single assertion"
            ),
        ))
    return tuple(out)


def _to_should_name(ac_text: str, prefix: str) -> str:
    """Convert AC text into a `should_X_when_Y`-shaped name fragment.

    Strips punctuation, lowercases, replaces spaces with
    underscores, trims to a sensible length.
    """
    slug = re.sub(r"[^A-Za-z0-9 ]+", " ", ac_text.lower())
    slug = re.sub(r"\s+", "_", slug.strip())
    slug = slug[:50].rstrip("_")
    return f"{prefix}_{slug}" if slug else prefix


def _act_hint_for(kind: FeatureKind, ac_text: str) -> str:
    """Suggest the act phrasing based on feature kind."""
    if kind == FeatureKind.AUTH:
        return "when the auth use case is dispatched"
    if kind == FeatureKind.FORM:
        return "when the form submit handler is invoked"
    if kind == FeatureKind.PAYMENT:
        return "when the payment confirmation flow is executed"
    if kind == FeatureKind.NAVIGATION:
        return "when the navigation event is dispatched"
    if kind == FeatureKind.LIST or kind == FeatureKind.DETAIL:
        return "when the data load is triggered"
    return "when the feature's primary action is invoked"


def _primary_layer_for(kind: FeatureKind, ac_text: str) -> str:
    """Pick the layer where this AC's tests primarily live."""
    text = ac_text.lower()
    if any(k in text for k in (
        "navigate", "screen", "renders", "displays", "shows",
    )):
        return TestLayer.WIDGET.value
    if any(k in text for k in (
        "persists", "restart", "deep link", "cross",
    )):
        return TestLayer.INTEGRATION.value
    if any(k in text for k in (
        "happy", "end-to-end", "user can", "user cannot",
    )):
        return TestLayer.E2E.value
    if kind in (FeatureKind.PAYMENT, FeatureKind.AUTH):
        return TestLayer.INTEGRATION.value
    return TestLayer.UNIT.value


# ============================================================
# Cross-cutting requirements
# ============================================================


def _cross_cutting_for(
    kind: FeatureKind,
) -> list[CrossCuttingRequirement]:
    """Cross-cutting concerns that should be tested for every
    feature. a11y + l10n + lifecycle are baseline; payment adds
    idempotency + audit; auth adds session-restore."""
    out: list[CrossCuttingRequirement] = [
        CrossCuttingRequirement(
            concern="a11y",
            rule="every_interactive_has_semantics_label",
            description=(
                "Every Button / IconButton / interactive widget "
                "must have a Semantics label — TalkBack/VoiceOver "
                "must reach it."
            ),
            must_include_in_layer="widget",
            fix_hint="Wrap with Semantics(label: ..., child: ...)",
        ),
        CrossCuttingRequirement(
            concern="a11y",
            rule="tap_targets_min_48dp",
            description=(
                "Tap targets are min 48dp Android / 44pt iOS "
                "(WCAG 2.5.5)."
            ),
            must_include_in_layer="widget",
            fix_hint="Wrap small icons with ConstrainedBox(minWidth: 48).",
        ),
        CrossCuttingRequirement(
            concern="l10n",
            rule="no_hardcoded_user_facing_strings",
            description=(
                "User-facing strings come from AppLocalizations / "
                ".arb files (Polish-locale-phone lesson)."
            ),
            must_include_in_layer="widget",
            fix_hint="Replace Text('Sign in') with AppLocalizations.of(c)!.signIn.",
        ),
        CrossCuttingRequirement(
            concern="lifecycle",
            rule="dispose_owned_resources",
            description=(
                "StatefulWidget owning Controllers/Streams/Timers "
                "must override dispose()."
            ),
            must_include_in_layer="widget",
            fix_hint="Override dispose(); call .dispose() / .cancel() on owned resources.",
        ),
        CrossCuttingRequirement(
            concern="error_handling",
            rule="failure_path_has_assertion",
            description=(
                "Every happy path must have a paired failure-path "
                "test asserting `isA<Failure>`."
            ),
            must_include_in_layer="unit",
            fix_hint="Add a paired `should_X_when_Y_fails` test per happy path.",
        ),
    ]
    if kind == FeatureKind.PAYMENT:
        out.extend([
            CrossCuttingRequirement(
                concern="idempotency",
                rule="retry_does_not_double_charge",
                description=(
                    "Payment retries must be idempotent via "
                    "client-side idempotency key."
                ),
                must_include_in_layer="integration",
                fix_hint=(
                    "Generate UUID v4 at retry; backend dedupes."
                ),
            ),
            CrossCuttingRequirement(
                concern="audit",
                rule="payment_events_logged",
                description=(
                    "Every payment state transition emits an audit "
                    "event."
                ),
                must_include_in_layer="integration",
                fix_hint="Assert the AuditLogger receives the event.",
            ),
        ])
    if kind == FeatureKind.AUTH:
        out.append(CrossCuttingRequirement(
            concern="session",
            rule="session_persists_across_restart",
            description=(
                "Auth session restored after a cold start without "
                "the user having to re-enter credentials."
            ),
            must_include_in_layer="integration",
            fix_hint="Tear down + boot fresh; assert auto-login.",
        ))
    if kind == FeatureKind.FORM:
        out.append(CrossCuttingRequirement(
            concern="keyboard",
            rule="ime_action_routes_focus",
            description=(
                "TextInputAction.next moves focus to the next field; "
                ".done submits."
            ),
            must_include_in_layer="widget",
            fix_hint=(
                "Set textInputAction on each TextFormField; verify "
                "with FocusManager."
            ),
        ))
    return out


# ============================================================
# Test layers
# ============================================================


def _test_layers_for(
    kind: FeatureKind, ac_count: int, use_gherkin: bool,
) -> list[TestLayerRecommendation]:
    """Recommend the test pyramid for this feature."""
    out: list[TestLayerRecommendation] = [
        TestLayerRecommendation(
            layer=TestLayer.UNIT.value,
            rationale=(
                "Use cases / repos / pure functions belong here — "
                "fastest feedback. Aim for 60-70% of total tests."
            ),
            estimated_test_count=max(ac_count * 4, 4),
            framework_note="Plain `test()` from package:test.",
        ),
        TestLayerRecommendation(
            layer=TestLayer.WIDGET.value,
            rationale=(
                "Bloc + Widget binding tests. Pumps a single widget "
                "tree with BlocProvider.value + mocked dependencies."
            ),
            estimated_test_count=max(ac_count * 2, 3),
            framework_note=(
                "testWidgets from flutter_test. Always `await` pumps. "
                "Never raw Future.delayed — use pumpAndSettle."
            ),
        ),
    ]
    if kind in (
        FeatureKind.AUTH, FeatureKind.PAYMENT,
        FeatureKind.NAVIGATION,
    ):
        out.append(TestLayerRecommendation(
            layer=TestLayer.INTEGRATION.value,
            rationale=(
                "Cross-bloc / cross-service flows belong here. Uses "
                "a real Firebase/Dio with a test backend or recorded "
                "fixtures."
            ),
            estimated_test_count=max(ac_count, 2),
            framework_note=(
                "integration_test package. Use Patrol for native "
                "system dialogs (perms / biometric)."
            ),
        ))
    if use_gherkin:
        out.append(TestLayerRecommendation(
            layer=TestLayer.E2E.value,
            rationale=(
                "Gherkin-style E2E because the team has business "
                "stakeholders who read scenarios. Keep scenario "
                "count small — every scenario costs CI minutes."
            ),
            estimated_test_count=max(ac_count // 2, 1),
            framework_note=(
                "bdd_widget_test or flutter_gherkin. Re-use the "
                "TestDataFactories from the unit/widget layers."
            ),
        ))
    return out


# ============================================================
# Test data factories
# ============================================================


def _factories_for(
    kind: FeatureKind,
) -> list[TestDataFactoryRecommendation]:
    """Builder pattern factories per feature."""
    out: list[TestDataFactoryRecommendation] = []
    if kind == FeatureKind.AUTH:
        out.append(TestDataFactoryRecommendation(
            factory_name="UserFactory",
            purpose="Build User entities in known states for auth tests.",
            methods=(
                "anonymous()", "signedIn()", "signedInExpired()",
                "banned()", "withCustomClaims(claims)",
            ),
            rationale=(
                "Never hardcode a 'the user' literal — share via "
                "factory so each test owns its own User."
            ),
        ))
    if kind == FeatureKind.PAYMENT:
        out.append(TestDataFactoryRecommendation(
            factory_name="OrderFactory",
            purpose="Build Order entities at various lifecycle stages.",
            methods=(
                "pending()", "confirmed()", "failed(reason)",
                "refunded()", "withItems(items)",
            ),
            rationale=(
                "Each payment test owns its order; no leakage."
            ),
        ))
    if kind in (FeatureKind.FORM, FeatureKind.SETTINGS):
        out.append(TestDataFactoryRecommendation(
            factory_name=f"{kind.value.capitalize()}InputFactory",
            purpose=(
                f"Build input shapes for {kind.value} tests, "
                "covering valid/invalid/boundary cases."
            ),
            methods=(
                "valid()", "invalid_missingField()",
                "boundary_minLength()", "boundary_maxLength()",
            ),
            rationale=(
                "Boundary inputs live in the factory so BVA cases "
                "share the same source of truth."
            ),
        ))
    if kind in (FeatureKind.LIST, FeatureKind.DETAIL):
        out.append(TestDataFactoryRecommendation(
            factory_name="ItemFactory",
            purpose="Build list / detail items in known shapes.",
            methods=(
                "single()", "many(count)", "empty()",
                "withImages(n)",
            ),
            rationale=(
                "Empty + many + single covers EP partitions for "
                "list rendering."
            ),
        ))
    # Always include a Failure factory — every happy path has a
    # matched failure case (discipline #7).
    out.append(TestDataFactoryRecommendation(
        factory_name="FailureFactory",
        purpose=(
            "Build typed Failure values for negative-path tests."
        ),
        methods=(
            "networkError()", "validationFailure(field)",
            "unauthorised()", "notFound()", "rateLimited()",
        ),
        rationale=(
            "Every happy path needs a matched `should_X_when_Y_fails` "
            "asserting the right Failure. Factory keeps this DRY."
        ),
    ))
    return out


# ============================================================
# Exploratory charter
# ============================================================


def _charter_for(
    kind: FeatureKind, time_box_min: int, user_story: str,
) -> ExploratoryCharter:
    """Discipline rule #6 — every feature gets a charter even if
    the team thinks it's 'simple'."""
    mission = (
        "Explore the implemented feature to surface 2-3 new "
        "automated test cases the planner missed. Look for "
        "interaction patterns the spec didn't anticipate."
    )
    areas = _areas_for(kind)
    return ExploratoryCharter(
        mission=mission,
        areas_to_explore=tuple(areas),
        time_box_min=max(15, time_box_min),
        what_to_record=(
            "Each unexpected behavior with: trigger, observed result, "
            "expected result, repro steps. Each item becomes a "
            "should_X_when_Y test in follow-up.",
            "Screenshots of any visual smell.",
            "adb / device logs around the time of the smell.",
        ),
        expected_outcomes=(
            "2-3 new automated cases land in the same PR / story. "
            "If 0 cases come out, the charter was too narrow — "
            "broaden next time."
        ),
    )


def _areas_for(kind: FeatureKind) -> list[str]:
    base = [
        "Rapid orientation: rotation, app backgrounding, low memory.",
        "Network: offline, throttled, mid-flight cancellation.",
        "Bad data: malformed responses, partial fields, nulls.",
        "Concurrency: rapid taps, navigation while loading.",
    ]
    if kind == FeatureKind.PAYMENT:
        base.append(
            "Idempotency: double-tap submit; tap-back during payment."
        )
        base.append(
            "Edge currencies / decimal precision."
        )
    if kind == FeatureKind.AUTH:
        base.append(
            "Session edge cases: stale token, server clock skew."
        )
    if kind == FeatureKind.LIST:
        base.append(
            "Scroll edges: top/bottom over-scroll, fast fling, "
            "scroll-while-refresh."
        )
    return base


# ============================================================
# Advice builder
# ============================================================


def _build_advice(
    grade: str, ac_count: int, coverage_count: int,
    test_case_count: int, cross_cutting_count: int,
    use_gherkin: bool, reverse_engineered: bool,
) -> str:
    gherkin_note = (
        " Gherkin E2E enabled for business-readable scenarios."
        if use_gherkin else " Gherkin off (dev-heavy team)."
    )
    re_note = (
        " ⚠ ACs were synthesised from the implementation — "
        "confirm with the PO before merge and re-run "
        "design_test_plan against the real ACs."
        if reverse_engineered else ""
    )
    return (
        f"Test plan grade: {grade}. {ac_count} ACs → {test_case_count} "
        f"planned test cases across {coverage_count} ACs. "
        f"{cross_cutting_count} cross-cutting concerns to cover "
        f"(a11y, l10n, lifecycle).{gherkin_note}{re_note}"
    )
