"""FastAPI routers for topology (gRPC broker) and files (YAML database) search APIs."""

from __future__ import annotations

from enum import Enum
from pathlib import Path
from typing import Annotated, cast

from fastapi import APIRouter, HTTPException, Request
from fastapi import Query as QueryParam

from remotivelabs.broker.exceptions import BrokerConnectionError, BrokerError
from remotivelabs.cli.search.assembler import (
    assemble_broker_channels,
    assemble_parsed_db,
    count_broker_signals_and_frames,
    count_signals_and_frames,
)
from remotivelabs.cli.search.base import SearchAPI, SearchOptions
from remotivelabs.cli.search.grpc import _fetch_grpc_signals
from remotivelabs.cli.search.models import (
    BrokerChannel,
    IndexResponse,
    ParsedCanChannel,
    ParsedDbSearchResponse,
    ParsedLinChannel,
    SlimSearchResponse,
    StatusResponse,
    SuggestionItem,
    SuggestResponse,
)
from remotivelabs.cli.search.query import Operator, ParsedQuery, Query, resolve_filter_field
from remotivelabs.cli.search.sqlite.index import SQLiteSearch
from remotivelabs.cli.studio.api.files import run_cmd_and_get_yaml
from remotivelabs.cli.studio.api.path_utils import join_under, to_relative_path

# ---------------------------------------------------------------------------
# Shared OpenAPI error response schemas
# ---------------------------------------------------------------------------

_ERROR_400 = {
    "description": "Bad request — `q` is missing when required, or an unknown `_f_*` filter field was supplied.",
    "content": {
        "application/json": {
            "example": {"detail": "Query parameter 'q' is required for mode=search and mode=suggest (unless _f_* filters are provided)"}
        }
    },
}
_ERROR_404 = {
    "description": "File not found at the supplied `path`.",
    "content": {"application/json": {"example": {"detail": "No such file or directory: '/data/signals.yaml'"}}},
}
_ERROR_500 = {
    "description": "Unexpected server error.",
    "content": {"application/json": {"example": {"detail": "Internal server error"}}},
}
_ERROR_501 = {
    "description": "Operation not supported by the current backend.",
    "content": {"application/json": {"example": {"detail": "YAML file indexing is only supported by the SQLite backend"}}},
}
_ERROR_502 = {
    "description": "gRPC broker unreachable or returned an error.",
    "content": {"application/json": {"example": {"detail": "Failed to connect to broker at grpc://localhost:50051"}}},
}

# ---------------------------------------------------------------------------
# Common query parameter annotations
# ---------------------------------------------------------------------------

_FILTER_FIELDS_DESC = """\
`_f_<field>=value` parameters narrow results to entries where the named field
exactly equals `value` (case-sensitive prefix match). Multiple filters are
always ANDed together and with any `q` result. `q` may be omitted when at
least one filter is provided. An unknown field name returns HTTP 400.

Accepted field names (use the suffix after `_f_`):
`signal_name`,
`frame_name`,
`frame_id`,
`namespace`,
`channel`,
`description`,
`unit`, `senders`, `receivers`,
`named_values`,
`file_path`."""

_TOPOLOGY_DESCRIPTION = (
    """\
Search, suggest, or get status for live broker topology signals (gRPC source
only). Use `mode` to switch behaviour:

- **`search`** (default) — full-text search with prefix matching. Returns a
  hierarchical channel → frame → signal structure unless `slim=true`.
- **`suggest`** — returns autocomplete suggestions for the current query
  prefix. Useful for building incremental search UIs.
- **`status`** — returns the total number of indexed entries and the timestamp
  of the last index operation.

The topology index is populated by `POST /api/studio/v1/topology/index`.
Signal data is sourced exclusively from the connected gRPC broker; YAML file
signals are never mixed in.

### Filter parameters

"""
    + _FILTER_FIELDS_DESC
)

