import logging
import os
from typing import Any, Literal, Optional
from uuid import uuid4

import dotenv
import grpc
import neo4j
from neo4j import AsyncGraphDatabase
from fire import Fire  # type: ignore[import-untyped]
from observability_common.auth import load_auth_provider
from observability_common.certificates import load_tls_credentials
from observability_common.grpc_interceptors import (
    AsyncAuthInterceptor,
    create_service_interceptor,
    disable_rbac,
    require_role,
)
from observability_common.proto import observability_pb2_grpc as observability_grpc
from observability_common.proto.observability_pb2 import (
    Computation,
    ComputationEdge,
    Computations,
    Dependencies,
    Edge,
    Event,
    Events,
    Graph,
    IDs,
    Response,
    Role,
    SliceRequest,
    SpanRequest,
    SpanStatusCode,
    TraceGraph,
    TraceRequest,
)

from .utils import (
    computation_from_dict,
    computation_message_edge_from_dict,
    edge_from_dict,
    event_from_dict,
    tool_to_json,
    tools_to_json,
)

logger = logging.getLogger(__name__)

dotenv.load_dotenv()

# Shared async Neo4j driver
_async_driver: Optional[neo4j.AsyncDriver] = None


def _get_async_driver(uri: str, auth: tuple[str, str]) -> neo4j.AsyncDriver:
    """Get or create a shared async Neo4j driver."""
    global _async_driver
    if _async_driver is None:
        _async_driver = AsyncGraphDatabase.driver(uri, auth=auth)
    return _async_driver


async def _record_events(
    events: list[Event],
    uri: str,
    auth: tuple[str, str],
    db_name: str,
) -> list[str]:
    """
    Records the events as nodes in the Neo4j database.

    Args:
        events: the events to record corresponding to messages with metadata
        uri: the URI of the neo4j instance
        auth: the credentials for the neo4j instance (username, password)
        db_name: the name of the neo4j database

    Returns: the provided or generated IDs of the event nodes
    """

    event_ids = []
    driver = _get_async_driver(uri, auth)

    for event in events:
        # Events without text are allowed if they have tools (tool-calling messages)
        if not event.HasField("text") and not event.tools:
            # TODO: improve RPC response type to allow signalling
            event_ids.append("")
            continue

        load_query = """
        MERGE (m: Message {id: $id})
        ON CREATE SET m.content = $content,
        m.created_at = datetime.transaction()
        """
        params = {
            "content": event.text,
            # Retain the provided ID from the user or generate a new one
            "id": event.id if event.HasField("id") else str(uuid4()),
        }

        for field in ["role", "agent", "tools", "derived_from"]:
            if field == "tools":
                present = bool(event.tools)
            elif field == "derived_from":
                present = event.HasField("derived_from")
            else:
                present = event.HasField(field)
            if present:
                load_query += f""",
                m.{field} = ${field}
                """
                if field == "agent":
                    # Only add dynamic label if agent name is valid (non-empty)
                    agent_value = str(getattr(event, field))
                    if (
                        agent_value.strip()
                    ):  # Check if agent name is not empty/whitespace
                        load_query += """,
                        m:$($agent)
                        """

                value = getattr(event, field)
                # TODO: we may want to represent tool
                # calls as separate nodes
                if field == "tools":
                    # Record all tool calls as a JSON string
                    params[field] = tools_to_json(value)
                elif field == "derived_from":
                    # Record derived_from as a JSON object using shared helper
                    params[field] = tool_to_json(value)
                # role is an integer
                elif field != "role":
                    params[field] = str(value)
                else:
                    match value:
                        case Role.SYSTEM:
                            params[field] = "system"
                        case Role.USER:
                            params[field] = "user"
                        case Role.LLM:
                            params[field] = "llm"
                        case Role.AGENT:
                            params[field] = "agent"

        event_ids.append(params["id"])
        try:
            logger.debug(f"Recording event {params['id']}: query={load_query[:100]}...")
            await driver.execute_query(load_query, database_=db_name, parameters_=params)  # type: ignore
            logger.debug(f"Successfully recorded event {params['id']}")
        except neo4j.exceptions.ConstraintError:
            logger.debug(f"Event {params['id']} already exists (constraint)")
        except Exception as e:
            logger.error(f"Failed to record event {params['id']}: {e}")
            logger.error(f"Query was: {load_query}")
            logger.error(f"Params were: {params}")

    return event_ids


