# engine/product/runtime_bridge.py
"""E5 — Runtime Bridge: observability, error explanation, and dependency automation.

E5a — ace_instrument(stack):
  Reads stack from ACE capabilities → generates production-ready OTel
  configuration files (Python ASGI middleware, Node.js SDK config, structured
  logging, Jaeger/OTLP export). Output is ready to plug into the framework
  entry point with minimal manual wiring.

E5b — ace_explain_error(error, stack_trace):
  Parses stack trace → identifies affected modules → loads relevant ACE
  decisions and intelligence → LLM explains the error in plain English with
  fix commands. Checks runbook table for known patterns. Auto-captures new
  error patterns as runbooks for future sessions.

E5c — ace_update_deps(strategy, repo_path):
  Runs pip-audit / npm audit → cross-references with ACE decision graph →
  returns a risk-annotated update plan. Decisions that pin specific versions
  or mention a package block that update with a reason.

Closes:
  #6 Knowledge Gap at Errors  C→A
  #10 Debugging Impossible  C+→A
  #5 Dependency Hell  C-→A+
"""

from __future__ import annotations

import json
import logging
import re
import subprocess

from core.engine.core.db import parse_rows, pool
from core.engine.core.llm import get_llm

logger = logging.getLogger(__name__)

# ── E5a — OTel Auto-Configuration ────────────────────────────────────────────

_PYTHON_OTEL_MIDDLEWARE = '''\
# otel_config.py — generated by ACE ace_instrument
# Plug into your ASGI entry point:
#   from otel_config import configure_otel
#   configure_otel(app)

from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource, SERVICE_NAME
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
import logging
import structlog


def configure_otel(app=None, service_name: str = "{service_name}") -> None:
    """Configure OpenTelemetry for {stack} stack."""
    resource = Resource(attributes={{SERVICE_NAME: service_name}})
    provider = TracerProvider(resource=resource)

    # OTLP exporter — set OTEL_EXPORTER_OTLP_ENDPOINT in environment
    import os
    otlp_endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317")
    provider.add_span_processor(
        BatchSpanProcessor(OTLPSpanExporter(endpoint=otlp_endpoint, insecure=True))
    )
    trace.set_tracer_provider(provider)

    # Auto-instrument frameworks
    if app is not None:
        FastAPIInstrumentor.instrument_app(app)
    HTTPXClientInstrumentor().instrument()

    # Structured logging with trace correlation
    structlog.configure(
        processors=[
            structlog.stdlib.add_log_level,
            structlog.stdlib.PositionalArgumentsFormatter(),
            structlog.processors.TimeStamper(fmt="iso"),
            structlog.processors.StackInfoRenderer(),
            structlog.processors.format_exc_info,
            structlog.processors.UnicodeDecoder(),
            structlog.processors.JSONRenderer(),
        ],
        context_class=dict,
        logger_factory=structlog.stdlib.LoggerFactory(),
        wrapper_class=structlog.stdlib.BoundLogger,
        cache_logger_on_first_use=True,
    )
    logging.basicConfig(
        format="%(message)s",
        level=logging.INFO,
    )
'''

_NODE_OTEL_CONFIG = """\
// otel.ts — generated by ACE ace_instrument
// Import at the top of your entry point (before other imports):
//   import './otel';

import {{ NodeSDK }} from '@opentelemetry/sdk-node';
import {{ OTLPTraceExporter }} from '@opentelemetry/exporter-trace-otlp-grpc';
import {{ Resource }} from '@opentelemetry/resources';
import {{ SemanticResourceAttributes }} from '@opentelemetry/semantic-conventions';
import {{ getNodeAutoInstrumentations }} from '@opentelemetry/auto-instrumentations-node';

const sdk = new NodeSDK({{
  resource: new Resource({{
    [SemanticResourceAttributes.SERVICE_NAME]: '{service_name}',
  }}),
  traceExporter: new OTLPTraceExporter({{
    url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? 'http://localhost:4317',
  }}),
  instrumentations: [
    getNodeAutoInstrumentations({{
      '@opentelemetry/instrumentation-http': {{ enabled: true }},
      '@opentelemetry/instrumentation-express': {{ enabled: true }},
      '@opentelemetry/instrumentation-pg': {{ enabled: true }},
      '@opentelemetry/instrumentation-ioredis': {{ enabled: true }},
    }}),
  ],
}});

sdk.start();

process.on('SIGTERM', () => {{
  sdk.shutdown()
    .then(() => console.log('Tracing terminated'))
    .finally(() => process.exit(0));
}});
"""

