"""AI Test Generation from Replay Transcripts.

This module generates pytest tests from Hikaflow replay data,
turning recorded I/O into reproducible test fixtures.

Key capabilities:
- Generate pytest tests from HTTP recordings
- Generate database fixtures from SQL recordings
- Generate mock fixtures for Redis, MongoDB, etc.
- Create regression tests for fixed bugs
"""

from __future__ import annotations

import json
import os
import re
from dataclasses import dataclass, field
from typing import Any, Dict, List

try:
    import anthropic
    HAS_ANTHROPIC = True
except ImportError:
    HAS_ANTHROPIC = False

try:
    import openai
    HAS_OPENAI = True
except ImportError:
    HAS_OPENAI = False


@dataclass
class GeneratedTest:
    """A generated pytest test."""
    name: str
    code: str
    fixtures: List[str]
    imports: List[str]
    description: str
    confidence: float
    source_run_id: str


@dataclass
class TestGenerationResult:
    """Result of test generation."""
    tests: List[GeneratedTest]
    fixture_code: str
    conftest_additions: str
    errors: List[str] = field(default_factory=list)


# Prompt templates
TEST_GENERATION_PROMPT = """You are an expert Python test engineer. Generate a pytest test based on this recorded I/O transcript.

## Test Context
Test Name: {test_name}
Exception: {exception_type}: {exception_message}
File: {file_path}:{line_number}

## Recorded I/O Transcript

### HTTP Calls
{http_calls}

### SQL Queries
{sql_queries}

### Redis Operations
{redis_ops}

### Time Values
{time_values}

## Instructions

Generate a pytest test that:
1. Mocks all external I/O using the recorded data
2. Reproduces the exact scenario that caused the failure
3. Asserts the expected behavior (or expected exception)
4. Uses appropriate fixtures for database setup

Use these libraries:
- `responses` for HTTP mocking
- `pytest-mock` for general mocking
- `fakeredis` for Redis mocking
- Standard `unittest.mock` for other mocks

Format your response as JSON:
```json
{{
  "test_name": "test_<descriptive_name>",
  "description": "Brief description of what this test verifies",
  "imports": ["import responses", "from unittest.mock import patch"],
  "fixtures": ["db_session", "mock_time"],
  "test_code": "def test_...():\\n    ...",
  "fixture_code": "@pytest.fixture\\ndef ...():\\n    ...",
  "confidence": 0.85
}}
```

Important:
- Make the test deterministic - no random values
- Use the exact recorded values for mocks
- Include appropriate assertions
- Add docstring explaining the test purpose
"""

HTTP_FIXTURE_TEMPLATE = '''@pytest.fixture
def mock_http_{name}():
    """Mock HTTP responses recorded from {source}."""
    with responses.RequestsMock() as rsps:
{mock_lines}
        yield rsps
'''

SQL_FIXTURE_TEMPLATE = '''@pytest.fixture
def db_data_{name}(db_session):
    """Database state from recorded session."""
{setup_lines}
    yield
{teardown_lines}
'''

REDIS_FIXTURE_TEMPLATE = '''@pytest.fixture
def mock_redis_{name}():
    """Mock Redis with recorded state."""
    import fakeredis
    fake = fakeredis.FakeStrictRedis()
{setup_lines}
    yield fake
'''


def _format_http_calls(transcript: Dict[str, Any]) -> str:
    """Format HTTP calls from transcript for prompt."""
    http_calls = transcript.get("http", [])
    if not http_calls:
        return "No HTTP calls recorded."

    lines = []
    for i, call in enumerate(http_calls[:10], 1):  # Limit to 10
        method = call.get("method", "GET")
        url = call.get("url", "?")
        status = call.get("status", "?")
        body = call.get("response_body", "")
        if isinstance(body, (dict, list)):
            body = json.dumps(body, indent=2)[:500]
        elif len(str(body)) > 500:
            body = str(body)[:500] + "..."

        lines.append(f"{i}. {method} {url}")
        lines.append(f"   Status: {status}")
        if body:
            lines.append(f"   Response: {body}")
        lines.append("")

    return "\n".join(lines)