async def _record_dependencies(
    dependencies: list[Edge],
    uri: str,
    auth: tuple[str, str],
    db_name: str,
) -> None:
    """
    Records dependencies between events with metadata in the Neo4j database.

    Args:
        dependencies: the dependencies to record between events in the graph
        uri: the URI of the neo4j instance
        auth: the credentials for the neo4j instance (username, password)
        db_name: the name of the neo4j database
    """
    driver = _get_async_driver(uri, auth)

    for e in dependencies:
        # Ignore edges missing source or destination data
        if not (e.source and e.destination):
            continue
        source, destination = e.source, e.destination

        params = {"source": source, "destination": destination}

        # Build metadata string for optional fields
        metadata_parts = ["source: $source", "destination: $destination"]
        for field in ["message_index", "proximal"]:
            if e.HasField(field):
                metadata_parts.append(f"{field}: ${field}")
                params[field] = str(getattr(e, field))

        metadata_str = " {" + ", ".join(metadata_parts) + "}"

        # Use MERGE to avoid duplicate edges with same source, destination, and properties
        query = f"""
        MATCH (s: Message {{id: $source}})
        MATCH (d: Message {{id: $destination}})
        MERGE (d)-[:DEPENDS_ON{metadata_str}]->(s)
        """

        await driver.execute_query(query, database_=db_name, parameters_=params)  # type: ignore


async def _backward_slice(
    request: SliceRequest,
    uri: str,
    auth: tuple[str, str],
    db_name: str,
) -> Graph:
    """
    Queries the neo4j database for the subgraph of the event graph containing
    the ancestors of the node with `event_id`, up to a maximum depth.

    Note: depth is computed via any direct dependency, not just proximal dependencies
    (e.g. an LLM message depends proximally on its predecessor but depends directly
    on all messages in the context window).

    Args:
        request: a SliceRequest(event_id, max_depth)
        uri: the URI of the neo4j instance
        auth: the credentials for the neo4j instance (username, password)
        db_name: the name of the neo4j database

    Returns: a graph representation of the backward slice in Graph(Event, Edge) form
    """
    driver = _get_async_driver(uri, auth)

    assert request.event_id is not None
    event_id = request.event_id
    max_depth = request.max_depth if request.HasField("max_depth") else None

    params = {"id": event_id}
    if max_depth is None:
        depth_str = "+"
    else:
        depth_str = f"{{1,{max_depth:d}}}"

    # Get all nodes in the subgraph
    node_query = f"""
    MATCH (m: Message {{id: $id}})-[:DEPENDS_ON]->{depth_str}(m2)
    RETURN m2 as node
    UNION
    MATCH (m: Message {{id: $id}})
    RETURN m as node
    """

    # Get all edges in the subgraph
    edge_query = f"""
    MATCH (m: Message {{id: $id}})-[ds:DEPENDS_ON]->{depth_str}(m2)
    UNWIND ds as edge
    RETURN DISTINCT edge
    """

    nodes = await driver.execute_query(
        node_query, database_=db_name, parameters_=params  # type: ignore
    )
    edges = await driver.execute_query(
        edge_query, database_=db_name, parameters_=params  # type: ignore
    )
    return Graph(
        nodes=[
            event_from_dict(dict(n.value("node").items())) for n in nodes.records
        ],
        edges=[
            edge_from_dict(dict(e.value("edge").items())) for e in edges.records
        ],
    )


