"""CLI entry point for bicep-whatif-advisor."""

import json
import os
import sys
from typing import Optional

import click
import yaml

from . import __version__
from .ci.platform import detect_platform
from .input import InputError, read_stdin
from .noise_filter import (
    extract_resource_patterns,
    filter_whatif_text,
    load_builtin_patterns,
    load_user_patterns,
    reclassify_resource_noise,
)
from .prompt import build_system_prompt, build_user_prompt
from .providers import get_provider
from .render import print_banner, render_json, render_markdown, render_table

# Keys recognized in the config file (must match Click parameter names)
_KNOWN_CONFIG_KEYS = {
    "provider",
    "model",
    "format",
    "verbose",
    "no_color",
    "ci",
    "diff",
    "diff_ref",
    "drift_threshold",
    "intent_threshold",
    "post_comment",
    "pr_url",
    "bicep_dir",
    "pr_title",
    "pr_description",
    "no_block",
    "skip_drift",
    "skip_intent",
    "comment_title",
    "noise_file",
    "noise_threshold",
    "no_builtin_patterns",
    "include_whatif",
    "hide_noise",
    "agents_dir",
    "agent_threshold",
    "skip_agent",
}


def _load_config_file(ctx, param, value):
    """Click callback that loads a YAML config file into ctx.default_map.

    Runs eagerly (before other options are resolved) so that the
    default_map is available when Click processes subsequent parameters.
    CLI flags always take precedence over config file values.
    """
    if value is None:
        return

    try:
        with open(value, "r", encoding="utf-8") as f:
            config = yaml.safe_load(f)
    except FileNotFoundError:
        raise click.BadParameter(f"Config file not found: {value}")
    except yaml.YAMLError as exc:
        raise click.BadParameter(f"Invalid YAML in config file: {exc}")
    except IOError as exc:
        raise click.BadParameter(f"Could not read config file: {exc}")

    # Empty file → safe_load returns None
    if config is None:
        return

    if not isinstance(config, dict):
        raise click.BadParameter(
            "Config file must contain a YAML mapping (key: value), not a list or scalar"
        )

    # Warn about unknown keys (non-fatal for forward compatibility)
    unknown = set(config.keys()) - _KNOWN_CONFIG_KEYS
    if unknown:
        sys.stderr.write(f"Warning: Unknown config keys ignored: {', '.join(sorted(unknown))}\n")
        for key in unknown:
            del config[key]

    # Click multiple=True options expect tuples
    for key in ("agent_threshold", "skip_agent"):
        if key in config and isinstance(config[key], list):
            config[key] = tuple(config[key])

    ctx.default_map = config


def extract_json(text: str) -> dict:
    """Attempt to extract JSON from LLM response.

    Args:
        text: Raw LLM response text

    Returns:
        Parsed JSON dict

    Raises:
        ValueError: If no valid JSON found
    """
    # Try parsing as-is first
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        pass

    # Try to find JSON in the text by looking for balanced braces
    # This handles deeply nested JSON properly
    start = text.find("{")
    if start == -1:
        raise ValueError("Could not extract valid JSON from LLM response")

    # Find the matching closing brace
    brace_count = 0
    in_string = False
    escape_next = False

    for i in range(start, len(text)):
        char = text[i]

        if escape_next:
            escape_next = False
            continue

        if char == "\\":
            escape_next = True
            continue

        if char == '"' and not escape_next:
            in_string = not in_string
            continue

        if in_string:
            continue

        if char == "{":
            brace_count += 1
        elif char == "}":
            brace_count -= 1
            if brace_count == 0:
                # Found the matching closing brace
                json_str = text[start : i + 1]
                try:
                    return json.loads(json_str)
                except json.JSONDecodeError:
                    pass
                break

    # Failed to extract JSON
    raise ValueError("Could not extract valid JSON from LLM response")


