"""Layer 3 — In-memory tracer + verified span shape + log/trace correlation.

Production-shaped OTel use, with the in-memory exporter swapped
in so the example can verify what real exporters would see —
without needing a running collector. Demonstrates the patterns
from the LOGGING standard §7 + DESIGN_PRINCIPLES standard
(mgf-common standards LOGGING / mgf-common standards DESIGN_PRINCIPLES):

  • Every L2 use case wrapped in ``operation_span`` (rule §7.3
    of LOGGING.md). Span names follow ``<subject>.<verb>``;
    attributes are snake_case.

  • Trace-context propagation across nested spans — child spans
    automatically inherit the parent's trace_id, with their own
    span_id. The full call tree is reconstructable from the
    exported spans.

  • Log records inside a span auto-carry ``trace_id`` + ``span_id``
    (rule LG-03). The standard JSON formatter
    (``mgf.common.observability.json_formatter._add_otel_context``)
    reads the active span at format time.

  • Operation start/end log records (rule LG-11) bracket each
    span, giving the same flow two views: the trace (low-level
    timing graph) and the log query (operator-friendly text
    timeline).

  • Cancellation / failure inside a span — the SDK records the
    exception on the span; the operator sees both the failed
    span and the ERROR log record carrying the same trace_id.

The in-memory exporter is the same trick used by the project's
own test suite (``tests/unit/observability/conftest.py``). It
keeps the example fully runnable without OTel infrastructure.

Run with::

    python examples/04_observability/03_advanced.py
"""

from __future__ import annotations

import json
import logging
import os
import tempfile
from pathlib import Path

# OTel SDK is part of the [observability] extra; the dev install
# already has it. The example assumes it; if you've installed
# without the extra, install with: pip install 'mgf-common[observability]'
from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
    InMemorySpanExporter,
)

import mgf.common
from mgf.common.observability import operation_span
from mgf.common.settings import LoggingSettings, MgfSettings


def install_in_memory_tracer(service_name: str) -> InMemorySpanExporter:
    """Install an in-memory TracerProvider so the example can read
    back the spans without a real collector.

    NOTE: Per ``tests/unit/observability/conftest.py``,
    ``set_tracer_provider`` is one-shot per process. If this
    example is imported and run twice in the same process, the
    second install is a silent no-op. The smoke test invokes each
    example as a fresh subprocess (rule TS-03), so this is fine.
    """
    exporter = InMemorySpanExporter()
    provider = TracerProvider(resource=Resource.create({"service.name": service_name}))
    provider.add_span_processor(SimpleSpanProcessor(exporter))
    trace.set_tracer_provider(provider)
    return exporter


def use_case_create(LOG: logging.Logger, vm_name: str) -> str:
    """Outer use case — wraps the entire workflow in one span.

    Inner steps spawn child spans; they inherit this span's
    trace_id. The exporter shows the full tree.
    """
    # Operation start (rule LG-11). The `operation` field joins
    # log records to the trace by name; the trace_id field joins
    # by ID at the structural level.
    LOG.info(
        "operation start",
        extra={"operation": "vm.create", "vm_name": vm_name},
    )

    with operation_span("vm.create", vm_name=vm_name):
        _step_provision(LOG, vm_name)
        _step_attach_disks(LOG, vm_name, disk_count=2)
        _step_start(LOG, vm_name)

    LOG.info(
        "operation end",
        extra={"operation": "vm.create", "vm_name": vm_name, "result": "ok"},
    )
    return f"vm-{vm_name}-id"


def _step_provision(LOG: logging.Logger, vm_name: str) -> None:
    """Inner step — child span of the parent vm.create span."""
    with operation_span("vm.create.provision", vm_name=vm_name):
        LOG.info("provisioning hardware spec", extra={"vm_name": vm_name})


def _step_attach_disks(
    LOG: logging.Logger, vm_name: str, *, disk_count: int
) -> None:
    """Inner step — fans out one grandchild span per disk."""
    with operation_span("vm.create.attach_disks", vm_name=vm_name, disk_count=disk_count):
        for i in range(disk_count):
            with operation_span("vm.disk.attach", vm_name=vm_name, disk_index=i):
                LOG.info(
                    "attached disk",
                    extra={"vm_name": vm_name, "disk_index": i},
                )