async def _forward_slice(
    request: SliceRequest,
    uri: str,
    auth: tuple[str, str],
    db_name: str,
) -> Graph:
    """
    Queries the neo4j database for the subgraph of the event graph containing
    the descendants of the node with `event_id`, up to a maximum depth.

    Note: depth is computed via any direct dependency, not just proximal dependencies
    (e.g. an LLM message depends proximally on its predecessor but depends directly
    on all messages in the context window).

    Args:
        request: a SliceRequest(event_id, max_depth)
        uri: the URI of the neo4j instance
        auth: the credentials for the neo4j instance (username, password)
        db_name: the name of the neo4j database

    Returns: a graph representation of the forward slice in Graph(Event, Edge) form
    """
    driver = _get_async_driver(uri, auth)

    assert request.event_id is not None
    event_id = request.event_id
    max_depth = request.max_depth if request.HasField("max_depth") else None

    params = {"id": event_id}
    if max_depth is None:
        depth_str = "+"
    else:
        depth_str = f"{{1,{max_depth:d}}}"

    # Get all nodes in the subgraph
    node_query = f"""
    MATCH (m: Message {{id: $id}})<-[:DEPENDS_ON]-{depth_str}(m2)
    RETURN m2 as node
    UNION
    MATCH (m: Message {{id: $id}})
    RETURN m as node
    """

    # Get all edges in the subgraph
    edge_query = f"""
    MATCH (m: Message {{id: $id}})<-[ds:DEPENDS_ON]-{depth_str}(m2)
    UNWIND ds as edge
    RETURN DISTINCT edge
    """

    nodes = await driver.execute_query(
        node_query, database_=db_name, parameters_=params  # type: ignore
    )
    edges = await driver.execute_query(
        edge_query, database_=db_name, parameters_=params  # type: ignore
    )
    return Graph(
        nodes=[
            event_from_dict(dict(n.value("node").items())) for n in nodes.records
        ],
        edges=[
            edge_from_dict(dict(e.value("edge").items())) for e in edges.records
        ],
    )


async def _register_computations(
    computations: list[Computation],
    uri: str,
    auth: tuple[str, str],
    db_name: str,
) -> list[str]:
    """
    Records computations (OTel spans) as Computation nodes in the Neo4j database.

    Args:
        computations: the computations to record
        uri: the URI of the neo4j instance
        auth: the credentials for the neo4j instance (username, password)
        db_name: the name of the neo4j database

    Returns: the span_ids of the recorded computations
    """
    span_ids = []
    driver = _get_async_driver(uri, auth)

    for comp in computations:
        # Create Computation node
        query = """
        MERGE (c:Computation {span_id: $span_id})
        SET c.trace_id = $trace_id,
            c.parent_span_id = $parent_span_id,
            c.name = $name,
            c.start_time_ns = $start_time_ns,
            c.end_time_ns = $end_time_ns,
            c.duration_ns = $duration_ns,
            c.status_code = $status_code,
            c.status_message = $status_message,
            c.attributes = $attributes,
            c.events = $events,
            c.service_name = $service_name,
            c.service_version = $service_version,
            c.created_at = datetime.transaction(),
            c.id = $span_id
        """

        # Map status code enum to string
        status_code = SpanStatusCode.Name(comp.status_code)

        params = {
            "span_id": comp.span_id,
            "trace_id": comp.trace_id,
            "parent_span_id": comp.parent_span_id if comp.HasField("parent_span_id") else None,
            "name": comp.name,
            "start_time_ns": comp.start_time_ns,
            "end_time_ns": comp.end_time_ns,
            "duration_ns": comp.duration_ns,
            "status_code": status_code,
            "status_message": comp.status_message if comp.HasField("status_message") else None,
            "attributes": comp.attributes_json,
            "events": comp.events_json,
            "service_name": comp.service_name if comp.HasField("service_name") else None,
            "service_version": comp.service_version if comp.HasField("service_version") else None,
        }

        span_ids.append(comp.span_id)

        try:
            await driver.execute_query(query, database_=db_name, parameters_=params)  # type: ignore

            # Create CHILD_OF relationship if parent exists
            if comp.HasField("parent_span_id") and comp.parent_span_id:
                await driver.execute_query(
                    """
                    MATCH (c:Computation {span_id: $span_id})
                    MATCH (p:Computation {span_id: $parent_span_id})
                    MERGE (c)-[:CHILD_OF]->(p)
                    """,
                    database_=db_name,
                    parameters_={"span_id": comp.span_id, "parent_span_id": comp.parent_span_id},
                )

            # Create PRODUCES relationship to output message
            if comp.HasField("output_message_id") and comp.output_message_id:
                await driver.execute_query(
                    """
                    MATCH (c:Computation {span_id: $span_id})
                    MATCH (m:Message {id: $message_id})
                    MERGE (c)-[:PRODUCES]->(m)
                    """,
                    database_=db_name,
                    parameters_={"span_id": comp.span_id, "message_id": comp.output_message_id},
                )

            # Create CONSUMES relationships to input messages
            for idx, message_id in enumerate(comp.input_message_ids):
                await driver.execute_query(
                    """
                    MATCH (c:Computation {span_id: $span_id})
                    MATCH (m:Message {id: $message_id})
                    MERGE (c)-[:CONSUMES {message_index: $idx}]->(m)
                    """,
                    database_=db_name,
                    parameters_={"span_id": comp.span_id, "message_id": message_id, "idx": idx},
                )

        except neo4j.exceptions.ConstraintError:
            pass

    return span_ids