_DOCKER_COMPOSE_OTEL = """\
# docker-compose.otel.yml — generated by ACE ace_instrument
# Add to your docker-compose.yml with: extends: file: docker-compose.otel.yml

services:
  jaeger:
    image: jaegertracing/all-in-one:latest
    ports:
      - "16686:16686"   # Jaeger UI
      - "4317:4317"     # OTLP gRPC
      - "4318:4318"     # OTLP HTTP
    environment:
      - COLLECTOR_OTLP_ENABLED=true

  otel-collector:
    image: otel/opentelemetry-collector-contrib:latest
    volumes:
      - ./otel-collector-config.yml:/etc/otel-collector-config.yml
    command: ["--config=/etc/otel-collector-config.yml"]
    ports:
      - "8888:8888"
      - "8889:8889"
      - "13133:13133"
    depends_on:
      - jaeger
"""

_OTEL_COLLECTOR_CONFIG = """\
# otel-collector-config.yml — generated by ACE ace_instrument
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

processors:
  batch:
    timeout: 1s
    send_batch_size: 1024

exporters:
  jaeger:
    endpoint: jaeger:14250
    tls:
      insecure: true
  logging:
    loglevel: info

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [jaeger, logging]
"""


async def run_instrument(product_id: str, stack: list[str] | None = None) -> dict:
    """E5a: Generate OTel auto-configuration files for the detected stack.

    Reads stack from ACE capabilities if not provided.
    Returns {files: [{path, content}], stack, install_commands, quickstart}
    """
    if stack is None:
        stack = await _detect_stack(product_id)

    service_name = product_id.replace("product:", "").replace(":", "_")
    stack_lower = " ".join(stack).lower()

    files = []

    if "python" in stack_lower or "fastapi" in stack_lower:
        content = _PYTHON_OTEL_MIDDLEWARE.format(stack=", ".join(stack), service_name=service_name)
        files.append({"path": "otel_config.py", "content": content})

    if "node" in stack_lower or "typescript" in stack_lower or "nextjs" in stack_lower:
        content = _NODE_OTEL_CONFIG.format(service_name=service_name)
        files.append({"path": "otel.ts", "content": content})

    # Always generate the docker-compose + collector config
    files.append({"path": "docker-compose.otel.yml", "content": _DOCKER_COMPOSE_OTEL})
    files.append({"path": "otel-collector-config.yml", "content": _OTEL_COLLECTOR_CONFIG})

    install_commands = _otel_install_commands(stack)

    quickstart = (
        "1. Install packages: see install_commands\n"
        "2. Add OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 to .env\n"
        "3. Start Jaeger: docker compose -f docker-compose.otel.yml up -d\n"
        "4. Open http://localhost:16686 — traces appear after first request\n"
    )
    if "python" in stack_lower or "fastapi" in stack_lower:
        quickstart += "5. Call configure_otel(app) in your FastAPI entry point before app startup\n"
    elif "node" in stack_lower or "typescript" in stack_lower:
        quickstart += "5. Add `import './otel'` as the very first line of your entry point\n"

    return {
        "stack": stack,
        "files": files,
        "files_count": len(files),
        "install_commands": install_commands,
        "quickstart": quickstart,
    }


