"""
Builds system and user prompts for the Claude agent to generate pytest tests.

Replaces the backend /api/v1/tests/generate-prompt call with a local,
Python-oriented prompt that produces green, lint-clean, type-safe tests.
"""

from __future__ import annotations

import os
import re
import sys
from pathlib import PurePosixPath

from py_agent.models.tested_code import TestedCodeDTO
from py_agent.utils.pytest_config import PytestConfig


def _has_relative_imports(source_code: str) -> bool:
    """Check if source code contains relative imports (from . or from ..)."""
    return bool(re.search(r"^\s*from\s+\.", source_code, re.MULTILINE))


def _is_package(project_root: str, pythonpath_entry: str) -> bool:
    """Check if a pythonpath directory is a Python package (has __init__.py)."""
    init_path = os.path.join(project_root, pythonpath_entry, "__init__.py")
    return os.path.isfile(init_path)


def _has_stdlib_shadowing(project_root: str, pythonpath_entry: str) -> bool:
    """Check if a pythonpath directory contains files that shadow stdlib modules."""
    stdlib_names: frozenset[str] = getattr(sys, "stdlib_module_names", frozenset())
    if not stdlib_names:
        return False
    pp_dir = os.path.join(project_root, pythonpath_entry)
    if not os.path.isdir(pp_dir):
        return False
    for name in os.listdir(pp_dir):
        module_name = name.removesuffix(".py") if name.endswith(".py") else name
        if module_name != name and module_name in stdlib_names:
            return True
        if os.path.isdir(os.path.join(pp_dir, name)) and module_name in stdlib_names:
            return True
    return False


def _adjust_pythonpath_for_relative_imports(
    pythonpath: list[str],
    project_root: str,
    file_code: str | None,
) -> tuple[list[str], bool]:
    """Move pythonpath up one level when entries are packages with relative imports
    or contain files that shadow stdlib modules.

    Returns (adjusted_pythonpath, was_adjusted).
    """
    has_relative = bool(file_code and _has_relative_imports(file_code))

    adjusted = list(pythonpath)
    any_adjusted = False
    for i, pp in enumerate(adjusted):
        should_adjust = (has_relative and _is_package(project_root, pp)) or (
            _has_stdlib_shadowing(project_root, pp)
        )
        if should_adjust:
            parent = str(PurePosixPath(pp).parent)
            if parent != pp and parent != ".":
                adjusted[i] = parent
                any_adjusted = True
    return (adjusted, True) if any_adjusted else (pythonpath, False)


def _extract_import_block(source: str) -> str:
    """Return the import preamble of a Python file (imports + sys.path lines)."""
    lines = source.splitlines()
    last_import_idx = -1
    for i, line in enumerate(lines[:30]):
        stripped = line.strip()
        if stripped.startswith(("import ", "from ")) or "sys.path" in stripped:
            last_import_idx = i
    if last_import_idx < 0:
        return ""
    return "\n".join(lines[: last_import_idx + 1])


def _find_sibling_test_imports(test_file_path: str) -> str | None:
    """Find a sibling test_early_*.py file and return its import block."""
    test_dir = os.path.dirname(test_file_path)
    if not os.path.isdir(test_dir):
        return None
    target_name = os.path.basename(test_file_path)
    siblings = sorted(
        name
        for name in os.listdir(test_dir)
        if name.startswith("test_early_") and name.endswith(".py") and name != target_name
    )
    if not siblings:
        return None
    sibling_path = os.path.join(test_dir, siblings[0])
    try:
        with open(sibling_path, encoding="utf-8", errors="replace") as f:
            content = f.read()
    except OSError:
        return None
    block = _extract_import_block(content)
    return block or None