async def _get_trace(
    request: TraceRequest,
    uri: str,
    auth: tuple[str, str],
    db_name: str,
) -> TraceGraph:
    """
    Retrieves a full trace with all spans, messages, and relationships.

    Args:
        request: TraceRequest with trace_id and optional time filters
        uri: the URI of the neo4j instance
        auth: the credentials for the neo4j instance
        db_name: the name of the neo4j database

    Returns: TraceGraph containing computations, messages, and edges
    """
    driver = _get_async_driver(uri, auth)

    trace_id = request.trace_id
    params: dict[str, Any] = {"trace_id": trace_id}

    # Build time filter clause
    time_filter = ""
    if request.HasField("start_time_ns"):
        time_filter += " AND c.start_time_ns >= $start_time_ns"
        params["start_time_ns"] = request.start_time_ns
    if request.HasField("end_time_ns"):
        time_filter += " AND c.end_time_ns <= $end_time_ns"
        params["end_time_ns"] = request.end_time_ns

    # Get all computations in the trace
    comp_query = f"""
    MATCH (c:Computation {{trace_id: $trace_id}})
    WHERE true {time_filter}
    RETURN c as computation
    """
    comp_result = await driver.execute_query(comp_query, database_=db_name, parameters_=params)

    # Get CHILD_OF edges between computations
    child_of_query = """
    MATCH (c:Computation {trace_id: $trace_id})-[r:CHILD_OF]->(p:Computation)
    RETURN c.span_id as child_span_id, p.span_id as parent_span_id
    """
    child_of_result = await driver.execute_query(child_of_query, database_=db_name, parameters_={"trace_id": trace_id})

    # Get messages linked to computations (PRODUCES and CONSUMES)
    message_query = """
    MATCH (c:Computation {trace_id: $trace_id})-[r:PRODUCES|CONSUMES]->(m:Message)
    RETURN DISTINCT m as message
    """
    message_result = await driver.execute_query(message_query, database_=db_name, parameters_={"trace_id": trace_id})

    # Get message edges (PRODUCES/CONSUMES relationships)
    message_edge_query = """
    MATCH (c:Computation {trace_id: $trace_id})-[r:PRODUCES|CONSUMES]->(m:Message)
    RETURN c.span_id as span_id, m.id as message_id, type(r) as edge_type, r.message_index as message_index
    """
    message_edge_result = await driver.execute_query(
        message_edge_query, database_=db_name, parameters_={"trace_id": trace_id}
    )

    # Convert results to protobuf messages
    computations = [
        computation_from_dict(dict(r.value("computation").items()))
        for r in comp_result.records
    ]

    messages = [
        event_from_dict(dict(r.value("message").items()))
        for r in message_result.records
    ]

    computation_edges = [
        ComputationEdge(
            parent_span_id=r.value("parent_span_id"),
            child_span_id=r.value("child_span_id"),
        )
        for r in child_of_result.records
    ]

    message_edges = [
        computation_message_edge_from_dict({
            "span_id": r.value("span_id"),
            "message_id": r.value("message_id"),
            "edge_type": r.value("edge_type"),
            "message_index": r.value("message_index"),
        })
        for r in message_edge_result.records
    ]

    return TraceGraph(
        computations=computations,
        messages=messages,
        computation_edges=computation_edges,
        message_edges=message_edges,
    )


async def _get_span(
    request: SpanRequest,
    uri: str,
    auth: tuple[str, str],
    db_name: str,
) -> Computation:
    """
    Retrieves a single span by span_id.

    Args:
        request: SpanRequest with span_id
        uri: the URI of the neo4j instance
        auth: the credentials for the neo4j instance
        db_name: the name of the neo4j database

    Returns: The Computation (span) data
    """
    driver = _get_async_driver(uri, auth)

    span_id = request.span_id
    params = {"span_id": span_id}

    query = """
    MATCH (c:Computation {span_id: $span_id})
    RETURN c as computation
    """
    result = await driver.execute_query(query, database_=db_name, parameters_=params)

    if not result.records:
        return Computation()

    return computation_from_dict(dict(result.records[0].value("computation").items()))


