"""Pre-run review using Claude Agent SDK.

Reviews experiment code before execution to catch errors early.
This is like a "run request" review - similar to PR review but for ML runs.
"""

from __future__ import annotations

import asyncio
import logging
import re
import time
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING

from goldfish.models import ReviewIssue, ReviewSeverity, RunReason, RunReview
from goldfish.svs.agent import (
    ReviewRequest,
    ToolPolicy,
    get_agent_provider,
)

if TYPE_CHECKING:
    from goldfish.config import PreRunReviewConfig
    from goldfish.db.database import Database
    from goldfish.svs.config import SVSConfig

logger = logging.getLogger(__name__)

# Security limits
MAX_FILE_SIZE = 100_000  # 100KB per file
MAX_TOTAL_CONTEXT_SIZE = 500_000  # 500KB total context
MAX_CONTENTS_ITEMS = 1000  # Max items in input contents listing (avoids blowing context budget)


def escape_for_prompt(text: str) -> str:
    """Escape special XML/HTML characters in user content for safe prompt inclusion.

    Prevents XML/HTML injection attacks by escaping special characters that could
    be interpreted as markup. This ensures user-provided content (file contents,
    descriptions, configs) cannot inject malicious tags into AI prompts.

    The escaping order is important:
    1. & must be escaped first, otherwise &lt; would become &amp;lt;
    2. Then < > " ' are escaped

    Args:
        text: User-provided text that may contain special characters

    Returns:
        Text with XML special characters escaped:
        - & -> &amp;
        - < -> &lt;
        - > -> &gt;
        - " -> &quot;
        - ' -> &#39;

    Examples:
        >>> escape_for_prompt("if x < 5:")
        'if x &lt; 5:'
        >>> escape_for_prompt('name = "value"')
        'name = &quot;value&quot;'
    """
    # Escape & first to avoid double-escaping other entities
    text = text.replace("&", "&amp;")
    # Then escape the other special characters
    text = text.replace("<", "&lt;")
    text = text.replace(">", "&gt;")
    text = text.replace('"', "&quot;")
    text = text.replace("'", "&#39;")
    return text