def _format_sql_queries(transcript: Dict[str, Any]) -> str:
    """Format SQL queries from transcript for prompt."""
    sql_queries = transcript.get("sql", [])
    if not sql_queries:
        return "No SQL queries recorded."

    lines = []
    for i, query in enumerate(sql_queries[:10], 1):
        sql = query.get("query", "?")[:200]
        params = query.get("params", [])
        rows = query.get("row_count", "?")

        lines.append(f"{i}. {sql}")
        if params:
            lines.append(f"   Params: {params}")
        lines.append(f"   Rows: {rows}")
        lines.append("")

    return "\n".join(lines)


def _format_redis_ops(transcript: Dict[str, Any]) -> str:
    """Format Redis operations from transcript for prompt."""
    redis_ops = transcript.get("redis", [])
    if not redis_ops:
        return "No Redis operations recorded."

    lines = []
    for i, op in enumerate(redis_ops[:10], 1):
        command = op.get("command", "?")
        key = op.get("key", "?")
        value = str(op.get("value", ""))[:100]

        lines.append(f"{i}. {command} {key}")
        if value:
            lines.append(f"   Value: {value}")
        lines.append("")

    return "\n".join(lines)


def _format_time_values(transcript: Dict[str, Any]) -> str:
    """Format time values from transcript for prompt."""
    time_values = transcript.get("time", [])
    if not time_values:
        return "No time values recorded."

    lines = []
    for i, tv in enumerate(time_values[:5], 1):
        func = tv.get("function", "time")
        value = tv.get("value", "?")
        lines.append(f"{i}. {func}() -> {value}")

    return "\n".join(lines)


def _call_llm(prompt: str, model: str = "claude-sonnet-4-20250514") -> str:
    """Call LLM for test generation."""
    # Prefer DeepSeek/OpenAI-compatible path via central config
    try:
        from autodebug.llm_config import get_llm_config, create_openai_client
        config = get_llm_config()
        if config.is_openai_compatible:
            client = create_openai_client(config)
            response = client.chat.completions.create(
                model=config.default_model,
                max_tokens=4000,
                messages=[{"role": "user", "content": prompt}],
            )
            return response.choices[0].message.content
    except (ImportError, ValueError):
        pass

    if HAS_ANTHROPIC and os.environ.get("ANTHROPIC_API_KEY"):
        client = anthropic.Anthropic()
        response = client.messages.create(
            model=model,
            max_tokens=4000,
            messages=[{"role": "user", "content": prompt}],
        )
        return response.content[0].text
    elif HAS_OPENAI and os.environ.get("OPENAI_API_KEY"):
        client = openai.OpenAI()
        response = client.chat.completions.create(
            model="gpt-5",
            max_tokens=4000,
            messages=[{"role": "user", "content": prompt}],
        )
        return response.choices[0].message.content
    else:
        raise RuntimeError("No LLM API key available (DEEPSEEK_API_KEY, ANTHROPIC_API_KEY, or OPENAI_API_KEY)")


# Dangerous code patterns that should never appear in generated test code.
# Shared with pbt_generator.py -- if this list grows, extract to a shared module.
_DANGEROUS_PATTERNS = [
    "eval(", "exec(", "__import__(", "subprocess",
    "os.system(", "shutil.rmtree(", "os.remove(", "os.rmdir(",
]


def _validate_generated_code(code: str) -> bool:
    """Validate that LLM-generated code is safe and syntactically valid.

    Checks:
        1. Code parses as valid Python (ast.parse)
        2. Code does not contain dangerous patterns

    Args:
        code: Python source code string to validate.

    Returns:
        True if the code passes all checks, False otherwise.
    """
    import ast as _ast

    if not code or not code.strip():
        return True  # Empty code is vacuously valid

    # Check for dangerous patterns
    code_lower = code.lower()
    if any(pat.lower() in code_lower for pat in _DANGEROUS_PATTERNS):
        return False

    # Syntax validation
    try:
        _ast.parse(code)
    except SyntaxError:
        return False

    return True