class ObservabilityServer(observability_grpc.ObservabilityServicer):  # type: ignore
    """gRPC server for observability event registration and graph queries."""

    def __init__(
        self,
        uri: str,
        auth: tuple[str, str],
        db_name: str,
    ):
        self.uri = uri
        self.auth = auth
        self.db_name = db_name

    @require_role("observability-writer")
    async def RegisterEvents(  # type: ignore
        self,
        request: Events,
        context: grpc.aio.ServicerContext,
    ) -> IDs:
        ids = await _record_events(list(request.events), self.uri, self.auth, self.db_name)
        return IDs(ids=ids)

    @require_role("observability-writer")
    async def RegisterDependencies(  # type: ignore
        self, request: Dependencies, context: grpc.aio.ServicerContext
    ) -> Response:
        await _record_dependencies(list(request.edges), self.uri, self.auth, self.db_name)
        # Dummy response
        return Response()

    @require_role("observability-reader")
    async def BackwardSlice(  # type: ignore
        self,
        request: SliceRequest,
        context: grpc.aio.ServicerContext,
    ) -> Graph:
        return await _backward_slice(request, self.uri, self.auth, self.db_name)

    @require_role("observability-reader")
    async def ForwardSlice(  # type: ignore
        self,
        request: SliceRequest,
        context: grpc.aio.ServicerContext,
    ) -> Graph:
        return await _forward_slice(request, self.uri, self.auth, self.db_name)

    @require_role("observability-writer")
    async def RegisterComputations(  # type: ignore
        self,
        request: Computations,
        context: grpc.aio.ServicerContext,
    ) -> IDs:
        """Register OpenTelemetry spans as Computation nodes."""
        ids = await _register_computations(list(request.computations), self.uri, self.auth, self.db_name)
        return IDs(ids=ids)

    @require_role("observability-reader")
    async def GetTrace(  # type: ignore
        self,
        request: TraceRequest,
        context: grpc.aio.ServicerContext,
    ) -> TraceGraph:
        """Get a full trace with all spans, messages, and relationships."""
        return await _get_trace(request, self.uri, self.auth, self.db_name)

    @require_role("observability-reader")
    async def GetSpan(  # type: ignore
        self,
        request: SpanRequest,
        context: grpc.aio.ServicerContext,
    ) -> Computation:
        """Get a single span by span_id."""
        return await _get_span(request, self.uri, self.auth, self.db_name)