_FILES_DESCRIPTION = (
    """\
Search, suggest, or get status for signals indexed from
`remotive-signal-database` YAML files. Use `mode` to switch behaviour:

- **`search`** (default) — full-text search with prefix matching. Returns a
  hierarchical channel → frame → signal structure unless `slim=true`.
- **`suggest`** — returns autocomplete suggestions for the current query
  prefix.
- **`status`** — returns the total number of indexed entries and the timestamp
  of the last index operation.

Use `path` to scope results to a single previously indexed YAML file; omit it
to search across all indexed files. Topology (gRPC) signals are never mixed in.

### Filter parameters

"""
    + _FILTER_FIELDS_DESC
)


class Mode(str, Enum):
    """Query mode: search (default), suggest, or status."""

    SEARCH = "search"
    SUGGEST = "suggest"
    STATUS = "status"


topology_router = APIRouter(prefix="/api/studio/v1/topology", tags=["topology"])
files_router = APIRouter(prefix="/api/studio/v1/files/signaldb", tags=["files"])


def _get_index(request: Request) -> SearchAPI:
    return request.app.state.search_index  # type: ignore[no-any-return]


# ---------------------------------------------------------------------------
# Topology endpoints (gRPC broker)
# ---------------------------------------------------------------------------


@topology_router.post(
    "/index",
    summary="Index topology signals from the live broker",
    description=(
        "Fetch all signals from the connected gRPC broker and rebuild the topology "
        "index. Any previously indexed topology data is replaced. Signals from indexed "
        "YAML files are unaffected."
    ),
    response_description="Number of frames and signals written to the index.",
    responses={
        502: _ERROR_502,
        500: _ERROR_500,
    },
)
def reindex_topology(request: Request) -> IndexResponse:
    """Fetch all signals from the live broker and rebuild the topology index."""
    index = _get_index(request)
    broker_url: str = request.app.state.broker_url
    try:
        signals = _fetch_grpc_signals(broker_url)
        count = index.index_signals(signals)
    except (BrokerConnectionError, BrokerError) as e:
        raise HTTPException(status_code=502, detail=str(e)) from e
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e)) from e
    return IndexResponse(indexed=count)


