"""
Test runners and health check utilities.

Provides:
- run_command: Execute subprocess commands with timeout
- run_coverage: Run pytest with coverage collection
- run_test_health_check: Validate test infrastructure health
- classify_error: Categorize test failures
- group_failures_by_root_cause: Group related failures
"""

import hashlib
import os
import re
import subprocess
import sys
import tempfile
from pathlib import Path


def get_cache_dir(project_path: str) -> Path:
    """Get cache directory for a project (in system temp)."""
    project_hash = hashlib.md5(project_path.encode()).hexdigest()[:8]
    cache_dir = Path(tempfile.gettempdir()) / "test_analysis" / project_hash
    cache_dir.mkdir(parents=True, exist_ok=True)
    return cache_dir


def run_command(
    cmd: list[str],
    cwd: str | None = None,
    timeout: int = 300,
    env: dict[str, str] | None = None,
) -> tuple[int, str, str]:
    """Run a command and return (returncode, stdout, stderr)."""
    try:
        run_env = None
        if env:
            run_env = os.environ.copy()
            run_env.update(env)
        result = subprocess.run(
            cmd, capture_output=True, text=True, cwd=cwd, timeout=timeout, env=run_env
        )
        return result.returncode, result.stdout, result.stderr
    except subprocess.TimeoutExpired:
        return -1, "", "Command timed out"
    except FileNotFoundError:
        return -1, "", f"Command not found: {cmd[0]}"


def get_coverage_xml_path(project_path: str) -> Path:
    """Get the path for coverage.xml in temp directory."""
    project_hash = hashlib.md5(project_path.encode()).hexdigest()[:8]
    return Path(tempfile.gettempdir()) / f"test_analysis_coverage_{project_hash}.xml"


def detect_modules(project_path: Path) -> list[str]:
    """Detect top-level Python modules in project."""
    modules = []
    src_dirs = [project_path, project_path / "src"]

    for src_dir in src_dirs:
        if not src_dir.exists():
            continue

        for item in src_dir.iterdir():
            if item.name.startswith((".", "_", "test", "spec", "doc")):
                continue
            if item.name in ("venv", "node_modules", "build", "dist", "data"):
                continue

            if item.is_dir() and (item / "__init__.py").exists():
                modules.append(item.name)
            elif item.is_file() and item.suffix == ".py" and item.name != "setup.py":
                modules.append(item.stem)

    return sorted(set(modules))


def run_coverage(
    project_path: str, test_dir: str = "tests"
) -> tuple[Path | None, str | None]:
    """Run pytest with coverage and return (coverage_path, error).

    Uses --cov=. to preserve full file paths in coverage.xml.
    """
    coverage_file = get_coverage_xml_path(project_path)
    cache_dir = get_cache_dir(project_path)

    cmd = [
        sys.executable,
        "-m",
        "pytest",
        test_dir,
        "--cov=.",
        f"--cov-report=xml:{coverage_file}",
        "--cov-branch",
        "-q",
        "--no-header",
        "--timeout=120",
        f"-o=cache_dir={cache_dir / 'pytest_cache'}",
    ]

    env = {"COVERAGE_FILE": str(cache_dir / ".coverage")}
    returncode, _, stderr = run_command(cmd, cwd=project_path, timeout=600, env=env)

    if not coverage_file.exists():
        return None, f"Failed to generate coverage report: {stderr[:200]}"

    return coverage_file, None


# =============================================================================
# Error Classification
# =============================================================================

ERROR_PATTERNS = {
    "database": [
        r"relation .* does not exist",
        r"OperationalError.*could not connect",
        r"ProgrammingError",
        r"IntegrityError",
        r"sqlalchemy\.exc\.",
        r"psycopg2\..*Error",
        r"pymysql\..*Error",
        r"sqlite3\..*Error",
        r"connection refused",
        r"authentication failed",
    ],
    "import": [
        r"ModuleNotFoundError",
        r"ImportError",
        r"No module named",
        r"cannot import name",
    ],
    "fixture": [
        r"fixture .* not found",
        r"ScopeMismatch",
        r"FixtureLookupError",
    ],
    "mock": [
        r"AttributeError.*Mock",
        r"MagicMock.*has no attribute",
        r"patch.*target",
        r"does not have the attribute",
    ],
    "timeout": [
        r"TimeoutError",
        r"timeout",
        r"Timeout",
    ],
    "permission": [
        r"PermissionError",
        r"Permission denied",
        r"EACCES",
    ],
    "network": [
        r"ConnectionError",
        r"ConnectionRefusedError",
        r"URLError",
        r"socket\.error",
        r"requests\.exceptions",
    ],
}