async def serve(
    bind_address_base: str = "localhost",
    database_name: str = "neo4j",
    neo4j_uri: Optional[str] = None,
    neo4j_username: Optional[str] = None,
    neo4j_password: Optional[str] = None,
    port: int = 10085,
    tls: bool = True,
    mtls: bool = False,
    cert_path: Optional[str] = None,
    key_path: Optional[str] = None,
    ca_path: Optional[str] = None,
    use_mkcert: bool = False,
    cert_dir: str = "certs",
    auth_config_path: Optional[str] = None,
    log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO",
) -> None:
    """
    Starts the gRPC server and creates the database if it does not exist.

    Note: Graph updates streaming is provided by the separate updates server
    (observability.updates_server) which requires mTLS authentication.

    Args:
        bind_address_base: Host to bind to
        database_name: Neo4j database name
        neo4j_uri: Neo4j connection URI (or NEO4J_URI env var)
        neo4j_username: Neo4j username (or NEO4J_USERNAME env var)
        neo4j_password: Neo4j password (or NEO4J_PASSWORD env var)
        port: Port to listen on
        tls: Enable TLS
        mtls: Enable mutual TLS (requires client certificates)
        cert_path: Path to server certificate
        key_path: Path to server private key
        ca_path: Path to CA certificate for client verification
        use_mkcert: Use mkcert for certificate generation
        cert_dir: Directory for generated certificates
        auth_config_path: Path to auth config JSON (same format as reference monitor)
        log_level: Logging level (DEBUG, INFO, WARNING, ERROR)
    """
    # Configure logging
    logging.basicConfig(
        level=getattr(logging, log_level),
        format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
    )

    uri = neo4j_uri or os.environ["NEO4J_URI"]
    auth = (
        neo4j_username or os.environ["NEO4J_USERNAME"],
        neo4j_password or os.environ["NEO4J_PASSWORD"],
    )

    # Initialize the shared async driver
    driver = _get_async_driver(uri, auth)
    await driver.verify_connectivity()

    # Create the database if it does not exist
    try:
        await driver.execute_query(
            "CREATE DATABASE $database", database_="system", database=database_name
        )
    except neo4j.exceptions.ClientError:
        pass  # Database already exists

    try:
        # Define required indices and constraints (idempotent with IF NOT EXISTS)
        # For efficient node retrieval by our generated ID
        # While it serves a similar function, neo4j does not recommend
        # use of their internal IDs
        await driver.execute_query(
            """
            CREATE CONSTRAINT id_is_key IF NOT EXISTS
            FOR (m: Message)
            REQUIRE m.id IS NODE KEY
            """,
            database_=database_name,
        )
        # Computation node constraint and indexes for OTel spans
        await driver.execute_query(
            """
            CREATE CONSTRAINT computation_span_id_key IF NOT EXISTS
            FOR (c:Computation)
            REQUIRE c.span_id IS NODE KEY
            """,
            database_=database_name,
        )
    except (neo4j.exceptions.ClientError, neo4j.exceptions.DatabaseError):
        pass  # Node key constraints require Neo4j Enterprise Edition

    try:
        # Improves performance on node aggregation queries for infinite
        # loop detection
        await driver.execute_query(
            """
            CREATE INDEX content_agent_role IF NOT EXISTS
            FOR (m: Message)
            ON (m.content, m.agent, m.role)
            """,
            database_=database_name,
        )

        await driver.execute_query(
            """
            CREATE INDEX computation_trace_id IF NOT EXISTS
            FOR (c:Computation)
            ON (c.trace_id)
            """,
            database_=database_name,
        )
        await driver.execute_query(
            """
            CREATE INDEX computation_start_time IF NOT EXISTS
            FOR (c:Computation)
            ON (c.start_time_ns)
            """,
            database_=database_name,
        )
    except neo4j.exceptions.ClientError:
        pass

    # Configure interceptors (same approach as reference monitor)
    interceptors = []
    if auth_config_path:
        # JWT/API key/mTLS auth via config file
        auth_provider = load_auth_provider(auth_config_path)
        interceptors.append(AsyncAuthInterceptor(auth_provider))
        logger.info(f"Auth enabled via config: {auth_config_path}")
    elif mtls:
        # mTLS only - use service interceptor
        interceptors.append(
            create_service_interceptor("observability-server")  # type: ignore[arg-type]
        )
        logger.info("mTLS auth enabled with role-based access control")
    else:
        # No auth configured
        disable_rbac()
        logger.warning("Auth disabled - RBAC checks will be skipped")

    server = grpc.aio.server(interceptors=interceptors)
    observability_grpc.add_ObservabilityServicer_to_server(
        ObservabilityServer(uri, auth, database_name),
        server,
    )  # type: ignore
    url = f"{bind_address_base}:{port}"

    if tls or mtls:
        server_cert, server_key, ca_cert = load_tls_credentials(
            cert_path=cert_path,
            key_path=key_path,
            ca_path=ca_path,
            use_mkcert=use_mkcert,
            cert_name=bind_address_base,
            cert_dir=cert_dir,
        )

        # Always pass CA cert so client certs can be optionally accepted
        # When mtls=True, client certs are required; when False, they're optional
        credentials = grpc.ssl_server_credentials(
            [(server_key, server_cert)],
            root_certificates=ca_cert,
            require_client_auth=mtls,
        )
        tls_mode = "mTLS (required)" if mtls else "TLS (mTLS optional)"
        logger.info(f"{tls_mode} enabled")

        bound_port = server.add_secure_port(url, credentials)
        if bound_port == 0:
            logger.error(f"Failed to bind secure port {url}")
        else:
            logger.debug(f"Bound secure port: {bound_port}")
    else:
        server.add_insecure_port(url)
        logger.warning("Running without TLS (insecure)")

    await server.start()
    logger.info(f"Observability server started, listening on {url}")

    await server.wait_for_termination()


if __name__ == "__main__":
    Fire(serve)