def _step_start(LOG: logging.Logger, vm_name: str) -> None:
    """Inner step — boots the VM, simulating a transient failure
    that surfaces on the span as a recorded exception.
    """
    with operation_span("vm.create.start", vm_name=vm_name) as _span:  # noqa: F841
        try:
            # Simulate a one-shot failure that the use case logs but
            # otherwise tolerates (in real code, this would be a
            # retry — see ERROR_HANDLING.md §15).
            raise RuntimeError("kvm module not loaded")
        except RuntimeError as e:
            # ERROR-level log carries the traceback (rule LG-05) AND
            # auto-carries trace_id/span_id (rule LG-03). The same
            # event is visible in two systems with the same correlation.
            LOG.exception(
                "VM start failed",
                extra={"vm_name": vm_name, "transient": True},
            )
            # Mark the span as errored so the trace tool shows it
            # red. operation_span doesn't auto-record exceptions
            # raised AND caught inside the span; do it explicitly
            # when relevant.
            current = trace.get_current_span()
            if current.is_recording():
                current.record_exception(e)
                current.set_status(trace.StatusCode.ERROR, str(e))


def main() -> None:
    with tempfile.TemporaryDirectory() as tmp:
        os.environ["XDG_STATE_HOME"] = tmp
        os.environ["HOME"] = tmp

        log_file = Path(tmp) / "myapp.log"

        # bootstrap() with OTel disabled — we install our in-memory
        # exporter manually below so the example can read back what
        # would have been sent. This is the same fixture pattern the
        # project's own test suite uses (rule TS-03).
        ctx = mgf.common.bootstrap(
            app_name="example-advanced-otel",
            app_version="0.1.0",
            settings=MgfSettings(
                logging=LoggingSettings(level="DEBUG", format="json", file=log_file),
            ),
        )

        exporter = install_in_memory_tracer(service_name=mgf.common.app_name())

        LOG = logging.getLogger(__name__)
        LOG.info("starting demo", extra={"phase": "boot"})

        # Run the use case — generates a span tree.
        result = use_case_create(LOG, vm_name="demo-vm-1")
        print(f"\nuse case returned: {result}")

        # ── Inspect the exported spans ──────────────────────
        spans = exporter.get_finished_spans()
        print(f"\n─── Captured {len(spans)} spans ─────────────")
        for s in spans:
            parent = s.parent.span_id if s.parent else None
            attrs = dict(s.attributes or {})
            duration_us = (s.end_time - s.start_time) // 1000
            print(
                f"  {s.name:<32} "
                f"trace={format(s.context.trace_id, 'x')[:8]}.. "
                f"span={format(s.context.span_id, 'x')[:8]}.. "
                f"parent={'-' if parent is None else format(parent, 'x')[:8] + '..'} "
                f"{duration_us}µs "
                f"{attrs}"
            )

        # ── Verify log records carry trace correlation ─────
        records = [json.loads(line) for line in log_file.read_text().splitlines()]
        with_trace = [r for r in records if "trace_id" in r]
        print(f"\n─── Log/trace correlation ──────────────────")
        print(f"  {len(with_trace)} of {len(records)} records carry trace_id")
        if with_trace:
            sample = with_trace[0]
            print(
                f"  example: msg={sample['msg']!r} "
                f"trace_id={sample['trace_id'][:8]}.. "
                f"span_id={sample['span_id'][:8]}.."
            )
        # The records emitted INSIDE the spans carry the IDs; records
        # emitted before any span (e.g., the "starting demo" record)
        # don't, which is the right behaviour — they're not part of
        # any traced operation.

        # ── Demonstrate the rule-§7.4 scope question ────────
        # operation_span shows EXECUTION failures (the kvm-module
        # error inside the vm.create.start span). It does NOT show
        # validation failures rejected at the parser before the span
        # opens. See LOGGING.md §7.4 for why this is the right scope.

        ctx.shutdown()


if __name__ == "__main__":
    main()