def _otel_install_commands(stack: list[str]) -> list[str]:
    stack_lower = " ".join(stack).lower()
    cmds = []
    if "python" in stack_lower or "fastapi" in stack_lower:
        cmds.append(
            "pip install opentelemetry-sdk opentelemetry-exporter-otlp-proto-grpc "
            "opentelemetry-instrumentation-fastapi opentelemetry-instrumentation-httpx structlog"
        )
    if "node" in stack_lower or "typescript" in stack_lower or "nextjs" in stack_lower:
        cmds.append(
            "npm install @opentelemetry/sdk-node @opentelemetry/exporter-trace-otlp-grpc "
            "@opentelemetry/resources @opentelemetry/semantic-conventions "
            "@opentelemetry/auto-instrumentations-node"
        )
    return cmds or ["No packages detected — check stack detection"]


# ── E5b — Error Explanation Bridge ───────────────────────────────────────────

_EXPLAIN_PROMPT = """You are ACE's Error Explanation Bridge, helping a builder understand and fix a runtime error.

Error: {error}

Stack trace:
{stack_trace}

Affected modules (from stack trace):
{affected_modules}

Relevant ACE intelligence for these modules:
{decisions}

Known runbooks for similar errors:
{runbooks}

Provide:
1. **Root cause**: What caused this error in 1-2 sentences
2. **Why**: Deeper explanation of the underlying mechanism
3. **Fix**: Specific commands or code changes to resolve it
4. **Verify**: How to confirm the fix worked
5. **Prevention**: What to add (tests, validation) to prevent recurrence

If this matches a known runbook: reference it and check if still applicable.
If new: note it will be captured as a runbook for future sessions.

Be specific to this codebase. Reference the actual files and decisions when relevant.
Return clean markdown."""


def _parse_stack_trace_modules(stack_trace: str) -> list[str]:
    """Extract file paths and module names from a stack trace string."""
    modules = []

    # Python stack traces: "File "/path/to/file.py", line N"
    py_files = re.findall(r'File "([^"]+\.py)"', stack_trace)
    modules.extend(py_files)

    # Python module names: "in module_name"
    py_modules = re.findall(r"\bin ([a-zA-Z_][a-zA-Z0-9_.]+)\b", stack_trace)
    modules.extend(py_modules[:5])

    # Node/TypeScript: "at Object.<anonymous> (path/to/file.js:N:N)"
    node_files = re.findall(r"\(([^)]+\.[jt]sx?):[\d]+", stack_trace)
    modules.extend(node_files)

    # Remove duplicates, keep short paths
    seen = set()
    unique = []
    for m in modules:
        key = m.split("/")[-1]  # basename for dedup
        if key not in seen:
            seen.add(key)
            unique.append(m)
    return unique[:10]