def classify_error(error_text: str) -> list[str]:
    """Classify an error message into categories."""
    categories = []
    for category, patterns in ERROR_PATTERNS.items():
        for pattern in patterns:
            if re.search(pattern, error_text, re.IGNORECASE):
                categories.append(category)
                break
    return categories if categories else ["unknown"]


def extract_root_cause(error_text: str) -> dict | None:
    """Extract the root cause location from a stack trace.

    Returns the deepest non-test, non-stdlib frame that's likely the actual
    cause of the failure.

    Returns:
        dict with keys: file, function, line, context (the failing line)
        or None if can't extract
    """
    frame_pattern = r'File "([^"]+)", line (\d+), in (\w+)'
    frames = re.findall(frame_pattern, error_text)

    if not frames:
        return None

    stdlib_paths = ["site-packages", "lib/python", "pytest", "pluggy", "_pytest"]
    test_patterns = ["test_", "conftest.py", "tests/"]

    candidate = None
    for file_path, line_no, func_name in reversed(frames):
        if any(sp in file_path for sp in stdlib_paths):
            continue
        if any(tp in file_path for tp in test_patterns):
            continue
        candidate = {
            "file": file_path,
            "line": int(line_no),
            "function": func_name,
        }
        break

    if not candidate and frames:
        file_path, line_no, func_name = frames[-1]
        if any(sp not in file_path for sp in stdlib_paths):
            candidate = {
                "file": file_path,
                "line": int(line_no),
                "function": func_name,
            }

    if candidate:
        error_lines = error_text.split("\n")
        for i, line in enumerate(error_lines):
            if candidate["function"] in line and "line" in line.lower():
                for j in range(i + 1, min(i + 3, len(error_lines))):
                    if error_lines[j].strip() and not error_lines[j].startswith("File"):
                        candidate["context"] = error_lines[j].strip()[:200]
                        break
                break

    return candidate


def _guess_patch_target(file_path: str, func_name: str) -> str:
    """Guess the @patch target for a function."""
    parts = file_path.replace("\\", "/").split("/")

    module_parts = []
    in_module = False
    for part in parts:
        if part.endswith(".py"):
            part = part[:-3]
        if in_module:
            module_parts.append(part)
        elif part in ("src", "lib") or (part and not part.startswith(".")):
            if part not in ("home", "usr", "var", "opt", "Users", "projects"):
                in_module = True
                module_parts.append(part)

    if module_parts:
        return ".".join(module_parts) + "." + func_name
    return func_name


def _suggest_fix_for_root_cause(
    location: dict, categories: list, sample_errors: list
) -> dict:
    """Suggest a fix based on the root cause location and error type."""
    file_path = location["file"]
    func_name = location["function"]
    error_text = " ".join(sample_errors)

    if "database" in categories or "OperationalError" in error_text:
        return {
            "type": "add_mock",
            "complexity": "low",
            "action": f"Add mock for database call in {func_name}",
            "patch_target": f"@patch('{_guess_patch_target(file_path, func_name)}')",
            "estimated_effort": "5-10 minutes",
        }

    if "mock" in categories or "AttributeError" in error_text:
        return {
            "type": "fix_mock",
            "complexity": "low",
            "action": f"Fix mock configuration for {func_name}",
            "hint": "Check that the mock is patching the right location (where imported, not defined)",
            "estimated_effort": "5-15 minutes",
        }

    if "import" in categories:
        return {
            "type": "fix_import",
            "complexity": "medium",
            "action": f"Fix import error in {file_path}",
            "hint": "Check dependencies and import paths",
            "estimated_effort": "15-30 minutes",
        }

    if "fixture" in categories:
        return {
            "type": "fix_fixture",
            "complexity": "medium",
            "action": "Fix or add missing fixture",
            "hint": "Check conftest.py for fixture definition",
            "estimated_effort": "10-20 minutes",
        }

    if "network" in categories or "Connection" in error_text:
        return {
            "type": "add_mock",
            "complexity": "low",
            "action": f"Add mock for network call in {func_name}",
            "hint": "Mock the external service or use responses/httpretty",
            "estimated_effort": "10-15 minutes",
        }

    return {
        "type": "investigate",
        "complexity": "unknown",
        "action": f"Investigate {func_name} in {file_path}",
        "hint": "Check the function for unmocked external dependencies",
        "estimated_effort": "unknown",
    }