def _compute_module_path(file_path: str, pythonpath: list[str]) -> str | None:
    """Compute the Python module path for a file relative to pythonpath roots."""
    fp = PurePosixPath(file_path)
    for root in pythonpath:
        rp = PurePosixPath(root)
        try:
            relative = fp.relative_to(rp)
        except ValueError:
            continue
        parts = list(relative.parts)
        if parts and parts[-1].endswith(".py"):
            parts[-1] = parts[-1][:-3]
        if parts and parts[-1] == "__init__":
            parts.pop()
        return ".".join(parts) if parts else None
    return None


# ---------------------------------------------------------------------------
# System prompt
# ---------------------------------------------------------------------------

SYSTEM_PROMPT = """\
You are an expert pytest test generation agent. Your goal is to produce \
7-12 high-quality, passing, type-safe, lint-clean unit tests for a given \
Python function or method.

<workflow>
You follow a strict phased workflow. Always proceed in order:
Phase 0 → 1 → 2 → 3. Phase 3 determines what happens next.

Phase 0: DISCOVER
Phase 1: PLAN
Phase 2: GENERATE
Phase 3: VALIDATE & DECIDE → routes to one of:
  → Done (all green)
  → Phase 4: REPAIR (then back to Phase 3)
  → Phase 5: PRUNE (then done)
</workflow>

<phase name="0-discover">
Read the `<project-setup>` section in the user prompt to learn the project \
commands for running tests, linting, and type-checking.

For each command that is provided, remember it for later phases:
- `testCommand` → run the test file.
- `lintCommand` → lint only the test file.
- `typecheckCommand` → type-check the test file.
- `formatCommand` → format the test file.

These commands are pre-configured and correct. Use them exactly as written. \
Do NOT search for tools, install packages, or modify the commands. \
If a command is listed as missing, skip that check.
</phase>

<phase name="1-plan">
The user prompt contains ALL the context you need: source code, type \
definitions, dependency code, and import paths. Do NOT use Grep, Glob, \
or Read during this phase unless a type referenced in the source code is \
completely absent from the prompt.

Step 1 — Identify behaviour paths:
From the provided source code, map out guards/validations, branches, \
happy paths, edge cases, and error scenarios.

Step 2 — Resolve types:
Check the user prompt for type/class definitions (provided under \
"Dependencies"). Use the import path given in the prompt — do not guess.

Step 3 — Classify dependencies:
Pure data classes (dataclasses, Pydantic models, TypedDict, NamedTuple) \
should be used directly — never mock them. \
Only mock I/O, network calls, and non-deterministic behaviour.

Step 4 — Create test matrix:
One row per distinct behaviour path. Aim for 7-12 test cases covering:
- Happy paths (normal inputs, expected outputs)
- Edge cases (boundary values, empty inputs, None values)
- Error paths (raised exceptions, invalid inputs)
- Dataclass/model behaviour (equality, immutability/mutability, field \
defaults)

Always test through the public API. Never access private/mangled attributes.
</phase>

<phase name="2-generate">
An empty test file has been pre-created (its path is given in the user \
prompt). Use `Write` to create your complete test code in this file.

Rules for the generated code:
- Use `pytest` conventions: plain functions or classes prefixed with \
`Test`, no unittest subclassing.
- Use `pytest.raises` for exception checks.
- Use `pytest.mark.parametrize` when several inputs test the same path.
- Use ONLY the import path given in the prompt — do not guess alternative paths.
- If a sibling test is shown in the user prompt, replicate its import pattern exactly.
- Every test function and helper must have a return type annotation \
(use `-> None` for test functions).
- Every helper function parameter must have a type annotation.
- Keep imports sorted: stdlib first, then third-party, then local \
(standard isort convention). No unused imports.
</phase>

<phase name="3-validate-and-decide">
Step 1 — Run all checks in sequence using `Bash`:
1. `lintCommand` (if available).
2. `testCommand`.
3. `formatCommand` (if available).

IMPORTANT: Run every command exactly as written in `<project-setup>`. \
All commands use relative paths and must be run from the working directory \
(already set for you — do NOT cd elsewhere).

If a command exits non-zero, read the output carefully:
- If pytest collected and ran tests but some failed → that is a TEST failure, \
not an environment problem. Proceed to Step 2 and count results.
- Only if the command itself cannot start (e.g., "command not found", \
"No module named pytest") → that is a config error. Stop using that command.

Never search the filesystem for pytest or try to install it. \
The provided `testCommand` is correct.

Step 2 — Count results:
Classify each test as "green" (passes, lint-clean) or "not green".

Step 3 — Decide:
- ALL tests green → Stop. Done.
- 6 or more tests green but some are not → Phase 5 (PRUNE).
- Fewer than 6 green → Phase 4 (REPAIR), then back to Phase 3.

Follow this decision tree exactly.
</phase>

<phase name="4-repair">
Read the error output carefully.
Use `Grep` / `Glob` to investigate (find definitions, verify paths). \
Fix the issue with `Edit`, then return to Phase 3.
</phase>

<phase name="5-prune">
You have 6+ green tests but some have issues. Pruning is the right choice.

1. Identify every test that is not green.
2. Delete them from the test file with `Edit`.
3. Run `lintCommand` (with --fix) if available.
4. Run the test command once to confirm remaining tests pass.
5. Run `formatCommand` if available.
6. Stop. Done.
</phase>

<tools>
Available tools (prefer the cheapest):
- `Glob`: Find files by pattern. Returns only paths.
- `Grep`: Search file contents. Returns matching lines.
- `Edit`: Precise edits to existing files.
- `Bash`: Run commands (tests, lint, typecheck).
- `Read`: Read full files. Use sparingly.
- `Write`: Create new files. Avoid unless necessary.
</tools>

Start the workflow now with Phase 0, then proceed through all phases.
"""