async def run_explain_error(
    error: str,
    stack_trace: str,
    product_id: str,
) -> dict:
    """E5b: Explain a runtime error using ACE intelligence and decision graph.

    Pipeline:
      1. Parse stack trace → identify affected modules/files
      2. Load relevant ACE decisions for those modules
      3. Check runbook table for known similar patterns
      4. LLM synthesizes plain-English explanation + fix commands
      5. Auto-capture new patterns as runbooks

    Returns {explanation, affected_modules, decisions_loaded, runbook_match, captured_as_runbook}
    """
    if not error:
        return {"error": "error string is required"}

    affected_modules = _parse_stack_trace_modules(stack_trace or "")

    decisions = []
    runbooks = []
    runbook_match = None

    async with pool.connection() as db:
        # Load decisions related to affected modules
        if affected_modules:
            try:
                # Search decisions by affected file keywords
                file_keywords = [m.split("/")[-1].replace(".py", "").replace(".ts", "") for m in affected_modules[:3]]
                for keyword in file_keywords:
                    result = await db.query(
                        """SELECT title, rationale, discipline, created_at
                           FROM decision
                           WHERE product = <record>$product
                             AND (string::contains(title, $kw) OR string::contains(rationale, $kw))
                           LIMIT 3""",
                        {"product": product_id, "kw": keyword},
                    )
                    decisions.extend(parse_rows(result))
            except Exception:
                pass

        # Check runbook table for similar errors
        try:
            error_keywords = _extract_error_keywords(error)
            for kw in error_keywords[:2]:
                result = await db.query(
                    """SELECT title, content, tags, created_at
                       FROM runbook
                       WHERE product = <record>$product
                         AND string::contains(title, $kw)
                       LIMIT 2""",
                    {"product": product_id, "kw": kw},
                )
                found = parse_rows(result)
                if found:
                    runbooks.extend(found)
                    runbook_match = found[0].get("title")
        except Exception:
            pass

    # Format context for LLM
    decisions_text = (
        "\n".join(
            f"  - [{d.get('discipline', '?')}] {d.get('title', '')}: {d.get('rationale', '')[:150]}"
            for d in decisions[:5]
        )
        or "  No relevant decisions found for affected modules"
    )

    runbooks_text = (
        "\n".join(f"  - {r.get('title', '?')}: {str(r.get('content', ''))[:200]}" for r in runbooks[:3])
        or "  No matching runbooks — this appears to be a new error pattern"
    )

    modules_text = "\n".join(f"  - {m}" for m in affected_modules) or "  Stack trace not parseable"

    prompt = _EXPLAIN_PROMPT.format(
        error=error[:500],
        stack_trace=(stack_trace or "")[:1500],
        affected_modules=modules_text,
        decisions=decisions_text,
        runbooks=runbooks_text,
    )

    explanation = ""
    try:
        llm = get_llm()
        explanation = await llm.complete(prompt, max_tokens=1500)
    except Exception as exc:
        logger.warning("ace_explain_error LLM call failed: %s", exc)
        explanation = f"[LLM unavailable: {exc}]\n\nError: {error}\n\nAffected: {', '.join(affected_modules)}"

    # Auto-capture as runbook if no match found
    captured_as_runbook = False
    if not runbook_match and explanation:
        try:
            async with pool.connection() as db:
                title = f"Error: {error[:80]}"
                await db.query(
                    """CREATE runbook SET
                       product = <record>$product,
                       title = <string>$title,
                       content = <string>$content,
                       tags = $tags,
                       created_at = time::now()""",
                    {
                        "product": product_id,
                        "title": title,
                        "content": explanation[:2000],
                        "tags": _extract_error_keywords(error),
                    },
                )
                captured_as_runbook = True
        except Exception:
            pass

    return {
        "explanation": explanation,
        "affected_modules": affected_modules,
        "decisions_loaded": len(decisions),
        "runbook_match": runbook_match,
        "captured_as_runbook": captured_as_runbook,
    }


def _extract_error_keywords(error: str) -> list[str]:
    """Extract searchable keywords from an error string."""
    # Common error patterns to normalize
    error_clean = re.sub(r"0x[0-9a-fA-F]+", "", error)  # strip hex addresses
    error_clean = re.sub(r"\d{4,}", "", error_clean)  # strip long numbers
    error_clean = re.sub(r"[^a-zA-Z0-9_ :/.-]", " ", error_clean)

    words = error_clean.split()
    # Keep meaningful words (length 4+, not pure numbers)
    keywords = [w for w in words if len(w) >= 4 and not w.isdigit()]
    return keywords[:5]


# ── E5c — Dependency Automation ───────────────────────────────────────────────

_STRATEGY_LEVELS = frozenset({"semver", "minor", "patch"})