# Review prompt template
REVIEW_PROMPT = """You are reviewing an ML experiment before execution. Your job is to catch errors that would waste compute time.

## Context

**Workspace:** {workspace}
**Stages to run:** {stages_to_run}

### Pipeline Definition (pipeline.yaml)
```yaml
{pipeline_yaml}
```

### Experiment Hypothesis
{run_reason}

### Code Changes Since Last Successful Run
```diff
{diff_text}
```

### Input Resolution (resolved sources)
{input_resolution}

{stage_sections}

## Your Task

Review ONLY the stages being run: {stages_to_run}

Check each stage for:
1. **Syntax errors** - missing imports, typos, invalid Python
2. **Logic bugs** - wrong dimensions, off-by-one errors, incorrect APIs
3. **Config issues** - invalid hyperparameters (negative lr, etc.), mismatched shapes
4. **Pipeline issues** - missing inputs, type mismatches between stages
5. **Hypothesis coherence** - does the code actually test what's claimed?
6. **Key/column name mismatches** - When code accesses arrays or dataframes with dynamically
   constructed keys (f-strings, loop variables), trace the actual key values. For `from_stage`
   inputs, use the Read tool to check `modules/<upstream_stage>.py` or `.rs` for actual output key names.
   Pay special attention to train/val/test split naming inconsistencies (e.g., 'valid' vs 'val').
7. **Input freshness** - If a `from_stage` input resolves to an older run while a newer run of
   that upstream stage is currently RUNNING or POST_RUN, this is a BLOCKING error. The run
   should be paused/canceled and re-run after the newer upstream run completes, or use an
   explicit inputs_override to pin the intended run.
8. **Output format/API mismatch** - `save_output()` only supports `npy` (numpy) and `csv` (pandas)
   formats. For `file` or `directory` output types declared in pipeline.yaml, the code MUST use
   `get_output_path()` instead and save manually. Using `save_output()` with file/directory formats
   will raise ValueError at runtime. This is a BLOCKING error.
9. **goldfish.io function signatures** - Check exact signatures:
   - `get_output_path(name)` - takes 1 arg, returns Path (directory). NOT `get_output_path(name, filename)`!
   - `get_input_path(name)` - takes 1 arg, returns Path. NOT `get_input_path(name, filename)`!
   - `save_output(name, data, artifact=False)` - 2-3 args
   - `load_input(name, format=None)` - 1-2 args
   Wrong argument count is a BLOCKING error (TypeError at runtime).
10. **Storage vs. Code Mismatch** - Verify code logic against the `storage_location` and `contents` listing in Input Resolution.
    - If an input resolves to a directory (indicated by a trailing `/` in `storage_location` or multiple files in `contents`) but the code assumes it is a file (e.g., using `open(path)`), this is a BLOCKING ERROR.
    - If the code looks for a specific filename inside an input directory that is NOT present in the `contents` listing, flag it as a BLOCKING ERROR.
    - `load_input()` returns the path exactly as shown in `storage_location`.
11. **Config Type Safety** - Check for type mismatches in configuration usage.
    - If code uses a config value in a mathematical operation or API that expects a number (e.g., `optimizer(lr=lr)`), verify it is cast to float/int.
    - Config values from `get_config()` or environment variables may be strings (e.g., "3e-4").
    - Usage like `lr = config['lr']` followed by `Adam(lr=lr)` without `float()` cast is a BLOCKING ERROR if the config source is not typed.
12. **Metrics instrumentation** - Training stages MUST call `log_metric()` or `log_metrics()` from `goldfish.io`.
    - Without metrics logging, Goldfish cannot monitor training progress or trigger during-run AI reviews.
    - If a training stage (stages with training loops, loss computation, epochs) does NOT import and call
      `log_metric()` or `log_metrics()`, this is a BLOCKING ERROR.
    - At minimum, training stages should log: loss values per step/epoch, learning rate, and validation metrics.
    - Non-training stages (preprocessing, data loading) are exempt from this requirement.

## Output Format

CRITICAL: Only use ERROR for issues that WILL DEFINITELY cause the run to fail. Do NOT use ERROR to discuss potential issues that you then explain are actually fine.

Severity markers (use exactly these prefixes):
- ERROR: Use ONLY for definite blocking issues - code that WILL crash or produce wrong results with THIS config
- WARNING: Potential problems that MAY cause issues depending on runtime conditions
- NOTE: Suggestions, observations, or things you checked that are fine

Decision rules:
- If you investigate something and determine it's NOT a problem, either skip it or use NOTE
- If an issue only affects code paths not taken by this config, use WARNING not ERROR
- ERROR means "stop the run" - only use it when you're certain

Format for each stage:
```
## stage_name
ERROR: file.py:line - definite failure reason
WARNING: file.py - potential concern
NOTE: observation
```

If a stage looks good:
```
## stage_name
No issues found.
```

## Final Decision (REQUIRED)

After your analysis, you MUST end with exactly one of these lines:

```
DECISION: APPROVE
```
Use when no blocking errors found. Warnings and notes are fine.

```
DECISION: BLOCK
```
Use ONLY when there are definite errors that WILL cause the run to fail.

Start your review now:
"""

STAGE_SECTION_TEMPLATE = """
### Stage: {stage_name}

#### Module: {module_path}
```{module_lang}
{module_content}
```

#### Config: configs/{stage_name}.yaml (if exists)
```yaml
{config_content}
```
{config_override_section}
"""