def filter_by_confidence(data: dict) -> tuple[dict, dict]:
    """Filter resources by confidence level.

    Splits resources into high-confidence (medium/high) and low-confidence (low) lists.
    Low-confidence resources are likely Azure What-If noise and should be excluded
    from risk analysis but displayed separately.

    Args:
        data: Parsed LLM response with resources and other fields

    Returns:
        Tuple of (high_confidence_data, low_confidence_data) dicts with same structure
    """
    resources = data.get("resources", [])

    high_confidence_resources = []
    low_confidence_resources = []

    for resource in resources:
        confidence = resource.get("confidence_level", "medium").lower()

        if confidence in ("low", "noise"):
            # Low confidence and noise-matched resources excluded from analysis
            low_confidence_resources.append(resource)
        else:
            # medium and high confidence included in analysis
            high_confidence_resources.append(resource)

    # Build high-confidence data dict (includes CI fields if present)
    high_confidence_data = {
        "resources": high_confidence_resources,
        "overall_summary": data.get("overall_summary", ""),
    }

    # Preserve CI mode fields in high-confidence data
    if "risk_assessment" in data:
        high_confidence_data["risk_assessment"] = data["risk_assessment"]
    if "verdict" in data:
        high_confidence_data["verdict"] = data["verdict"]

    # Build low-confidence data dict (no CI fields - these are excluded from risk analysis)
    low_confidence_data = {
        "resources": low_confidence_resources,
        "overall_summary": "",  # No separate summary for noise
    }

    return high_confidence_data, low_confidence_data