async def run_update_deps(
    strategy: str = "minor",
    repo_path: str = ".",
    product_id: str | None = None,
) -> dict:
    """E5c: Decision-aware dependency update plan.

    Runs pip-audit / npm audit → cross-references ACE decisions →
    returns a risk-annotated update plan with decision-blocked packages flagged.

    Args:
        strategy:   'semver' (all updates), 'minor' (minor+patch), 'patch' (patch only)
        repo_path:  Path to repo root.
        product_id: Product for decision graph lookup. None = skip decision gating.

    Returns:
        {strategy, updates: [{package, current, latest, risk, blocked_by_decision}],
         vulnerabilities, blocked_count, safe_count}
    """
    import os

    if strategy not in _STRATEGY_LEVELS:
        return {
            "strategy": strategy,
            "error": f"Unknown strategy {strategy!r}. Valid: {sorted(_STRATEGY_LEVELS)}",
        }

    abs_path = os.path.abspath(repo_path)

    # Run audit tools
    vulnerabilities = []
    available_updates = []

    # Python: pip-audit
    py_vuln, py_updates = _run_pip_audit(abs_path)
    vulnerabilities.extend(py_vuln)
    available_updates.extend(py_updates)

    # Node: npm audit
    node_vuln, node_updates = _run_npm_audit(abs_path)
    vulnerabilities.extend(node_vuln)
    available_updates.extend(node_updates)

    # Load ACE decisions for gating
    locked_decisions: list[dict] = []
    if product_id:
        try:
            async with pool.connection() as db:
                result = await db.query(
                    """SELECT title, rationale, discipline, created_at
                       FROM decision
                       WHERE product = <record>$product
                       ORDER BY created_at DESC LIMIT 50""",
                    {"product": product_id},
                )
                locked_decisions = parse_rows(result)
        except Exception:
            pass

    # Annotate updates with decision blocks and strategy filter
    annotated = []
    blocked_count = 0
    safe_count = 0

    for update in available_updates:
        pkg = update.get("package", "")
        current = update.get("current", "")
        latest = update.get("latest", "")
        update_type = _classify_update_type(current, latest)

        # Strategy filter
        if strategy == "patch" and update_type not in ("patch",):
            continue
        if strategy == "minor" and update_type not in ("patch", "minor"):
            continue

        # Decision gate
        blocking_decision = _find_blocking_decision(pkg, current, latest, locked_decisions)

        risk = update.get("risk", "low")
        if blocking_decision:
            blocked_count += 1
        else:
            safe_count += 1

        annotated.append(
            {
                "package": pkg,
                "current": current,
                "latest": latest,
                "update_type": update_type,
                "risk": risk,
                "blocked_by_decision": blocking_decision,
            }
        )

    # Sort: vulnerabilities first, then by package name
    vuln_pkgs = {v.get("package", "") for v in vulnerabilities}
    annotated.sort(key=lambda u: (0 if u["package"] in vuln_pkgs else 1, u["package"]))

    return {
        "strategy": strategy,
        "updates": annotated,
        "vulnerabilities": vulnerabilities,
        "blocked_count": blocked_count,
        "safe_count": safe_count,
        "total_updates": len(annotated),
    }


def _run_pip_audit(repo_path: str) -> tuple[list[dict], list[dict]]:
    """Run pip-audit and return (vulnerabilities, available_updates)."""
    import os

    requirements = os.path.join(repo_path, "requirements.txt")
    pyproject = os.path.join(repo_path, "pyproject.toml")

    if not os.path.exists(requirements) and not os.path.exists(pyproject):
        return [], []

    try:
        result = subprocess.run(
            ["pip-audit", "--format", "json", "--progress-spinner", "off"],
            cwd=repo_path,
            capture_output=True,
            text=True,
            timeout=60,
        )
        if result.stdout:
            data = json.loads(result.stdout)
            vulns = []
            updates = []
            for dep in data.get("dependencies", []):
                pkg = dep.get("name", "")
                version = dep.get("version", "")
                dep_vulns = dep.get("vulns", [])
                if dep_vulns:
                    for vuln in dep_vulns:
                        vulns.append(
                            {
                                "package": pkg,
                                "current": version,
                                "cve": vuln.get("id", ""),
                                "description": vuln.get("description", "")[:200],
                                "fix_versions": vuln.get("fix_versions", []),
                                "source": "pip-audit",
                            }
                        )
                        # The fix version is the "latest safe"
                        if vuln.get("fix_versions"):
                            updates.append(
                                {
                                    "package": pkg,
                                    "current": version,
                                    "latest": vuln["fix_versions"][-1],
                                    "risk": "critical" if dep_vulns else "low",
                                    "source": "pip-audit",
                                }
                            )
            return vulns, updates
    except FileNotFoundError:
        logger.debug("pip-audit not installed — skipping Python dependency audit")
    except (subprocess.TimeoutExpired, json.JSONDecodeError, Exception) as exc:
        logger.warning("pip-audit failed: %s", exc)

    return [], []