class PreRunReviewer:
    """Reviews experiment runs before execution using unified agent abstraction."""

    def __init__(
        self,
        config: PreRunReviewConfig,
        svs_config: SVSConfig,
        workspace_path: Path,
        dev_repo_path: Path,
        db: Database | None = None,
    ):
        """Initialize the reviewer.

        Args:
            config: Pre-run review configuration
            svs_config: SVS configuration (for agent settings)
            workspace_path: Path to the user's workspace (slot)
            dev_repo_path: Path to the dev repository
            db: Database for looking up last successful run
        """
        self.config = config
        self.svs_config = svs_config
        self.workspace_path = workspace_path
        self.dev_repo_path = dev_repo_path
        self.db = db

    def _get_agent(self):
        """Get the configured SVS agent provider.

        Uses get_agent_provider() which respects the GOLDFISH_SVS_AGENT_PROVIDER
        environment variable for runtime overrides.
        """
        return get_agent_provider(self.svs_config.agent_provider)

    async def review(
        self,
        stages: list[str],
        reason: RunReason | None = None,
        diff_text: str = "",
        input_context: list[dict] | None = None,
        config_override: dict | None = None,
    ) -> RunReview:
        """Review stages before execution.

        Args:
            stages: List of stage names to review
            reason: The RunReason explaining why this is being run
            diff_text: Git diff since last successful run
            input_context: Resolved input metadata (for staleness checks)
            config_override: Runtime config overrides (will take precedence over static config)

        Returns:
            RunReview with findings
        """
        start_time = time.time()

        # Gather context
        pipeline_yaml = self._read_pipeline_yaml()
        stage_sections = self._build_stage_sections(stages, config_override)
        input_resolution = self._format_input_resolution(input_context)
        run_reason_text = reason.to_markdown() if reason else "No reason provided"

        # Build prompt with escaped content
        prompt = REVIEW_PROMPT.format(
            workspace=escape_for_prompt(self.workspace_path.name),
            stages_to_run=escape_for_prompt(", ".join(stages)),
            pipeline_yaml=escape_for_prompt(pipeline_yaml),
            run_reason=escape_for_prompt(run_reason_text),
            diff_text=escape_for_prompt(diff_text or "No changes (first run or diff unavailable)"),
            input_resolution=escape_for_prompt(input_resolution),
            stage_sections=stage_sections,  # Content within sections is already escaped by _build_stage_sections
        )

        # Add test mode instructions if enabled
        if self.svs_config.test_mode:
            test_mode_instructions = """
## TEST MODE ENABLED
This is a test run to verify the AI review system works. You MUST:
1. Always provide at least one finding for each stage, even if just a NOTE-level observation
2. Look for [SVS-TEST] markers in code/logs - these indicate intentional test triggers that you should comment on
3. Be verbose about what you observe - this is testing the review pipeline
4. Comment on code quality, structure, or suggestions even if not errors
"""
            prompt = test_mode_instructions + prompt

        # Enforce total context size limit
        if len(prompt) > MAX_TOTAL_CONTEXT_SIZE:
            logger.warning(f"Review context too large ({len(prompt)} bytes), truncating to {MAX_TOTAL_CONTEXT_SIZE}")
            # Truncate with indication that content was cut
            prompt = prompt[: MAX_TOTAL_CONTEXT_SIZE - 100] + "\n\n[... context truncated due to size limit ...]"

        # Call the agent provider
        try:
            agent = self._get_agent()

            # Pre-run tools: read-only, bypass permission prompts
            tool_policy = ToolPolicy(
                permission_mode="bypassPermissions",
                allow_tools=["Read", "Glob", "Grep"],
            )

            # Build agent request
            request = ReviewRequest(
                review_type="pre_run",
                context={
                    "cwd": str(self.workspace_path),
                    "model": self.svs_config.agent_model or self.config.model,
                    "fallback_model": self.svs_config.agent_fallback_model,
                    "max_turns": self.config.max_turns,
                    "timeout_seconds": self.config.timeout_seconds,
                    "tool_policy": tool_policy,
                    "prompt": prompt,
                },
            )

            # run() is sync in the provider, we run it in a thread if needed
            # but since we are in an async method, we'll wrap it
            loop = asyncio.get_running_loop()
            result = await asyncio.wait_for(
                loop.run_in_executor(None, agent.run, request),
                timeout=self.config.timeout_seconds,
            )

            raw_response = getattr(result, "response_text", None)
            if not raw_response:
                raw_response = getattr(result, "raw_output", "")
            review_text = raw_response if isinstance(raw_response, str) else ""
            logger.info(
                "Pre-run review completed: %d chars in %dms",
                len(review_text),
                getattr(result, "duration_ms", 0),
            )
        except TimeoutError:
            logger.error("Pre-run review timed out after %ds", self.config.timeout_seconds)
            return RunReview(
                approved=True,  # Don't block on timeout
                summary=f"Review timed out after {self.config.timeout_seconds}s",
                full_review="",
                reviewed_stages=stages,
            )
        except (KeyboardInterrupt, SystemExit, asyncio.CancelledError):
            # Let these propagate - user cancellation should work
            raise
        except Exception as e:
            logger.exception("Pre-run review failed: %s", e)
            return RunReview(
                approved=True,  # Don't block on review failures
                summary=f"Review failed: {e}",
                full_review="",
                reviewed_stages=stages,
            )

        # Parse the review
        issues = self._parse_review(review_text, stages)
        auto_issues = self._detect_stale_inputs(input_context, stages)
        if auto_issues:
            issues.extend(auto_issues)

        # Parse explicit decision from review text (authoritative)
        explicit_decision = self._parse_explicit_decision(review_text)

        # Determine approval: explicit decision takes precedence, then fall back to ERROR markers
        if explicit_decision == "block":
            approved = False
        elif explicit_decision == "approve":
            approved = True
        else:
            # No explicit decision - fall back to ERROR marker inference
            # (This handles legacy reviews or reviews that don't follow the format)
            approved = not any(i.severity == ReviewSeverity.ERROR for i in issues)
            if not approved:
                logger.warning("No explicit DECISION found in review, falling back to ERROR marker inference")

        elapsed_ms = int((time.time() - start_time) * 1000)

        # Build summary
        error_count = sum(1 for i in issues if i.severity == ReviewSeverity.ERROR)
        warning_count = sum(1 for i in issues if i.severity == ReviewSeverity.WARNING)

        if not approved:
            summary = f"Review blocked: {error_count} error(s), {warning_count} warning(s)"
        elif warning_count > 0:
            summary = f"Review passed with {warning_count} warning(s)"
        else:
            summary = "Review passed: no issues found"

        return RunReview(
            approved=approved,
            issues=issues,
            summary=summary,
            full_review=review_text,
            reviewed_stages=stages,
            review_time_ms=elapsed_ms,
        )

    def _read_pipeline_yaml(self) -> str:
        """Read pipeline.yaml from workspace."""
        pipeline_path = self.workspace_path / "pipeline.yaml"
        return self._read_file_safe(pipeline_path, "# No pipeline.yaml found")

    def _format_input_resolution(self, input_context: list[dict] | None) -> str:
        """Format resolved input context for prompt inclusion."""
        if not input_context:
            return "No resolved input context provided."

        lines = []
        for ctx in input_context:
            lines.append(f"- input: {ctx.get('input')}")
            lines.append(f"  source_type: {ctx.get('source_type')}")
            if ctx.get("from_stage"):
                lines.append(f"  from_stage: {ctx.get('from_stage')}")
                lines.append(f"  signal: {ctx.get('signal')}")
            if ctx.get("dataset"):
                lines.append(f"  dataset: {ctx.get('dataset')}")
            if ctx.get("override"):
                lines.append(f"  override: {ctx.get('override')}")
            if ctx.get("selected_run_id"):
                lines.append(f"  selected_run_id: {ctx.get('selected_run_id')}")
                lines.append(f"  selected_run_state: {ctx.get('selected_run_state')}")
                lines.append(f"  selected_run_started_at: {ctx.get('selected_run_started_at')}")
                lines.append(f"  selected_run_outcome: {ctx.get('selected_run_outcome')}")
            if ctx.get("latest_run_id"):
                lines.append(f"  latest_run_id: {ctx.get('latest_run_id')}")
                lines.append(f"  latest_run_state: {ctx.get('latest_run_state')}")
                lines.append(f"  latest_run_started_at: {ctx.get('latest_run_started_at')}")
                lines.append(f"  latest_run_outcome: {ctx.get('latest_run_outcome')}")

            if ctx.get("storage_location"):
                lines.append(f"  storage_location: {ctx.get('storage_location')}")

            contents = ctx.get("contents")
            if contents:
                lines.append(f"  contents: ({len(contents)} items)")
                for item in contents[:MAX_CONTENTS_ITEMS]:
                    lines.append(f"    - {item}")
                if len(contents) > MAX_CONTENTS_ITEMS:
                    lines.append(
                        f"    - ... ({len(contents) - MAX_CONTENTS_ITEMS} more items NOT SHOWN — "
                        f"do NOT treat unlisted files as absent)"
                    )

        return "\n".join(lines)

    def _detect_stale_inputs(self, input_context: list[dict] | None, stages: list[str]) -> list[ReviewIssue]:
        """Detect stale from_stage inputs when a newer upstream run is active."""
        if not input_context:
            return []

        issues: list[ReviewIssue] = []
        default_stage = stages[0] if stages else "unknown"

        for ctx in input_context:
            if ctx.get("source_type") != "stage":
                continue

            # Ignore explicit overrides
            if ctx.get("override"):
                continue

            selected_id = ctx.get("selected_run_id")
            latest_id = ctx.get("latest_run_id")
            if not selected_id or not latest_id or selected_id == latest_id:
                continue

            latest_state = str(ctx.get("latest_run_state") or "").lower()

            # Terminal states - newer run is done, not stale
            terminal_states = {"completed", "failed", "canceled", "terminated"}
            if latest_state in terminal_states:
                continue

            # Active states where newer run is still in progress
            active_states = {"preparing", "building", "launching", "running", "finalizing"}
            if latest_state not in active_states:
                continue

            selected_started = ctx.get("selected_run_started_at")
            latest_started = ctx.get("latest_run_started_at")
            try:
                if selected_started and latest_started:
                    selected_dt = datetime.fromisoformat(str(selected_started))
                    latest_dt = datetime.fromisoformat(str(latest_started))
                    if latest_dt <= selected_dt:
                        continue
            except ValueError:
                # If timestamps are malformed, still surface warning
                pass

            stage_name = ctx.get("consumer_stage") or default_stage
            message = (
                f"Input '{ctx.get('input')}' resolved to run {selected_id} from stage "
                f"'{ctx.get('from_stage')}', but a newer run {latest_id} is still "
                f"{latest_state}. "
                "Wait for the newer run to complete or use inputs_override to pin the intended run."
            )
            issues.append(
                ReviewIssue(
                    severity=ReviewSeverity.ERROR,
                    stage=str(stage_name),
                    message=message,
                )
            )

        return issues

    def _build_stage_sections(self, stages: list[str], config_override: dict | None = None) -> str:
        """Build detailed sections for each stage being reviewed."""
        import yaml

        sections = []
        included_related_files: set[str] = set()  # Track already included files

        for stage in stages:
            # Validate stage name to prevent path traversal
            if not self._is_safe_filename(stage):
                logger.warning(f"Skipping unsafe stage name: {stage}")
                continue

            module_path = self.workspace_path / "modules" / f"{stage}.py"
            module_lang = "python"
            if not module_path.exists():
                rust_path = self.workspace_path / "modules" / f"{stage}.rs"
                if rust_path.exists():
                    module_path = rust_path
                    module_lang = "rust"
            config_path = self.workspace_path / "configs" / f"{stage}.yaml"

            module_content = self._read_file_safe(module_path, "# Module not found")
            config_content = self._read_file_safe(config_path, "# No config file")

            # Build config_override section if there are overrides for this stage
            config_override_section = ""
            if config_override:
                # config_override can be {key: value} for single stage or {stage: {key: value}} for multi-stage
                stage_overrides = config_override.get(stage, {})
                if not stage_overrides and len(stages) == 1:
                    # Single stage run - overrides apply directly
                    stage_overrides = config_override
                if stage_overrides:
                    override_yaml = yaml.safe_dump(stage_overrides, default_flow_style=False)
                    config_override_section = f"""
#### Runtime Config Override (TAKES PRECEDENCE over static config)
```yaml
{escape_for_prompt(override_yaml.strip())}
```
"""

            section = STAGE_SECTION_TEMPLATE.format(
                stage_name=escape_for_prompt(stage),
                module_path=escape_for_prompt(str(module_path.relative_to(self.workspace_path))),
                module_lang=module_lang,
                module_content=escape_for_prompt(module_content),
                config_content=escape_for_prompt(config_content),
                config_override_section=config_override_section,
            )
            sections.append(section)

            # Include related local imports (model files, utils, etc.)
            if module_lang == "python":
                related_files = self._extract_local_imports(module_content)
                for rel_file in related_files:
                    if rel_file in included_related_files:
                        continue
                    included_related_files.add(rel_file)
                    rel_path = self.workspace_path / "modules" / rel_file
                    if rel_path.exists():
                        rel_content = self._read_file_safe(rel_path, "# File not found")
                        sections.append(f"""
#### Related Module: modules/{escape_for_prompt(rel_file)}
```python
{escape_for_prompt(rel_content)}
```
""")

        return "\n".join(sections)

    def _extract_local_imports(self, module_content: str) -> list[str]:
        """Extract local module imports from Python code.

        Looks for patterns like:
        - from .module_name import ...
        - from . import module_name

        Returns list of filenames (e.g., ['byte_model.py', 'utils.py'])
        """
        files = []
        # Match: from .module_name import ... or from . import module_name
        import_pattern = re.compile(r"from\s+\.(\w+)\s+import|from\s+\.\s+import\s+(\w+)")
        for match in import_pattern.finditer(module_content):
            module_name = match.group(1) or match.group(2)
            if module_name and self._is_safe_filename(module_name):
                files.append(f"{module_name}.py")
        return files

    def _is_safe_filename(self, name: str) -> bool:
        """Check if filename is safe (no path traversal)."""
        # Reject empty, dots only, or anything with path separators
        if not name or name in (".", ".."):
            return False
        if "/" in name or "\\" in name:
            return False
        # Reject hidden files
        if name.startswith("."):
            return False
        return True

    def _read_file_safe(self, file_path: Path, default: str) -> str:
        """Safely read a file with security and size checks.

        Args:
            file_path: Path to read
            default: Default value if file cannot be read

        Returns:
            File contents or default value
        """
        try:
            # Resolve to absolute path
            resolved = file_path.resolve()

            # SECURITY: Ensure path is within workspace
            try:
                resolved.relative_to(self.workspace_path.resolve())
            except ValueError:
                logger.warning(f"Path traversal attempt blocked: {file_path}")
                return default

            # SECURITY: Check for symlinks
            if file_path.is_symlink():
                logger.warning(f"Symlink blocked in review: {file_path}")
                return "# Symlink detected - not reading for security"

            if not resolved.exists():
                return default

            # SECURITY: Check file size before reading
            file_size = resolved.stat().st_size
            if file_size > MAX_FILE_SIZE:
                logger.warning(f"File too large for review: {file_path} ({file_size} bytes)")
                return f"# File too large ({file_size} bytes, max {MAX_FILE_SIZE})"

            # Read with explicit encoding
            return resolved.read_text(encoding="utf-8")

        except UnicodeDecodeError:
            logger.warning(f"File has invalid encoding: {file_path}")
            return "# File contains invalid UTF-8 encoding"
        except PermissionError:
            logger.warning(f"Permission denied reading: {file_path}")
            return "# Permission denied"
        except OSError as e:
            logger.warning(f"OS error reading {file_path}: {e}")
            return default

    def _parse_explicit_decision(self, review_text: str) -> str | None:
        """Parse explicit DECISION directive from review text.

        Looks for lines like:
        - DECISION: APPROVE
        - DECISION: BLOCK

        Returns:
            "approve", "block", or None if no explicit decision found
        """
        for line in review_text.split("\n"):
            line = line.strip().upper()
            if line.startswith("DECISION:"):
                decision_part = line[9:].strip()
                if decision_part.startswith("APPROVE"):
                    return "approve"
                elif decision_part.startswith("BLOCK"):
                    return "block"
        return None

    def _parse_review(self, review_text: str, stages: list[str]) -> list[ReviewIssue]:
        """Parse Claude's review text into structured ReviewIssues.

        Expected format:
        ## stage_name
        ERROR: file.py:line - description
        WARNING: file.py - description
        NOTE: suggestion
        """
        issues: list[ReviewIssue] = []
        current_stage: str | None = None

        # Create a set of lowercase stage names for flexible matching
        stage_set = {s.lower() for s in stages}
        stage_map = {s.lower(): s for s in stages}  # Map lowercase to original

        for line in review_text.split("\n"):
            line = line.strip()
            if not line:
                continue

            # Check for stage header - more flexible matching
            # Handles: ## train, ### train, ## train (details), ## Stage: train
            if line.startswith("#"):
                # Strip all leading # and whitespace
                header_content = line.lstrip("#").strip()
                # Remove common prefixes like "Stage:" or "Stage "
                header_content = re.sub(r"^(?:stage\s*:?\s*)", "", header_content, flags=re.IGNORECASE)
                # Extract first word/identifier (the stage name)
                match = re.match(r"^(\S+)", header_content)
                if match:
                    potential_stage = match.group(1).lower()
                    if potential_stage in stage_set:
                        current_stage = stage_map[potential_stage]
                continue

            if current_stage is None:
                continue

            # Parse issue lines - handle various formats
            severity: ReviewSeverity | None = None
            content = ""

            # Strip markdown bold markers and bullets for detection
            # Handles: **ERROR: ...**, **ERROR:** ..., ERROR: ..., - ERROR: ...
            check_line = line.lstrip("-").lstrip().lstrip("*").lstrip()
            lower_check = check_line.lower()

            if lower_check.startswith("error:"):
                severity = ReviewSeverity.ERROR
                # Extract content after the marker, handling trailing **
                content = check_line[6:].strip().rstrip("*").strip()
            elif lower_check.startswith("warning:"):
                severity = ReviewSeverity.WARNING
                content = check_line[8:].strip().rstrip("*").strip()
            elif lower_check.startswith("warn:"):
                severity = ReviewSeverity.WARNING
                content = check_line[5:].strip().rstrip("*").strip()
            elif lower_check.startswith("note:"):
                severity = ReviewSeverity.NOTE
                content = check_line[5:].strip().rstrip("*").strip()

            if severity and content:
                issue = self._parse_issue_content(severity, current_stage, content)
                issues.append(issue)

        return issues

    def _parse_issue_content(self, severity: ReviewSeverity, stage: str, content: str) -> ReviewIssue:
        """Parse the content part of an issue line.

        Handles formats like:
        - file.py:10 - description
        - file.py - description
        - description only
        """
        # Extended file extensions pattern
        file_ext_pattern = r"(?:py|yaml|yml|json|txt|sh|md|toml|cfg|ini|csv)"

        # Try to extract file:line - description
        file_line_match = re.match(rf"([^\s:]+\.{file_ext_pattern}):(\d+)\s*[-:]\s*(.*)", content)
        if file_line_match:
            return ReviewIssue(
                severity=severity,
                stage=stage,
                file=file_line_match.group(1),
                line=int(file_line_match.group(2)),
                message=file_line_match.group(3) or content,
            )

        # Try to extract file - description (no line number)
        file_only_match = re.match(rf"([^\s:]+\.{file_ext_pattern})\s*[-:]\s*(.*)", content)
        if file_only_match:
            return ReviewIssue(
                severity=severity,
                stage=stage,
                file=file_only_match.group(1),
                message=file_only_match.group(2) or content,
            )

        # No file reference, just the message
        return ReviewIssue(
            severity=severity,
            stage=stage,
            message=content,
        )


async def review_before_run(
    config: PreRunReviewConfig,
    svs_config: SVSConfig,
    workspace_path: Path,
    dev_repo_path: Path,
    stages: list[str],
    reason: RunReason | None = None,
    diff_text: str = "",
    input_context: list[dict] | None = None,
    db: Database | None = None,
    config_override: dict | None = None,
) -> RunReview:
    """Convenience function to perform pre-run review.

    Args:
        config: Pre-run review configuration
        svs_config: SVS configuration
        workspace_path: Path to the user's workspace (slot)
        dev_repo_path: Path to the dev repository
        stages: List of stage names to review
        reason: The RunReason explaining why this is being run
        diff_text: Git diff since last successful run
        input_context: Resolved input metadata for staleness checks
        db: Database for looking up last successful run
        config_override: Runtime config overrides passed to run()

    Returns:
        RunReview with findings
    """
    reviewer = PreRunReviewer(
        config=config,
        svs_config=svs_config,
        workspace_path=workspace_path,
        dev_repo_path=dev_repo_path,
        db=db,
    )
    return await reviewer.review(
        stages=stages,
        reason=reason,
        diff_text=diff_text,
        input_context=input_context,
        config_override=config_override,
    )