@click.command()
@click.option(
    "--config-file",
    type=click.Path(exists=False),
    default=None,
    is_eager=True,
    expose_value=False,
    callback=_load_config_file,
    help="Path to YAML config file. CLI flags override config file values.",
)
@click.option(
    "--provider",
    "-p",
    type=click.Choice(["anthropic", "azure-openai", "ollama"], case_sensitive=False),
    default="anthropic",
    help="LLM provider to use",
)
@click.option(
    "--model", "-m", type=str, default=None, help="Override the default model for the provider"
)
@click.option(
    "--format",
    "-f",
    type=click.Choice(["table", "json", "markdown"], case_sensitive=False),
    default="table",
    help="Output format",
)
@click.option(
    "--verbose",
    "-v",
    is_flag=True,
    help="Include property-level change details for modified resources",
)
@click.option("--no-color", is_flag=True, help="Disable colored output")
@click.option("--ci", is_flag=True, help="Enable CI mode with risk assessment and deployment gate")
@click.option("--diff", "-d", type=str, default=None, help="Path to git diff file (CI mode only)")
@click.option(
    "--diff-ref", type=str, default="HEAD~1", help="Git reference to diff against (default: HEAD~1)"
)
@click.option(
    "--drift-threshold",
    type=click.Choice(["low", "medium", "high"], case_sensitive=False),
    default="medium",
    help="Fail pipeline if drift risk meets or exceeds this level"
    " (only applies if drift bucket enabled)",
)
@click.option(
    "--intent-threshold",
    type=click.Choice(["low", "medium", "high"], case_sensitive=False),
    default="medium",
    help="Fail pipeline if intent alignment risk meets or exceeds"
    " this level (only applies if intent bucket enabled)",
)
@click.option("--post-comment", is_flag=True, help="Post summary as PR comment (CI mode only)")
@click.option(
    "--pr-url",
    type=str,
    default=None,
    help="PR URL for posting comments (auto-detected if not provided)",
)
@click.option(
    "--bicep-dir",
    type=str,
    default=".",
    help="Path to Bicep source files for context (CI mode only)",
)
@click.option(
    "--pr-title",
    type=str,
    default=None,
    help="Pull request title for intent analysis (CI mode only)",
)
@click.option(
    "--pr-description",
    type=str,
    default=None,
    help="Pull request description for intent analysis (CI mode only)",
)
@click.option(
    "--no-block",
    is_flag=True,
    help="Don't fail pipeline even if deployment is unsafe - only report findings (CI mode only)",
)
@click.option(
    "--skip-drift", is_flag=True, help="Skip infrastructure drift risk assessment (CI mode only)"
)
@click.option(
    "--skip-intent", is_flag=True, help="Skip PR intent alignment risk assessment (CI mode only)"
)
@click.option(
    "--comment-title",
    type=str,
    default=None,
    help="Custom title for PR comment (default: 'What-If Deployment Review')",
)
@click.option(
    "--noise-file",
    type=str,
    default=None,
    help="Path to additional noise patterns file (additive with built-in patterns)",
)
@click.option(
    "--noise-threshold",
    type=int,
    default=80,
    help="Similarity threshold percentage for 'fuzzy:' prefix patterns only (default: 80)",
)
@click.option(
    "--no-builtin-patterns", is_flag=True, help="Disable the built-in Azure What-If noise patterns"
)
@click.option(
    "--include-whatif",
    is_flag=True,
    help="Include raw What-If output in PR comment as collapsible section",
)
@click.option(
    "--hide-noise",
    is_flag=True,
    help="Hide low-confidence resources (potential noise) from output",
)
@click.option(
    "--agents-dir",
    type=str,
    default=None,
    help="Path to directory of custom risk assessment agent .md files (CI mode only)",
)
@click.option(
    "--agent-threshold",
    type=str,
    multiple=True,
    help=(
        "Set threshold for a custom agent (format: agent_id=level,"
        " e.g., --agent-threshold compliance=high). Repeatable."
    ),
)
@click.option(
    "--skip-agent",
    type=str,
    multiple=True,
    help=("Skip a custom agent by ID (e.g., --skip-agent compliance). Repeatable. CI mode only."),
)
@click.version_option(version=__version__)
def main(
    provider: str,
    model: str,
    format: str,
    verbose: bool,
    no_color: bool,
    ci: bool,
    diff: str,
    diff_ref: str,
    drift_threshold: str,
    intent_threshold: str,
    post_comment: bool,
    pr_url: str,
    bicep_dir: str,
    pr_title: str,
    pr_description: str,
    no_block: bool,
    skip_drift: bool,
    skip_intent: bool,
    comment_title: str,
    noise_file: str,
    noise_threshold: int,
    no_builtin_patterns: bool,
    include_whatif: bool,
    hide_noise: bool,
    agents_dir: str,
    agent_threshold: tuple,
    skip_agent: tuple,
):
    """Analyze Azure What-If deployment output using LLMs.

    Pipe Azure What-If output to this command:

        az deployment group what-if ... | bicep-whatif-advisor

    Example:

        az deployment group what-if \\
          --resource-group my-rg \\
          --template-file main.bicep \\
          --parameters params.json | bicep-whatif-advisor
    """
    try:
        # Read stdin
        whatif_content = read_stdin()

        # Auto-detect platform context (GitHub Actions, Azure DevOps, or local)
        platform_ctx = detect_platform()

        # Apply smart defaults based on platform detection
        if platform_ctx.platform != "local":
            # Auto-enable CI mode in pipeline environments
            if not ci:
                platform_name = (
                    "GitHub Actions" if platform_ctx.platform == "github" else "Azure DevOps"
                )
                sys.stderr.write(
                    f"🤖 Auto-detected {platform_name} environment - enabling CI mode\n"
                )
                ci = True

            # Auto-set diff reference if not manually provided
            if diff_ref == "HEAD~1" and platform_ctx.base_branch:
                diff_ref = platform_ctx.get_diff_ref()
                sys.stderr.write(f"📊 Auto-detected diff reference: {diff_ref}\n")

            # Auto-populate PR metadata if not manually provided
            if not pr_title and platform_ctx.pr_title:
                pr_title = platform_ctx.pr_title
                title_preview = pr_title[:60] + "..." if len(pr_title) > 60 else pr_title
                sys.stderr.write(f"📝 Auto-detected PR title: {title_preview}\n")

            if not pr_description and platform_ctx.pr_description:
                pr_description = platform_ctx.pr_description
                desc_lines = len(pr_description.splitlines())
                sys.stderr.write(f"📄 Auto-detected PR description ({desc_lines} lines)\n")

            # Auto-enable PR comments if token available
            if not post_comment:
                has_token = (
                    platform_ctx.platform == "github" and os.environ.get("GITHUB_TOKEN")
                ) or (
                    platform_ctx.platform == "azuredevops" and os.environ.get("SYSTEM_ACCESSTOKEN")
                )
                if has_token:
                    sys.stderr.write("💬 Auto-enabling PR comments (auth token detected)\n")
                    post_comment = True

        # Print banner in CI/CD mode only
        if ci:
            print_banner()

        # Get diff content if CI mode
        diff_content = None
        bicep_content = None

        if ci:
            from .ci.diff import get_diff

            diff_content = get_diff(diff, diff_ref)

            # Optionally load Bicep source files
            if bicep_dir:
                bicep_content = _load_bicep_files(bicep_dir)

        # Load custom agents
        custom_agent_ids = []
        custom_thresholds = {}

        if ci and agents_dir:
            from .ci.agents import (
                load_agents_from_directory,
                register_agents,
            )

            agents, agent_errors = load_agents_from_directory(agents_dir)
            for err in agent_errors:
                sys.stderr.write(f"Warning: {err}\n")

            if agents:
                custom_agent_ids = register_agents(agents)
                agent_names = ", ".join(custom_agent_ids)
                sys.stderr.write(f"Loaded {len(custom_agent_ids)} agent(s): {agent_names}\n")
        if not ci and agents_dir:
            sys.stderr.write("Warning: --agents-dir is only used in CI mode. Ignoring.\n")

        # Parse --agent-threshold values
        for entry in agent_threshold:
            if "=" not in entry:
                sys.stderr.write(
                    f"Warning: Invalid --agent-threshold"
                    f" format: '{entry}'."
                    f" Expected format: agent_id=level\n"
                )
                continue
            agent_id, level = entry.split("=", 1)
            agent_id = agent_id.strip()
            level = level.strip().lower()
            if level not in ("low", "medium", "high"):
                sys.stderr.write(
                    f"Warning: Invalid threshold level"
                    f" '{level}' for agent '{agent_id}'."
                    f" Must be low, medium, or high.\n"
                )
                continue
            custom_thresholds[agent_id] = level

        # Determine which risk buckets are enabled (CI mode only)
        # Do this before getting provider to validate flags early
        enabled_buckets = None
        if ci:
            from .ci.buckets import get_enabled_buckets

            enabled_buckets = get_enabled_buckets(
                skip_drift=skip_drift,
                skip_intent=skip_intent,
                has_pr_metadata=bool(pr_title or pr_description),
                custom_agent_ids=custom_agent_ids,
                skip_agents=list(skip_agent),
            )

            # Validation: At least one bucket must be enabled in CI mode
            if not enabled_buckets:
                sys.stderr.write(
                    "❌ Error: At least one risk assessment bucket must be enabled in CI mode\n"
                )
                sys.stderr.write(
                    "   Remove one or more --skip-* flags to enable risk assessment.\n"
                )
                sys.exit(2)

        # Load noise patterns and separate resource vs property patterns
        noise_patterns = []
        if not no_builtin_patterns:
            noise_patterns.extend(load_builtin_patterns())
        if noise_file:
            try:
                noise_patterns.extend(load_user_patterns(noise_file))
            except FileNotFoundError as e:
                sys.stderr.write(f"Error: {e}\n")
                sys.exit(2)
            except IOError as e:
                sys.stderr.write(f"Error reading noise file: {e}\n")
                sys.exit(2)

        # Separate resource patterns for post-LLM fallback reclassification
        resource_noise_patterns, _ = extract_resource_patterns(noise_patterns)

        # Preserve original What-If content for --include-whatif (before noise filtering)
        original_whatif_content = whatif_content

        # Tracks resource blocks removed pre-LLM so we can show them as noise
        pre_filtered_resources = []

        if noise_patterns:
            fuzzy_threshold = noise_threshold / 100.0
            whatif_content, num_lines, num_blocks, pre_filtered_resources = filter_whatif_text(
                whatif_content, noise_patterns, fuzzy_threshold
            )
            if num_blocks > 0:
                sys.stderr.write(
                    f"🔕 Pre-filtered {num_blocks} noisy resource block(s) from What-If output\n"
                )
            if num_lines > 0:
                sys.stderr.write(
                    f"🔕 Pre-filtered {num_lines} known-noisy line(s) from What-If output\n"
                )

        # Get provider
        llm_provider = get_provider(provider, model)

        # Build prompts
        system_prompt = build_system_prompt(
            verbose=verbose,
            ci_mode=ci,
            pr_title=pr_title,
            pr_description=pr_description,
            enabled_buckets=enabled_buckets,
        )
        user_prompt = build_user_prompt(
            whatif_content=whatif_content,
            diff_content=diff_content,
            bicep_content=bicep_content,
            pr_title=pr_title,
            pr_description=pr_description,
        )

        # Call LLM
        response_text = llm_provider.complete(system_prompt, user_prompt)

        # Parse JSON response
        try:
            data = extract_json(response_text)
        except ValueError:
            # Truncate response to prevent exposing sensitive data
            truncated = response_text[:500] + "..." if len(response_text) > 500 else response_text
            sys.stderr.write(
                "Error: LLM did not return valid JSON.\n"
                f"Raw response (first 500 chars):\n{truncated}\n"
            )
            sys.exit(1)

        # Validate required fields
        if "resources" not in data:
            sys.stderr.write("Warning: LLM response missing 'resources' field. Using empty list.\n")
            data["resources"] = []

        if "overall_summary" not in data:
            sys.stderr.write("Warning: LLM response missing 'overall_summary' field.\n")
            data["overall_summary"] = "No summary provided."

        # Add backward compatibility defaults for confidence fields
        for resource in data.get("resources", []):
            if "confidence_level" not in resource:
                resource["confidence_level"] = "medium"  # Default to include in analysis
            if "confidence_reason" not in resource:
                resource["confidence_reason"] = "No confidence assessment provided"

        # Post-LLM resource noise reclassification: demote matching resources to low confidence
        if resource_noise_patterns:
            num_reclassified = reclassify_resource_noise(
                data.get("resources", []), resource_noise_patterns
            )
            if num_reclassified > 0:
                sys.stderr.write(
                    f"🔕 Reclassified {num_reclassified} resource(s) as low-confidence noise\n"
                )

        # Inject synthetic low-confidence entries for pre-LLM filtered resource blocks
        # so they still appear in the "Potential Noise" section
        if pre_filtered_resources:
            action_map = {
                "Create": "Create",
                "Modify": "Modify",
                "Delete": "Delete",
                "Deploy": "Deploy",
                "NoChange": "NoChange",
                "Ignore": "Ignore",
            }
            for removed in pre_filtered_resources:
                data["resources"].append(
                    {
                        "resource_name": removed["resource_name"],
                        "resource_type": removed["resource_type"],
                        "action": action_map.get(removed["operation"], removed["operation"]),
                        "summary": "Removed by resource noise pattern",
                        "confidence_level": "low",
                        "confidence_reason": "Matched resource noise pattern (pre-LLM filtered)",
                    }
                )

        # Filter by confidence (always-on behavior)
        high_confidence_data, low_confidence_data = filter_by_confidence(data)

        # Suppress noise display if --hide-noise is set
        display_noise_data = None if hide_noise else low_confidence_data

        # CRITICAL FIX: If noise filtering removed resources in CI mode, the LLM's
        # risk_assessment is stale (generated before filtering). Re-prompt the LLM
        # with only high-confidence resources to get an accurate risk assessment.
        if ci and low_confidence_data.get("resources"):
            num_filtered = len(low_confidence_data["resources"])
            num_remaining = len(high_confidence_data.get("resources", []))

            sys.stderr.write(
                f"🔄 Recalculating risk assessment: {num_filtered} low-confidence resources "
                f"filtered, {num_remaining} high-confidence resources remain\n"
            )

            # Special case: If ALL resources were filtered as noise, skip LLM recalculation
            # and set all risk buckets to low with no concerns.
            # Rationale: if the LLM classified every resource as noise, then drift
            # detected on those same resources is also noise-driven and unreliable.
            if num_remaining == 0:
                sys.stderr.write(
                    "✅ All resources filtered as noise - setting all risk buckets to low\n"
                )

                # Build a clean risk assessment with no concerns for enabled buckets only
                from .ci.buckets import RISK_BUCKETS

                high_confidence_data["risk_assessment"] = {}

                for bucket_id in enabled_buckets:
                    bucket = RISK_BUCKETS[bucket_id]
                    high_confidence_data["risk_assessment"][bucket_id] = {
                        "risk_level": "low",
                        "concerns": [],
                        "reasoning": (
                            f"All detected changes were flagged as"
                            f" low-confidence noise. No high-confidence"
                            f" {bucket.display_name.lower()}"
                            f" concerns to evaluate."
                        ),
                    }

                # Update verdict
                high_confidence_data["verdict"] = {
                    "safe": True,
                    "highest_risk_bucket": "none",
                    "overall_risk_level": "low",
                    "reasoning": (
                        "All detected changes were identified as Azure"
                        " What-If noise and excluded from risk analysis."
                        " No meaningful infrastructure changes detected."
                    ),
                }
            else:
                # Re-prompt the LLM with the already noise-filtered What-If
                # content (real format, blocks already stripped) for accurate
                # risk assessment based on high-confidence resources only.

                # Re-build prompts with filtered data
                filtered_system_prompt = build_system_prompt(
                    verbose=verbose,
                    ci_mode=ci,
                    pr_title=pr_title,
                    pr_description=pr_description,
                    enabled_buckets=enabled_buckets,
                )
                filtered_user_prompt = build_user_prompt(
                    whatif_content=whatif_content,
                    diff_content=diff_content,
                    bicep_content=bicep_content,
                    pr_title=pr_title,
                    pr_description=pr_description,
                )

                # Re-call LLM with filtered resources
                sys.stderr.write(
                    "📡 Re-analyzing with filtered resources for accurate risk assessment...\n"
                )
                filtered_response_text = llm_provider.complete(
                    filtered_system_prompt, filtered_user_prompt
                )

                # Parse the new response
                try:
                    filtered_data = extract_json(filtered_response_text)

                    # Merge the fresh risk_assessment into existing data.
                    # Use update (not replace) so buckets the LLM omitted
                    # in the re-analysis keep their original assessment.
                    if "risk_assessment" in filtered_data:
                        existing_ra = high_confidence_data.get("risk_assessment", {})
                        existing_ra.update(filtered_data["risk_assessment"])
                        high_confidence_data["risk_assessment"] = existing_ra
                    if "verdict" in filtered_data:
                        high_confidence_data["verdict"] = filtered_data["verdict"]

                    sys.stderr.write(
                        "✅ Risk assessment recalculated based on high-confidence resources only\n"
                    )

                except ValueError:
                    sys.stderr.write(
                        "⚠️  Warning: Could not parse re-analysis response. "
                        "Using original risk assessment (may be inaccurate).\n"
                    )

        # Backfill missing buckets: the LLM may omit custom agents from
        # risk_assessment even when the schema explicitly requests them.
        # Add a default low-risk entry so they still appear in output.
        if ci and enabled_buckets:
            ra = high_confidence_data.get("risk_assessment", {})
            for bucket_id in enabled_buckets:
                if bucket_id not in ra:
                    ra[bucket_id] = {
                        "risk_level": "low",
                        "concerns": [],
                        "concern_summary": "None",
                        "reasoning": "No assessment returned by LLM",
                    }
            high_confidence_data["risk_assessment"] = ra

        # Store enabled buckets in data for rendering (CI mode only)
        if ci and enabled_buckets:
            high_confidence_data["_enabled_buckets"] = enabled_buckets

        # CI mode: evaluate thresholds and override LLM verdict before rendering
        is_safe = True
        failed_buckets = []
        if ci:
            from .ci.risk_buckets import evaluate_risk_buckets

            is_safe, failed_buckets, risk_assessment = evaluate_risk_buckets(
                high_confidence_data,
                enabled_buckets,
                drift_threshold,
                intent_threshold,
                custom_thresholds=custom_thresholds,
            )

            # Override LLM verdict with threshold evaluation result
            highest_bucket = failed_buckets[0] if failed_buckets else "none"
            ra = high_confidence_data.get("risk_assessment", {})
            highest_level = "low"
            for bucket_id in enabled_buckets:
                bucket_data = ra.get(bucket_id, {})
                level = bucket_data.get("risk_level", "low")
                level_idx = ["low", "medium", "high"].index(level)
                if level_idx > ["low", "medium", "high"].index(highest_level):
                    highest_level = level
            existing_verdict = high_confidence_data.get("verdict", {})
            high_confidence_data["verdict"] = {
                "safe": is_safe,
                "highest_risk_bucket": highest_bucket,
                "overall_risk_level": highest_level,
                "reasoning": existing_verdict.get("reasoning", ""),
            }

        # Render output
        if format == "table":
            render_table(
                high_confidence_data,
                verbose=verbose,
                no_color=no_color,
                ci_mode=ci,
                low_confidence_data=display_noise_data,
            )
        elif format == "json":
            render_json(high_confidence_data, low_confidence_data=display_noise_data)
        elif format == "markdown":
            raw_whatif = original_whatif_content if include_whatif else None
            markdown = render_markdown(
                high_confidence_data,
                ci_mode=ci,
                custom_title=comment_title,
                no_block=no_block,
                low_confidence_data=display_noise_data,
                platform=platform_ctx.platform,
                whatif_content=raw_whatif,
            )
            print(markdown)

        # CI mode: post comment and exit
        if ci:
            # Post comment if requested
            if post_comment:
                raw_whatif = original_whatif_content if include_whatif else None
                markdown = render_markdown(
                    high_confidence_data,
                    ci_mode=True,
                    custom_title=comment_title,
                    no_block=no_block,
                    low_confidence_data=display_noise_data,
                    platform=platform_ctx.platform,
                    whatif_content=raw_whatif,
                )
                _post_pr_comment(markdown, pr_url)

            # Exit with appropriate code
            if is_safe:
                sys.exit(0)  # Safe to deploy
            else:
                # Show which buckets failed
                if failed_buckets:
                    bucket_names = ", ".join(failed_buckets)
                    if no_block:
                        sys.stderr.write(
                            f"⚠️  Warning: Failed risk buckets:"
                            f" {bucket_names} (pipeline not blocked"
                            f" due to --no-block)\n"
                        )
                    else:
                        sys.stderr.write(
                            f"❌ Deployment blocked: Failed risk buckets: {bucket_names}\n"
                        )

                # Exit with 0 if --no-block is set, otherwise exit with 1
                if no_block:
                    sys.stderr.write("ℹ️  CI mode: Reporting findings only (--no-block enabled)\n")
                    sys.exit(0)  # Don't block pipeline
                else:
                    sys.exit(1)  # Unsafe, block deployment

        # Standard mode: exit successfully
        sys.exit(0)

    except InputError as e:
        sys.stderr.write(f"Error: {e}\n")
        sys.exit(2)

    except KeyboardInterrupt:
        sys.stderr.write("\nInterrupted by user.\n")
        sys.exit(130)

    except Exception as e:
        sys.stderr.write(f"Error: {e}\n")
        sys.exit(1)