def _run_npm_audit(repo_path: str) -> tuple[list[dict], list[dict]]:
    """Run npm audit and return (vulnerabilities, available_updates)."""
    import os

    package_json = os.path.join(repo_path, "package.json")
    if not os.path.exists(package_json):
        return [], []

    try:
        result = subprocess.run(
            ["npm", "audit", "--json", "--audit-level=none"],
            cwd=repo_path,
            capture_output=True,
            text=True,
            timeout=60,
        )
        if result.stdout:
            data = json.loads(result.stdout)
            vulns = []
            updates = []
            for vuln_id, vuln in (data.get("vulnerabilities") or {}).items():
                pkg = vuln.get("name", vuln_id)
                severity = vuln.get("severity", "moderate")
                vulns.append(
                    {
                        "package": pkg,
                        "current": vuln.get("range", ""),
                        "cve": vuln.get("via", [{}])[0].get("cve", "") if vuln.get("via") else "",
                        "description": vuln.get("via", [{}])[0].get("title", "")[:200] if vuln.get("via") else "",
                        "severity": severity,
                        "fix_available": vuln.get("fixAvailable", False),
                        "source": "npm-audit",
                    }
                )
                if vuln.get("fixAvailable"):
                    updates.append(
                        {
                            "package": pkg,
                            "current": vuln.get("range", ""),
                            "latest": "latest",
                            "risk": severity,
                            "source": "npm-audit",
                        }
                    )
            return vulns, updates
    except FileNotFoundError:
        logger.debug("npm not installed — skipping Node dependency audit")
    except (subprocess.TimeoutExpired, json.JSONDecodeError, Exception) as exc:
        logger.warning("npm audit failed: %s", exc)

    return [], []


def _classify_update_type(current: str, latest: str) -> str:
    """Classify whether an update is major, minor, or patch."""
    try:
        # Strip non-numeric prefix (^, ~, >=, etc.)
        cur = re.sub(r"^[^\d]*", "", current).split(".")
        lat = re.sub(r"^[^\d]*", "", latest).split(".")
        if len(cur) >= 1 and len(lat) >= 1 and int(lat[0]) > int(cur[0]):
            return "major"
        if len(cur) >= 2 and len(lat) >= 2 and int(lat[1]) > int(cur[1]):
            return "minor"
        return "patch"
    except (ValueError, IndexError):
        return "unknown"


def _find_blocking_decision(package: str, current: str, latest: str, decisions: list[dict]) -> str | None:
    """Return decision title if a decision blocks this package update, else None."""
    pkg_lower = package.lower()
    for decision in decisions:
        title = (decision.get("title") or "").lower()
        rationale = (decision.get("rationale") or "").lower()
        combined = title + " " + rationale
        if pkg_lower in combined:
            # Decision mentions this package
            return decision.get("title", "Unknown decision")[:80]
    return None


# ── Shared helpers ────────────────────────────────────────────────────────────


async def _detect_stack(product_id: str) -> list[str]:
    """Detect stack from capabilities."""
    try:
        async with pool.connection() as db:
            rows = parse_rows(
                await db.query(
                    "SELECT slug, title, category FROM capability WHERE product = <record>$product AND status != 'deprecated' LIMIT 30",
                    {"product": product_id},
                )
            )
        from core.engine.product.generation_engine import _infer_stack_from_capabilities

        return _infer_stack_from_capabilities(rows)
    except Exception:
        return ["python"]  # safe default