def _parse_llm_response(response: str) -> Dict[str, Any]:
    """Parse LLM response JSON and validate generated code."""
    # Extract JSON from response
    json_match = re.search(r"```json\s*(.*?)\s*```", response, re.DOTALL)
    if json_match:
        json_str = json_match.group(1)
    else:
        # Try to find raw JSON (non-greedy to avoid over-matching)
        json_match = re.search(r"\{[^}]*\"test_name\"[^}]*\}", response, re.DOTALL)
        if json_match:
            json_str = json_match.group(0)
        else:
            return {}

    try:
        parsed = json.loads(json_str)
    except json.JSONDecodeError:
        return {}

    # Validate generated code fields before returning
    for code_field in ("test_code", "fixture_code"):
        code = parsed.get(code_field, "")
        if code and not _validate_generated_code(code):
            return {}

    return parsed


def generate_http_fixture(
    http_calls: List[Dict[str, Any]],
    name: str = "api",
) -> str:
    """Generate HTTP mock fixture from recorded calls."""
    mock_lines = []
    for call in http_calls:
        method = call.get("method", "GET").lower()
        url = call.get("url", "")
        status = call.get("status", 200)
        body = call.get("response_body", "")

        if isinstance(body, (dict, list)):
            body_str = f"json={json.dumps(body)}"
        else:
            body_str = f"body={repr(str(body))}"

        mock_lines.append(
            f'        rsps.add(responses.{method.upper()}, "{url}", '
            f'{body_str}, status={status})'
        )

    return HTTP_FIXTURE_TEMPLATE.format(
        name=name,
        source="Hikaflow replay",
        mock_lines="\n".join(mock_lines),
    )


def generate_test_from_replay(
    run_id: str,
    transcript: Dict[str, Any],
    context: Dict[str, Any],
) -> GeneratedTest:
    """Generate a pytest test from a replay transcript.

    Args:
        run_id: Hikaflow run ID
        transcript: I/O transcript with http, sql, redis, time keys
        context: Error context with exception info, file, line, etc.

    Returns:
        GeneratedTest with code and fixtures
    """
    # Build prompt
    prompt = TEST_GENERATION_PROMPT.format(
        test_name=context.get("test_name", "unknown_test"),
        exception_type=context.get("exception_type", "Exception"),
        exception_message=context.get("exception_message", "")[:500],
        file_path=context.get("file", "?"),
        line_number=context.get("line", "?"),
        http_calls=_format_http_calls(transcript),
        sql_queries=_format_sql_queries(transcript),
        redis_ops=_format_redis_ops(transcript),
        time_values=_format_time_values(transcript),
    )

    # Generate test
    response = _call_llm(prompt)
    parsed = _parse_llm_response(response)

    if not parsed:
        # Fallback: generate simple test skeleton
        return _generate_fallback_test(run_id, transcript, context)

    return GeneratedTest(
        name=parsed.get("test_name", f"test_replay_{run_id[:8]}"),
        code=parsed.get("test_code", ""),
        fixtures=parsed.get("fixtures", []),
        imports=parsed.get("imports", []),
        description=parsed.get("description", ""),
        confidence=parsed.get("confidence", 0.5),
        source_run_id=run_id,
    )