@topology_router.get(
    "",
    summary="Search, suggest, or status — topology (gRPC broker)",
    description=_TOPOLOGY_DESCRIPTION,
    response_description=(
        "Hierarchical channel → frame → signal structure (default), slim ID list "
        "(`slim=true`), suggest candidates (`mode=suggest`), or index status "
        "(`mode=status`)."
    ),
    responses={
        400: _ERROR_400,
        500: _ERROR_500,
    },
)
def get_topology(  # noqa: PLR0913
    request: Request,
    mode: Annotated[
        Mode,
        QueryParam(
            description=(
                "Query mode. `search` (default) returns matching signals, "
                "`suggest` returns autocomplete candidates, `status` returns index metadata."
            ),
            openapi_examples={"search": {"summary": "Search (default)", "value": "search"}},
        ),
    ] = Mode.SEARCH,
    q: Annotated[
        str | None,
        QueryParam(
            min_length=1,
            description=(
                "Full-text search query (minimum 1 character). Supports prefix matching by default. "
                "Use `field:value` syntax to restrict to a specific field (e.g. `namespace:Powertrain`, "
                "`unit:rpm`). Separate multiple terms with spaces (AND by default). "
                "Optional when `_f_*` filter parameters are provided."
            ),
            openapi_examples={
                "plain": {"summary": "Plain prefix search", "value": "EngineRPM"},
                "field_scoped": {"summary": "Field-scoped search", "value": "namespace:Powertrain"},
                "multi_term": {"summary": "Multiple terms (AND)", "value": "Engine rpm"},
            },
        ),
    ] = None,
    limit: Annotated[
        int,
        QueryParam(
            ge=1,
            le=500,
            description="Maximum number of results to return (1–500). Ignored when `slim=true`. Capped at 100 for `mode=suggest`.",
            openapi_examples={"default": {"summary": "Default limit", "value": 50}},
        ),
    ] = 50,
    exact: Annotated[
        bool,
        QueryParam(
            description="Require exact token match instead of prefix expansion. Ignored when `substring=true`.",
        ),
    ] = False,
    slim: Annotated[
        bool,
        QueryParam(
            description=(
                "Return a slim response containing signal IDs only (no frame metadata or hierarchy). "
                "IDs have the form `namespace:signal_name`. The `limit` parameter is ignored in slim mode."
            ),
        ),
    ] = False,
    substring: Annotated[
        bool,
        QueryParam(
            description=(
                "Enable infix (substring) matching using SQL `LIKE '%term%'`. "
                "Use when prefix search is insufficient — e.g. `RPM` will match `EngineRPM`. "
                "Significantly slower on large indexes. Overrides `exact`."
            ),
        ),
    ] = False,
    operator: Annotated[
        Operator,
        QueryParam(
            description=(
                "How multiple query terms are combined. `and` (default) requires all terms to match; "
                "`or` requires any term to match. Filters are always AND-combined regardless."
            ),
            openapi_examples={"and": {"summary": "AND (default)", "value": "and"}},
        ),
    ] = Operator.AND,
    selected: Annotated[
        list[str],
        QueryParam(
            description=(
                "For `mode=suggest` only: already-confirmed query terms. "
                "Suggestions are scoped to signals that also match these terms, and the confirmed values are excluded from results. "
                "Repeatable — pass multiple `selected=` values."
            ),
        ),
    ] = [],  # noqa: B006
) -> ParsedDbSearchResponse | SlimSearchResponse | SuggestResponse | StatusResponse:
    """Search, suggest, or status for live broker topology (gRPC source only). Default mode=search."""
    filters: dict[str, str] = {}
    for key, value in request.query_params.items():
        if key.startswith("_f_"):
            col = resolve_filter_field(key[3:])
            if col is None:
                raise HTTPException(status_code=400, detail=f"Unknown filter field: {key!r}")
            filters[col] = value
    if mode in (Mode.SEARCH, Mode.SUGGEST) and not q and not filters:
        raise HTTPException(
            status_code=400, detail="Query parameter 'q' is required for mode=search and mode=suggest (unless _f_* filters are provided)"
        )
    index = _get_index(request)
    if mode == Mode.STATUS:
        parsed_q = Query.parse(q) if q else None
        s = index.status(
            source_filter="grpc",
            filters=filters or None,
            query=parsed_q,
            exact=exact,
            substring=substring,
            operator=operator,
        )
        return StatusResponse(total=s.total, last_indexed=s.last_indexed)
    parsed = Query.parse(q) if q else Query(ParsedQuery(terms=[], active_term=None))
    opts = SearchOptions(
        exact=exact,
        substring=substring,
        operator=operator,
        source_filter="grpc",
        filters=filters,
    )
    if mode == Mode.SUGGEST:
        selected_queries = [Query.parse(s) for s in selected]
        suggest_limit = min(limit, 100)
        raw_items, total = index.suggest(
            parsed,
            selected=selected_queries,
            limit=suggest_limit,
            opts=opts,
        )
        items = [SuggestionItem(**s) for s in raw_items]
        return SuggestResponse(suggestions=items, total=total)
    if slim:
        ids = index.search_slim(parsed, opts=opts)
        return SlimSearchResponse(ids=ids, total=len(ids))
    rows = index.search(parsed, limit=limit, opts=opts)
    assert isinstance(index, SQLiteSearch)
    channels = assemble_broker_channels(rows, index)
    typed_channels = cast(dict[str, ParsedCanChannel | ParsedLinChannel | BrokerChannel], channels)
    return ParsedDbSearchResponse(channels=typed_channels, total=count_broker_signals_and_frames(channels))


# ---------------------------------------------------------------------------
# Files endpoints (YAML database files)
# ---------------------------------------------------------------------------