def group_failures_by_root_cause(failing_tests: list[dict]) -> dict:
    """Group test failures by their root cause.

    Instead of listing failures separately, group them by the common
    function/location that's actually failing.

    Returns:
        dict with keys:
            - root_causes: list of {location, function, tests_affected, categories, suggested_fix}
            - ungrouped: list of failures that couldn't be grouped
            - summary: high-level summary
    """
    cause_groups = {}
    ungrouped = []

    for test_info in failing_tests:
        error_text = test_info.get("error_message", "")
        root_cause = extract_root_cause(error_text)

        if root_cause:
            key = f"{root_cause['file']}:{root_cause['function']}"
            if key not in cause_groups:
                cause_groups[key] = {
                    "location": root_cause,
                    "tests": [],
                    "error_categories": set(),
                    "sample_errors": [],
                }
            cause_groups[key]["tests"].append(test_info["test"])
            cause_groups[key]["error_categories"].update(
                test_info.get("error_categories", [])
            )
            if len(cause_groups[key]["sample_errors"]) < 2:
                cause_groups[key]["sample_errors"].append(error_text[:300])
        else:
            ungrouped.append(test_info)

    root_causes = []
    for key, group in sorted(cause_groups.items(), key=lambda x: -len(x[1]["tests"])):
        loc = group["location"]
        categories = list(group["error_categories"])
        suggested_fix = _suggest_fix_for_root_cause(
            loc, categories, group["sample_errors"]
        )

        root_causes.append(
            {
                "file": loc["file"],
                "function": loc["function"],
                "line": loc["line"],
                "context": loc.get("context", ""),
                "tests_affected": len(group["tests"]),
                "test_names": group["tests"][:5],
                "error_categories": categories,
                "suggested_fix": suggested_fix,
            }
        )

    total_grouped = sum(len(g["tests"]) for g in cause_groups.values())
    summary = {
        "total_failures": len(failing_tests),
        "distinct_root_causes": len(root_causes),
        "grouped_count": total_grouped,
        "ungrouped_count": len(ungrouped),
    }

    if root_causes:
        top_cause = root_causes[0]
        if top_cause["tests_affected"] > len(failing_tests) * 0.5:
            summary["dominant_cause"] = {
                "location": f"{top_cause['file']}:{top_cause['function']}",
                "affects_percent": round(
                    top_cause["tests_affected"] / len(failing_tests) * 100
                ),
                "fix": top_cause["suggested_fix"],
            }

    return {
        "root_causes": root_causes,
        "ungrouped": ungrouped,
        "summary": summary,
    }