def _generate_fallback_test(
    run_id: str,
    transcript: Dict[str, Any],
    context: Dict[str, Any],
) -> GeneratedTest:
    """Generate a simple fallback test when LLM fails."""
    test_name = f"test_replay_{run_id[:8]}"
    exception_type = context.get("exception_type", "Exception")

    # Build HTTP mocks
    http_mocks = []
    for call in transcript.get("http", [])[:5]:
        method = call.get("method", "GET")
        url = call.get("url", "")
        status = call.get("status", 200)
        body = json.dumps(call.get("response_body", {}))
        http_mocks.append(
            f'    responses.add(responses.{method}, "{url}", json={body}, status={status})'
        )

    http_mock_code = "\n".join(http_mocks) if http_mocks else "    pass  # No HTTP calls recorded"

    test_code = f'''@responses.activate
def {test_name}():
    """Regression test generated from Hikaflow replay {run_id}.

    Original failure: {exception_type}
    """
    # Setup HTTP mocks from recorded transcript
{http_mock_code}

    # Original error location: {context.get("file", "?")}:{context.get("line", "?")}
    # Replace with actual function call to exercise the error path.
    pytest.skip("Generated stub: implement function_under_test() call")

    # Expected behavior: should not raise {exception_type}
    # with pytest.raises({exception_type}):
    #     function_under_test()
'''

    return GeneratedTest(
        name=test_name,
        code=test_code,
        fixtures=[],
        imports=["import responses", "import pytest"],
        description=f"Regression test for {exception_type}",
        confidence=0.3,
        source_run_id=run_id,
    )


def generate_tests_from_run(
    run_id: str,
    num_tests: int = 1,
) -> TestGenerationResult:
    """Generate tests from a Hikaflow run.

    Args:
        run_id: Hikaflow run ID
        num_tests: Number of test variations to generate

    Returns:
        TestGenerationResult with generated tests and fixtures
    """
    # Fetch run data
    try:
        from api import db
        run = db.get_run(run_id)
        if not run:
            return TestGenerationResult(
                tests=[],
                fixture_code="",
                conftest_additions="",
                errors=[f"Run {run_id} not found"],
            )
    except Exception as e:
        return TestGenerationResult(
            tests=[],
            fixture_code="",
            conftest_additions="",
            errors=[f"Failed to fetch run: {e}"],
        )

    # Build context
    context = {
        "test_name": run.get("test_name"),
        "exception_type": run.get("error_type"),
        "exception_message": run.get("error_message"),
        "file": run.get("error_file"),
        "line": run.get("error_line"),
    }

    # Get transcript
    transcript = run.get("transcript", {})

    # Generate tests
    tests = []
    errors = []

    for i in range(num_tests):
        try:
            test = generate_test_from_replay(run_id, transcript, context)
            tests.append(test)
        except Exception as e:
            errors.append(f"Test generation {i+1} failed: {e}")

    # Generate shared fixture code
    fixture_code = ""
    if transcript.get("http"):
        fixture_code += generate_http_fixture(transcript["http"], "recorded")
        fixture_code += "\n\n"

    # Generate conftest additions
    conftest_additions = """# Add to conftest.py for Hikaflow-generated tests

import pytest
import responses

@pytest.fixture(autouse=True)
def reset_responses():
    \"\"\"Reset responses mock between tests.\"\"\"
    responses.reset()
    yield
    responses.reset()
"""

    return TestGenerationResult(
        tests=tests,
        fixture_code=fixture_code,
        conftest_additions=conftest_additions,
        errors=errors,
    )


def format_test_file(result: TestGenerationResult) -> str:
    """Format generated tests as a complete Python test file.

    Args:
        result: TestGenerationResult from generate_tests_from_run

    Returns:
        Complete test file content
    """
    # Collect all imports
    all_imports = set()
    all_imports.add("import pytest")

    for test in result.tests:
        for imp in test.imports:
            all_imports.add(imp)

    # Build file content
    lines = [
        '"""Auto-generated tests from Hikaflow replay data.',
        "",
        "These tests reproduce recorded I/O scenarios to verify fixes.",
        '"""',
        "",
        *sorted(all_imports),
        "",
        "",
    ]

    # Add fixture code
    if result.fixture_code:
        lines.append("# Generated fixtures")
        lines.append(result.fixture_code)
        lines.append("")

    # Add tests
    for test in result.tests:
        lines.append(f"# {test.description}")
        lines.append(f"# Confidence: {test.confidence:.0%}")
        lines.append(f"# Source: run {test.source_run_id}")
        lines.append(test.code)
        lines.append("")
        lines.append("")

    return "\n".join(lines)