@files_router.post(
    "/index",
    summary="Index a signal database file, only CAN is currently supported",
    description=(
        "Index a signal database file so its signals become "
        "searchable via `GET /api/studio/v1/files/signaldb`. The file is converted through "
        "the RemotiveLabs CLI pipeline before indexing. Re-indexing the same "
        "`path` replaces only that file's entries — signals from other indexed files "
        "and topology signals are unaffected."
    ),
    response_description="Number of frames and signals written to the index.",
    responses={
        400: {
            "description": "Bad request — `path` points to a directory rather than a file, or the signal database conversion failed.",
            "content": {"application/json": {"example": {"detail": "Not a file: '/data/'"}}},
        },
        404: _ERROR_404,
        500: _ERROR_500,
        501: _ERROR_501,
    },
)
def reindex_files(
    request: Request,
    path: Annotated[
        str,
        QueryParam(
            description=(
                "Absolute or workspace-relative path to a `remotive-signal-database` YAML file. "
                "Path traversal outside the workspace root is rejected with HTTP 400."
            ),
            openapi_examples={"default": {"summary": "YAML signal database path", "value": "/data/signals.yaml"}},
        ),
    ],
) -> IndexResponse:
    """Index a YAML signal database file. Re-indexing the same path replaces its entries."""
    index = _get_index(request)
    if not isinstance(index, SQLiteSearch):
        raise HTTPException(status_code=501, detail="YAML file indexing is only supported by the SQLite backend")
    workspace = request.app.state.topology.workspace
    abs_path = join_under(workspace, Path(path))
    rel_path = to_relative_path(workspace, Path(path))
    yaml_db = run_cmd_and_get_yaml(["convert", "--file"], request, path)
    raw = bytes(yaml_db.body).decode("utf-8")
    try:
        count = index.index_yaml_data(rel_path, str(abs_path), raw)
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e)) from e
    return IndexResponse(indexed=count)


