"""Play execution engine for g-gremlin Autopilot.

Executes plays from YAML definitions with support for both tool-based
and command-based steps.
"""

from __future__ import annotations

import csv
import json
import logging
import shlex
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

from .models import PlayDefinition, PlayRunSummary, StepResult
from .plays import (
    DEFAULT_PLAYS_DIR,
    PlayLoader,
    evaluate_condition,
    render_args,
    render_template,
)

logger = logging.getLogger(__name__)

# Tool-to-command mapping for tool-based plays (backwards compatibility)
TOOL_COMMAND_MAP: dict[str, str] = {
    "normalize_company": "g-gremlin enrich --provider normalize --column {column}",
    "company_to_domain": "g-gremlin enrich --provider domain --column {column} --output-column {output_column}",
    "is_business_email": "g-gremlin enrich --provider email-type --column {column} --output-column {output_column}",
    "dedupe": "g-gremlin dedup plan --key-columns {key_columns} --threshold {threshold}",
    "match_salesforce_account": "g-gremlin sfdc match --column {column} --threshold {threshold}",
    "clean_name": "g-gremlin enrich --provider clean-name --column {column}",
    "extract_domain_from_email": "g-gremlin enrich --provider email-domain --column {column} --output-column {output_column}",
}


class PlayRunner:
    """Executes plays with given parameters."""

    def __init__(self, plays_dir: Path | None = None):
        """Initialize the runner.

        Args:
            plays_dir: Optional custom plays directory.
        """
        self.plays_dir = plays_dir or DEFAULT_PLAYS_DIR

    def run(
        self,
        play_name: str,
        params: dict[str, Any] | None = None,
        output_dir: str | None = None,
        dry_run: bool = False,
    ) -> PlayRunSummary:
        """Execute a play.

        Args:
            play_name: Name of the play to run (can be file name or play name).
            params: User-provided parameters.
            output_dir: Directory for artifacts (auto-generated if not provided).
            dry_run: If True, print commands without executing.

        Returns:
            PlayRunSummary with execution results.

        Raises:
            ValueError: If play not found or missing required credentials/parameters.
        """
        params = params or {}
        started_at = datetime.now(timezone.utc)

        # Load play
        loader = PlayLoader(
            plays_dirs=[self.plays_dir],
            user_credentials=self._get_user_credentials(),
        )
        play = loader.get_play(play_name)
        if not play:
            # Try loading by file name
            play = self._try_load_by_filename(play_name, loader)
        if not play:
            raise ValueError(f"Play not found: {play_name}")

        # Check ALL required credentials
        self._check_credentials(play)

        # Setup output directory (reserved built-in, not a parameter)
        if not output_dir:
            timestamp = started_at.strftime("%Y%m%d_%H%M%S")
            output_dir = f"./artifacts/{self._safe_name(play.name)}/{timestamp}"
        output_path = Path(output_dir)
        logs_path = output_path / "logs"

        if not dry_run:
            output_path.mkdir(parents=True, exist_ok=True)
            logs_path.mkdir(exist_ok=True)

        # Build params with defaults and built-ins
        full_params = self._resolve_params(play, params, str(output_path))

        # Execute steps
        step_results: list[StepResult] = []
        status = "success"
        failed_step: str | None = None

        print(f"Running play: {play.name}")
        print(f"  Output: {output_path}")
        if dry_run:
            print("  Mode: dry-run (preview only)\n")
        else:
            print()

        for step in play.steps:
            step_name = step.name or self._safe_name(step.label)

            # Check condition using safe AST evaluator
            if step.when:
                if not evaluate_condition(step.when, full_params):
                    step_results.append(StepResult(
                        name=step_name,
                        status="skipped",
                        duration_ms=0,
                    ))
                    if not dry_run:
                        print(f"  [SKIP] {step.label} (condition not met)")
                    else:
                        print(f"  [{step_name}] SKIP (condition: {step.when})")
                    continue

            # Build command
            if step.command:
                cmd = render_template(step.command, full_params)
            else:
                # Tool-based: map to CLI command
                cmd = self._tool_to_command(step.tool, step.args, full_params)

            if dry_run:
                shell_mode = "[shell]" if step.shell else ""
                print(f"  [{step_name}] {shell_mode} {cmd}")
                step_results.append(StepResult(
                    name=step_name,
                    status="dry_run",
                    duration_ms=0,
                ))
                continue

            # Execute command
            print(f"  > {step.label}...")
            log_file = logs_path / f"{step_name}.log"

            step_start = datetime.now(timezone.utc)
            try:
                # Use shell=False by default with shlex.split() for security
                if step.shell:
                    result = subprocess.run(
                        cmd,
                        shell=True,
                        capture_output=True,
                        text=True,
                        timeout=600,  # 10 minute timeout
                    )
                else:
                    result = subprocess.run(
                        shlex.split(cmd),
                        shell=False,
                        capture_output=True,
                        text=True,
                        timeout=600,
                    )
                duration_ms = int(
                    (datetime.now(timezone.utc) - step_start).total_seconds() * 1000
                )

                # Write log
                with open(log_file, "w", encoding="utf-8") as f:
                    f.write(f"Command: {cmd}\n")
                    f.write(f"Shell: {step.shell}\n")
                    f.write(f"Exit code: {result.returncode}\n")
                    f.write(f"\n=== STDOUT ===\n{result.stdout}\n")
                    f.write(f"\n=== STDERR ===\n{result.stderr}\n")

                if result.returncode != 0:
                    print(f"    [FAIL] Exit code {result.returncode}")
                    # Show last 50 lines of stderr
                    stderr_lines = result.stderr.strip().split("\n")[-50:]
                    for line in stderr_lines:
                        if line.strip():
                            print(f"      {line}")

                    step_results.append(StepResult(
                        name=step_name,
                        status="failed",
                        duration_ms=duration_ms,
                        error=result.stderr[-500:] if result.stderr else None,
                        log_file=str(log_file),
                    ))
                    status = "failed"
                    failed_step = step_name
                    break  # Stop on first failure
                else:
                    print(f"    [OK] Complete ({duration_ms}ms)")
                    step_results.append(StepResult(
                        name=step_name,
                        status="success",
                        duration_ms=duration_ms,
                        log_file=str(log_file),
                    ))

            except subprocess.TimeoutExpired:
                print("    [FAIL] Timeout after 10 minutes")
                step_results.append(StepResult(
                    name=step_name,
                    status="failed",
                    duration_ms=600000,
                    error="Command timed out after 10 minutes",
                ))
                status = "failed"
                failed_step = step_name
                break

            except Exception as e:
                print(f"    [FAIL] Error: {e}")
                step_results.append(StepResult(
                    name=step_name,
                    status="failed",
                    duration_ms=0,
                    error=str(e),
                ))
                status = "failed"
                failed_step = step_name
                break

        # Collect artifacts using declared outputs (not glob)
        artifacts = self._collect_declared_outputs(play, full_params, output_path)

        warnings: list[str] = []
        empty_csvs: list[str] = []
        if not dry_run:
            empty_csvs = self._find_empty_csvs(output_path)
            for csv_name in empty_csvs:
                print(f"  [WARN] {csv_name} has 0 rows")
                warnings.append(
                    f"{csv_name}: 0 rows extracted (LIMIT may have excluded data or object is empty)"
                )
            if play.category == "crm_documentation":
                self._append_coverage_limitations(
                    output_path=output_path,
                    params=full_params,
                    empty_csvs=empty_csvs,
                )

        # Write run summary
        completed_at = datetime.now(timezone.utc)
        summary = PlayRunSummary(
            play=play.name,
            started_at=started_at.isoformat(),
            completed_at=completed_at.isoformat(),
            status=status,
            output_dir=str(output_path),
            failed_step=failed_step,
            parameters=full_params,
            steps=step_results,
            artifacts=artifacts,
            warnings=warnings,
        )

        if not dry_run:
            summary_path = output_path / "run_summary.json"
            with open(summary_path, "w", encoding="utf-8") as f:
                json.dump(summary.model_dump(), f, indent=2)
            # Add to artifacts if not already there
            if str(summary_path) not in artifacts:
                summary.artifacts.append(str(summary_path))

        return summary

    def _get_user_credentials(self) -> set[str]:
        """Get configured credential types."""
        try:
            from g_gremlin.credentials.provider import CredentialProvider
            provider = CredentialProvider()
            return set(provider.list_configured_integrations())
        except Exception:
            return set()

    def _check_credentials(self, play: PlayDefinition) -> None:
        """Check ALL required credentials are configured."""
        if not play.requires_credentials:
            return
        user_creds = self._get_user_credentials()
        missing = [c for c in play.requires_credentials if c not in user_creds]
        if missing:
            raise ValueError(
                f"Missing required credentials: {', '.join(missing)}\n"
                f"Configure with: g-gremlin auth login --integration <name>"
            )

    def _resolve_params(
        self,
        play: PlayDefinition,
        user_params: dict[str, Any],
        output_dir: str,
    ) -> dict[str, Any]:
        """Resolve parameters with defaults and built-ins."""
        params: dict[str, Any] = {}

        # Add user params and defaults
        for param_def in play.parameters:
            if param_def.name in user_params:
                params[param_def.name] = user_params[param_def.name]
            elif param_def.default is not None:
                params[param_def.name] = param_def.default
            elif param_def.required:
                raise ValueError(f"Missing required parameter: {param_def.name}")

        # Add reserved built-ins (not user-configurable)
        params["output_dir"] = output_dir
        params["play_name"] = play.name
        params["timestamp"] = datetime.now().strftime("%Y%m%d_%H%M%S")

        return params

    def _tool_to_command(
        self,
        tool: str | None,
        args: dict[str, Any],
        params: dict[str, Any],
    ) -> str:
        """Map tool-based step to CLI command."""
        if not tool:
            return "# No tool specified"

        if tool not in TOOL_COMMAND_MAP:
            return f"# Tool '{tool}' not yet mapped to CLI command"

        template = TOOL_COMMAND_MAP[tool]
        # Render args with params
        rendered_args = render_args(args, params)
        # Convert list values to comma-separated strings
        final_args: dict[str, str] = {}
        for k, v in rendered_args.items():
            if isinstance(v, list):
                final_args[k] = ",".join(str(item) for item in v)
            else:
                final_args[k] = str(v)

        try:
            return template.format(**final_args)
        except KeyError as e:
            return f"# Tool '{tool}' missing arg: {e}"

    def _collect_declared_outputs(
        self,
        play: PlayDefinition,
        params: dict[str, Any],
        output_path: Path,
    ) -> list[str]:
        """Collect artifacts by rendering declared outputs, not globbing."""
        artifacts: list[str] = []
        for output_template in play.outputs:
            rendered = render_template(output_template, params)
            path = Path(rendered)
            if path.exists():
                artifacts.append(str(path))
        # Always include run_summary.json if it exists
        summary_path = output_path / "run_summary.json"
        if summary_path.exists() and str(summary_path) not in artifacts:
            artifacts.append(str(summary_path))
        return artifacts

    def _find_empty_csvs(self, output_path: Path) -> list[str]:
        """Return CSV filenames with only headers (0 data rows)."""
        empty_csvs: list[str] = []
        for csv_path in output_path.glob("*.csv"):
            if self._csv_has_no_data(csv_path):
                empty_csvs.append(csv_path.name)
        return empty_csvs

    def _csv_has_no_data(self, csv_path: Path) -> bool:
        """Check whether a CSV has zero data rows (header-only or empty)."""
        try:
            with open(csv_path, newline="", encoding="utf-8") as f:
                reader = csv.reader(f)
                row_count = 0
                for _ in reader:
                    row_count += 1
                    if row_count > 1:
                        return False
            return True
        except OSError:
            return False

    def _append_coverage_limitations(
        self,
        output_path: Path,
        params: dict[str, Any],
        empty_csvs: list[str],
    ) -> None:
        """Append coverage limitations to risks_and_edge_cases.md."""
        risks_path = output_path / "risks_and_edge_cases.md"
        if not risks_path.exists():
            return

        lines = ["", "## Coverage Limitations"]
        for message in self._skipped_optional_extractions(params):
            lines.append(f"- {message}")
        for csv_name in empty_csvs:
            lines.append(
                f"- {csv_name}: 0 rows extracted (LIMIT may have excluded data or object is empty)"
            )
        lines.append("- Managed package configuration may be incomplete (SBQQ, Conga, etc.).")

        with open(risks_path, "a", encoding="utf-8") as f:
            f.write("\n".join(lines) + "\n")

    def _skipped_optional_extractions(self, params: dict[str, Any]) -> list[str]:
        """Return human-readable notes for skipped optional extractions."""
        mappings = {
            "include_legacy_automation": (
                "Legacy automation (Process Builder / Workflow Rules) not extracted "
                "(include_legacy_automation=false)"
            ),
            "include_apex": (
                "Apex classes and triggers not extracted (include_apex=false)"
            ),
            "include_permissions": (
                "Permission sets, profiles, and sharing rules not extracted "
                "(include_permissions=false)"
            ),
            "include_users": (
                "User and queue membership details not extracted (include_users=false)"
            ),
            "include_entitlements": (
                "Entitlements and SLA processes not extracted (include_entitlements=false)"
            ),
            "include_assets": (
                "Asset records not extracted (include_assets=false)"
            ),
            "include_forecasting": (
                "Forecasting configuration not extracted (include_forecasting=false)"
            ),
        }

        skipped: list[str] = []
        for key, message in mappings.items():
            if key in params and params.get(key) is False:
                skipped.append(message)
        return skipped

    def _try_load_by_filename(
        self,
        play_name: str,
        loader: PlayLoader,
    ) -> PlayDefinition | None:
        """Try loading a play by its filename (without .yaml)."""
        # First check all loaded plays
        for play in loader._plays.values():
            if self._safe_name(play.name) == play_name:
                return play
        return None

    def _safe_name(self, name: str) -> str:
        """Convert a name to a safe file/identifier name."""
        return name.lower().replace(" ", "_").replace("-", "_")