def run_test_health_check(project_path: str, test_dir: str = "tests") -> dict:
    """Run pytest and capture test health metrics.

    Returns:
        dict with keys:
            - passed: int
            - failed: int
            - errors: int
            - skipped: int
            - total: int
            - healthy: bool (True if no errors and no failures)
            - coverage_reliable: bool (True if tests ran successfully)
            - failing_tests: list of {test, error_type, error_message}
            - error_categories: dict of category -> count
            - infrastructure_issues: list of detected issues
    """
    cache_dir = get_cache_dir(project_path)

    cmd = [
        sys.executable,
        "-m",
        "pytest",
        test_dir,
        "-v",
        "--tb=short",
        "--timeout=120",
        f"-o=cache_dir={cache_dir / 'pytest_cache'}",
    ]

    env = {"COVERAGE_FILE": str(cache_dir / ".coverage")}
    returncode, stdout, stderr = run_command(
        cmd, cwd=project_path, timeout=600, env=env
    )

    result = {
        "passed": 0,
        "failed": 0,
        "errors": 0,
        "skipped": 0,
        "total": 0,
        "healthy": False,
        "coverage_reliable": False,
        "failing_tests": [],
        "error_categories": {},
        "infrastructure_issues": [],
        "returncode": returncode,
    }

    summary_pattern = r"(\d+)\s+(passed|failed|error|skipped|warnings?)"
    for match in re.finditer(summary_pattern, stdout + stderr, re.IGNORECASE):
        count = int(match.group(1))
        status = match.group(2).lower()
        if status == "passed":
            result["passed"] = count
        elif status == "failed":
            result["failed"] = count
        elif status == "error":
            result["errors"] = count
        elif status == "skipped":
            result["skipped"] = count

    result["total"] = (
        result["passed"] + result["failed"] + result["errors"] + result["skipped"]
    )

    full_output = stdout + "\n" + stderr
    current_test = None
    current_error = []
    in_error_block = False

    for line in full_output.split("\n"):
        if "FAILED" in line or "ERROR" in line:
            test_match = re.search(r"(\S+::\S+)", line)
            if test_match:
                if current_test and current_error:
                    error_text = "\n".join(current_error)
                    categories = classify_error(error_text)
                    result["failing_tests"].append(
                        {
                            "test": current_test,
                            "error_categories": categories,
                            "error_message": error_text[:500],
                        }
                    )
                    for cat in categories:
                        result["error_categories"][cat] = (
                            result["error_categories"].get(cat, 0) + 1
                        )

                current_test = test_match.group(1)
                current_error = []
                in_error_block = True

        elif in_error_block:
            if line.startswith("_") or line.startswith("=") or line.strip() == "":
                if current_error:
                    in_error_block = False
            else:
                current_error.append(line)

    if current_test and current_error:
        error_text = "\n".join(current_error)
        categories = classify_error(error_text)
        result["failing_tests"].append(
            {
                "test": current_test,
                "error_categories": categories,
                "error_message": error_text[:500],
            }
        )
        for cat in categories:
            result["error_categories"][cat] = result["error_categories"].get(cat, 0) + 1

    if result["error_categories"].get("database", 0) > 0:
        result["infrastructure_issues"].append(
            {
                "type": "database",
                "severity": "critical",
                "message": "Tests are hitting real database - missing mocks or fixtures",
                "suggestion": "Add database mocks or use test fixtures",
            }
        )

    if result["error_categories"].get("import", 0) > 0:
        result["infrastructure_issues"].append(
            {
                "type": "import",
                "severity": "critical",
                "message": "Import errors detected - dependencies may be missing",
                "suggestion": "Check that all dependencies are installed in test environment",
            }
        )

    if result["error_categories"].get("fixture", 0) > 0:
        result["infrastructure_issues"].append(
            {
                "type": "fixture",
                "severity": "high",
                "message": "Fixture errors detected - test setup is broken",
                "suggestion": "Check conftest.py for missing or misconfigured fixtures",
            }
        )

    if result["error_categories"].get("mock", 0) > 0:
        result["infrastructure_issues"].append(
            {
                "type": "mock",
                "severity": "high",
                "message": "Mock errors detected - patches may be incomplete",
                "suggestion": "Verify all external dependencies are properly mocked",
            }
        )

    if result["error_categories"].get("network", 0) > 0:
        result["infrastructure_issues"].append(
            {
                "type": "network",
                "severity": "high",
                "message": "Network errors detected - tests may be hitting real services",
                "suggestion": "Mock network calls or use VCR/responses for HTTP mocking",
            }
        )

    if result["failing_tests"]:
        result["failure_analysis"] = group_failures_by_root_cause(
            result["failing_tests"]
        )

        if "dominant_cause" in result["failure_analysis"].get("summary", {}):
            dominant = result["failure_analysis"]["summary"]["dominant_cause"]
            result["infrastructure_issues"].insert(
                0,
                {
                    "type": "single_root_cause",
                    "severity": "critical",
                    "message": f"{dominant['affects_percent']}% of failures share one root cause",
                    "location": dominant["location"],
                    "fix": dominant["fix"],
                },
            )

    result["healthy"] = result["errors"] == 0 and result["failed"] == 0
    result["coverage_reliable"] = (
        result["errors"] == 0
        and result["total"] > 0
        and len(result["infrastructure_issues"]) == 0
    )

    return result