@files_router.get(
    "",
    summary="Search, suggest, or status — YAML signal database files",
    description=_FILES_DESCRIPTION,
    response_description=(
        "Hierarchical channel → frame → signal structure (default), slim ID list "
        "(`slim=true`), suggest candidates (`mode=suggest`), or index status "
        "(`mode=status`)."
    ),
    responses={
        400: _ERROR_400,
        500: _ERROR_500,
    },
)
def get_files(  # noqa: PLR0913
    request: Request,
    mode: Annotated[
        Mode,
        QueryParam(
            description=(
                "Query mode. `search` (default) returns matching signals, "
                "`suggest` returns autocomplete candidates, `status` returns index metadata."
            ),
            openapi_examples={"search": {"summary": "Search (default)", "value": "search"}},
        ),
    ] = Mode.SEARCH,
    q: Annotated[
        str | None,
        QueryParam(
            min_length=1,
            description=(
                "Full-text search query (minimum 1 character). Supports prefix matching by default. "
                "Use `field:value` syntax to restrict to a specific field (e.g. `channel:LP1CAN`, "
                "`unit:Unitless`). Separate multiple terms with spaces (AND by default). "
                "Optional when `_f_*` filter parameters are provided."
            ),
            openapi_examples={
                "plain": {"summary": "Plain prefix search", "value": "DDM1LIN"},
                "channel_scoped": {"summary": "Channel-scoped search", "value": "channel:LP1CAN"},
                "substring": {"summary": "Substring search (use with substring=true)", "value": "RPM"},
            },
        ),
    ] = None,
    path: Annotated[
        str | None,
        QueryParam(
            description=("Limit results to a specific previously indexed YAML file path. Omit to search across all indexed files."),
            openapi_examples={"default": {"summary": "Scope to a single YAML file", "value": "/data/signals.yaml"}},
        ),
    ] = None,
    limit: Annotated[
        int,
        QueryParam(
            ge=1,
            le=50000,
            description="Maximum number of results to return (1–50000). Ignored when `slim=true`. Capped at 100 for `mode=suggest`.",
            openapi_examples={"default": {"summary": "Default limit", "value": 50000}},
        ),
    ] = 50000,
    exact: Annotated[
        bool,
        QueryParam(
            description="Require exact token match instead of prefix expansion. Ignored when `substring=true`.",
        ),
    ] = False,
    slim: Annotated[
        bool,
        QueryParam(
            description=(
                "Return a slim response containing signal IDs only (no frame metadata or hierarchy). "
                "IDs have the form `channel:signal_name`. The `limit` parameter is ignored in slim mode."
            ),
        ),
    ] = False,
    substring: Annotated[
        bool,
        QueryParam(
            description=(
                "Enable infix (substring) matching using SQL `LIKE '%term%'`. "
                "Use when prefix search is insufficient — e.g. `RPM` will match `EngineRPM`. "
                "Significantly slower on large indexes. Overrides `exact`."
            ),
        ),
    ] = False,
    operator: Annotated[
        Operator,
        QueryParam(
            description=(
                "How multiple query terms are combined. `and` (default) requires all terms to match; "
                "`or` requires any term to match. Filters are always AND-combined regardless."
            ),
            openapi_examples={"and": {"summary": "AND (default)", "value": "and"}, "or": {"summary": "OR", "value": "or"}},
        ),
    ] = Operator.AND,
    selected: Annotated[
        list[str],
        QueryParam(
            description=(
                "For `mode=suggest` only: already-confirmed query terms. "
                "Suggestions are scoped to signals that also match these terms, and the confirmed values are excluded from results. "
                "Repeatable — pass multiple `selected=` values."
            ),
        ),
    ] = [],  # noqa: B006
) -> ParsedDbSearchResponse | SlimSearchResponse | SuggestResponse | StatusResponse:
    """Search, suggest, or status for indexed YAML database files. Default mode=search."""
    filters: dict[str, str] = {}
    for key, value in request.query_params.items():
        if key.startswith("_f_"):
            col = resolve_filter_field(key[3:])
            if col is None:
                raise HTTPException(status_code=400, detail=f"Unknown filter field: {key!r}")
            filters[col] = value
    if mode in (Mode.SEARCH, Mode.SUGGEST) and not q and not filters:
        raise HTTPException(
            status_code=400, detail="Query parameter 'q' is required for mode=search and mode=suggest (unless _f_* filters are provided)"
        )
    index = _get_index(request)
    workspace = request.app.state.topology.workspace
    if path:
        file_path_filter = to_relative_path(workspace, Path(path))
    else:
        file_path_filter = None
    if "file_path" in filters:
        filters["file_path"] = to_relative_path(workspace, Path(filters["file_path"]))
    if mode == Mode.STATUS:
        parsed_q = Query.parse(q) if q else None
        s = index.status(
            source_filter="yaml",
            file_path_filter=file_path_filter,
            filters=filters or None,
            query=parsed_q,
            exact=exact,
            substring=substring,
            operator=operator,
        )
        return StatusResponse(total=s.total, last_indexed=s.last_indexed)
    parsed = Query.parse(q) if q else Query(ParsedQuery(terms=[], active_term=None))
    opts = SearchOptions(
        exact=exact,
        substring=substring,
        operator=operator,
        source_filter="yaml",
        file_path_filter=file_path_filter,
        filters=filters,
    )
    if mode == Mode.SUGGEST:
        selected_queries = [Query.parse(s) for s in selected]
        suggest_limit = min(limit, 100)
        raw_items, total = index.suggest(
            parsed,
            selected=selected_queries,
            limit=suggest_limit,
            opts=opts,
        )
        items = [SuggestionItem(**s) for s in raw_items]
        return SuggestResponse(suggestions=items, total=total)
    if slim:
        ids = index.search_slim(parsed, opts=opts)
        return SlimSearchResponse(ids=ids, total=len(ids))
    assert isinstance(index, SQLiteSearch)
    rows = index.search(parsed, limit=limit, opts=opts)
    db = assemble_parsed_db(rows, index)
    typed_channels = cast(dict[str, ParsedCanChannel | ParsedLinChannel | BrokerChannel] | None, db.channels)
    return ParsedDbSearchResponse(
        channels=typed_channels,
        total=count_signals_and_frames(db),
    )