def _load_bicep_files(bicep_dir: str) -> Optional[str]:
    """Load all Bicep files from directory for context.

    Args:
        bicep_dir: Directory containing Bicep files

    Returns:
        Combined content of all .bicep files, or None if no files found
    """
    from pathlib import Path

    # Resolve to absolute path and validate
    try:
        base_path = Path(bicep_dir).resolve()
    except (OSError, RuntimeError) as e:
        sys.stderr.write(f"Warning: Could not resolve bicep directory: {e}\n")
        return None

    if not base_path.exists() or not base_path.is_dir():
        sys.stderr.write(
            f"Warning: Bicep directory does not exist or is not a directory: {bicep_dir}\n"
        )
        return None

    # Find all .bicep files recursively
    bicep_files = []
    try:
        for file_path in base_path.rglob("*.bicep"):
            # Security: Ensure file is within base_path (prevent path traversal)
            try:
                file_path.resolve().relative_to(base_path)
            except ValueError:
                sys.stderr.write(f"Warning: Skipping file outside base directory: {file_path}\n")
                continue

            # Security: Skip symbolic links
            if file_path.is_symlink():
                sys.stderr.write(f"Warning: Skipping symbolic link: {file_path}\n")
                continue

            bicep_files.append(file_path)
    except (OSError, PermissionError) as e:
        sys.stderr.write(f"Warning: Error scanning bicep directory: {e}\n")
        return None

    if not bicep_files:
        return None

    # Read file contents (limit to 5 files to avoid huge context)
    contents = []
    for file_path in bicep_files[:5]:
        try:
            with open(file_path, "r", encoding="utf-8") as f:
                rel_path = file_path.relative_to(base_path)
                contents.append(f"// File: {rel_path}\n{f.read()}")
        except (OSError, UnicodeDecodeError) as e:
            sys.stderr.write(f"Warning: Could not read {file_path}: {e}\n")
            continue

    return "\n\n".join(contents) if contents else None


def _post_pr_comment(markdown: str, pr_url: str = None) -> None:
    """Post markdown comment to PR.

    Args:
        markdown: Markdown content to post
        pr_url: Optional PR URL override
    """
    import os

    # Detect GitHub or Azure DevOps
    if os.environ.get("GITHUB_TOKEN"):
        from .ci.github import post_github_comment

        success = post_github_comment(markdown, pr_url)
        if success:
            sys.stderr.write("Posted comment to GitHub PR.\n")
        else:
            sys.stderr.write("Warning: Failed to post comment to GitHub PR.\n")

    elif os.environ.get("SYSTEM_ACCESSTOKEN"):
        from .ci.azdevops import post_azdevops_comment

        success = post_azdevops_comment(markdown)
        if success:
            sys.stderr.write("Posted comment to Azure DevOps PR.\n")
        else:
            sys.stderr.write("Warning: Failed to post comment to Azure DevOps PR.\n")

    else:
        sys.stderr.write(
            "Warning: --post-comment requires GITHUB_TOKEN or SYSTEM_ACCESSTOKEN.\n"
            "Skipping PR comment.\n"
        )


if __name__ == "__main__":
    main()