# ---------------------------------------------------------------------------
# User prompt
# ---------------------------------------------------------------------------


def build_user_prompt(
    tested_code: TestedCodeDTO,
    test_file_path: str,
    rel_test_path: str,
    rel_source_path: str,
    project_root: str,
    pytest_config: PytestConfig,
    test_command: str | None = None,
    lint_command: str | None = None,
    typecheck_command: str | None = None,
    format_command: str | None = None,
) -> str:
    """Build the user prompt with all extracted context."""
    extracted = tested_code.extracted_data
    deps = tested_code.dependencies

    # Compute correct import module path, adjusting for relative imports
    effective_pythonpath = list(pytest_config.pythonpath)
    import_module: str | None = None
    if pytest_config.pythonpath:
        file_code = extracted.file_code if extracted else None
        effective_pythonpath, was_adjusted = _adjust_pythonpath_for_relative_imports(
            pytest_config.pythonpath, project_root, file_code
        )
        import_module = _compute_module_path(rel_source_path, effective_pythonpath)

    parts: list[str] = []

    # --- Project setup ---
    parts.append("<project-setup>")
    if test_command:
        parts.append(f"testCommand: {test_command}")
    else:
        parts.append(
            f"testCommand: python -m pytest {rel_test_path} -v --tb=short --no-header --disable-warnings"
        )
    if lint_command:
        parts.append(f"lintCommand: {lint_command}")
    else:
        parts.append("lintCommand: missing")
    if typecheck_command:
        parts.append(f"typecheckCommand: {typecheck_command}")
    else:
        parts.append("typecheckCommand: missing")
    if format_command:
        parts.append(f"formatCommand: {format_command}")
    else:
        parts.append("formatCommand: missing")
    parts.append("</project-setup>\n")

    # --- Target ---
    kind = (
        "method"
        if tested_code.function_name and "." in (tested_code.module_name or "")
        else "function"
    )
    parts.append("## Target")
    parts.append(f"The tested Python {kind}: `{tested_code.function_name}`")
    parts.append(f"File path: `{rel_source_path}`")
    if import_module:
        parts.append(f"Import as: `from {import_module} import {tested_code.function_name}`")
    parts.append("")

    # --- Source code ---
    if extracted:
        parts.append("## Source Code")
        parts.append(f"```python\n{extracted.function_code}\n```\n")

        if extracted.file_code and extracted.file_code != extracted.function_code:
            parts.append("## Full File Context")
            parts.append(f"```python\n{extracted.file_code}\n```\n")

        # --- Class metadata ---
        if extracted.class_metadata:
            cm = extracted.class_metadata
            parts.append(f"## Class: `{cm.name}`")
            parts.append(f"```python\n{cm.code}\n```\n")
            if cm.constructor_metadata:
                parts.append("### Constructor")
                parts.append(f"```python\n{cm.constructor_metadata.code}\n```\n")

        # --- Parameters ---
        if extracted.parameters:
            parts.append("## Parameters")
            for p in extracted.parameters:
                type_names = ", ".join(t.name for t in p.types) if p.types else "Any"
                opt = " (optional)" if p.is_optional else ""
                parts.append(f"- `{p.name}`: {type_names}{opt}")
            parts.append("")

        # --- Return type ---
        if extracted.return_parameter:
            rp = extracted.return_parameter
            rtype = ", ".join(t.name for t in rp.types) if rp.types else "Any"
            parts.append(f"## Return Type: {rtype}\n")

        # --- Async / Static ---
        flags: list[str] = []
        if extracted.is_async:
            flags.append("async")
        if extracted.is_static:
            flags.append("static")
        if flags:
            parts.append(f"Flags: {', '.join(flags)}\n")

    # --- Dependencies ---
    if deps:
        if deps.code_dependencies:
            parts.append("## Dependencies (source code available)")
            for cd in deps.code_dependencies:
                dep_module: str | None = None
                if effective_pythonpath and cd.import_path:
                    dep_module = _compute_module_path(cd.import_path, effective_pythonpath)
                import_hint = f" — import from `{dep_module}`" if dep_module else ""
                parts.append(f"### `{cd.name}` ({cd.kind}){import_hint}")
                parts.append(f"```python\n{cd.code}\n```\n")

        if deps.missing_dependencies:
            parts.append("## Dependencies (external / stdlib)")
            for md in deps.missing_dependencies:
                source = ""
                if md.is_core_module:
                    source = " (stdlib)"
                parts.append(f"- `{md.name}` ({md.kind}){source}")
                if md.code:
                    parts.append(f"  ```python\n  {md.code}\n  ```")
            parts.append("")

    # --- Import instructions ---
    if import_module:
        parts.append("## Import Instructions")
        parts.append(
            f"The project's pytest configuration adds these directories to "
            f"`sys.path` (pythonpath): {effective_pythonpath}"
        )
        parts.append(
            f"Import the code under test as: `from {import_module} import ...`\n"
            f"Do NOT use longer dotted paths that include parent directories "
            f"above the pythonpath root."
        )
        parts.append("")

    # --- Existing conftest ---
    test_dir = os.path.dirname(test_file_path)
    conftest_path = os.path.join(test_dir, "conftest.py")
    if os.path.isfile(conftest_path):
        with open(conftest_path, encoding="utf-8") as f:
            conftest_content = f.read().strip()
        if conftest_content:
            parts.append("## Existing Conftest")
            parts.append(
                "A `conftest.py` already exists in the test directory with import "
                "setup that pytest will load automatically. Use the same import "
                "patterns it provides — do NOT create a new conftest.py."
            )
            parts.append(f"```python\n{conftest_content}\n```\n")

    # --- Sibling test imports ---
    sibling_imports = _find_sibling_test_imports(test_file_path)
    if sibling_imports:
        parts.append("## Sibling Test (import reference)")
        parts.append(
            "An existing test file in the same directory uses these imports. "
            "Follow the exact same import pattern — do NOT invent a different one."
        )
        parts.append(f"```python\n{sibling_imports}\n```\n")

    # --- Test file ---
    parts.append("## Test File")
    parts.append(
        f"An empty test file has been pre-created at: `{test_file_path}`\n"
        f"Use `Write` to create your complete test code in this file."
    )

    return "\n".join(parts)
