"""query commands for cmem command line interface."""

import csv
import json
import sys
from datetime import datetime, timezone
from hashlib import sha1
from json import JSONDecodeError, load
from shutil import get_terminal_size
from time import sleep, time
from urllib.parse import quote
from uuid import uuid4

import click
import httpx
from click import Argument, Context
from click.shell_completion import CompletionItem
from cmem_client.models.query_catalog import Query, QueryOrigin
from cmem_client.repositories.queries import (
    QueriesCreateConfig,
    QueriesDeleteConfig,
    QueriesUpdateConfig,
)

from cmem_cmemc import completion
from cmem_cmemc.command import CmemcCommand
from cmem_cmemc.command_group import CmemcGroup
from cmem_cmemc.completion import (
    NOT_SORTED,
    finalize_completion,
    get_completion_args,
    suppress_completion_errors,
)
from cmem_cmemc.constants import UNKNOWN_GRAPH_ERROR
from cmem_cmemc.context import ApplicationContext, build_caption
from cmem_cmemc.exceptions import CmemcError
from cmem_cmemc.object_list import (
    DirectListPropertyFilter,
    DirectMultiValuePropertyFilter,
    DirectValuePropertyFilter,
    Filter,
    MultiFieldPropertyFilter,
    ObjectList,
    compare_int_greater_than,
    compare_regex,
)
from cmem_cmemc.parameter_types.path import ClickSmartPath
from cmem_cmemc.placeholder import QueryPlaceholder, get_placeholders_for_query
from cmem_cmemc.smart_path import SmartPath as Path
from cmem_cmemc.string_processor import QueryLink
from cmem_cmemc.utils import (
    extract_error_message,
    get_graphs_as_dict,
    get_objects_to_delete,
    is_catalog_uri,
    struct_to_table,
)

QUERY_FILTER_TYPES = sorted(["graph", "status", "slower-than", "type", "regex", "trace-id", "user"])


def _enrich_placeholder_keys(
    keys: list[str],
    placeholders: dict[str, QueryPlaceholder],
    provided_params: dict[str, str],
) -> list[str | tuple[str, str]]:
    """Add labels and dependency hints to placeholder key completion options."""
    enriched: list[str | tuple[str, str]] = []
    for key in keys:
        if key not in placeholders:
            enriched.append(key)
            continue
        ph = placeholders[key]
        needed = Query(text=ph.value_query).get_placeholder_keys() if ph.value_query else set()
        unmet = needed - set(provided_params)
        if unmet:
            enriched.append((key, f"{ph.label} (requires first: {', '.join(sorted(unmet))})"))
        else:
            enriched.append((key, ph.label))
    return enriched


@suppress_completion_errors
def placeholder(ctx: Context, param: Argument, incomplete: str) -> list[CompletionItem]:
    """Prepare a list of placeholder from the to-be executed queries."""
    _ = param
    args = get_completion_args(incomplete)
    # setup configuration
    app = ApplicationContext.set_connection_from_params(ctx.find_root().params)
    catalog_graph = ctx.params.get("catalog_graph")
    # extract placeholder from given queries in the command line
    options = []
    placeholders: dict[str, QueryPlaceholder] = {}
    for _, arg in enumerate(args):
        query = get_query_from_file_or_catalog(app, arg, catalog_graph=catalog_graph)
        if query is not None:
            # collect all placeholder descriptions only for remote queries
            # (file-based queries have no placeholder metadata in the catalog)
            if query.origin == QueryOrigin.REMOTE:
                placeholders = placeholders | get_placeholders_for_query(
                    client=app.client, iri=query.url, catalog_graph=catalog_graph
                )
            # collect all placeholder keys
            options.extend(list(query.get_placeholder_keys()))
    # look if the cursor is in value position of the -p option and
    # use placeholder value completion, in case it is
    if args[len(args) - 2] in ("-p", "--parameter"):
        key = args[len(args) - 1]
        if key in placeholders:
            candidates = placeholders[key].complete(
                incomplete=incomplete,
                provided_params=dict(ctx.params.get("parameter") or []),
            )
            return finalize_completion(
                candidates=candidates, incomplete=incomplete, sort_by=NOT_SORTED
            )
        return []

    # look for already given parameter in the arguments and remove them from
    # the available options
    for num, arg in enumerate(args):
        if num - 1 > 0 and args[num - 1] in ("-p", "--parameter"):
            options.remove(arg)
    provided_params = dict(ctx.params.get("parameter") or [])
    enriched = _enrich_placeholder_keys(options, placeholders, provided_params)
    return finalize_completion(candidates=enriched, incomplete=incomplete)


def get_query_from_file_or_catalog(
    app: ApplicationContext, identifier: str, catalog_graph: str | None = None
) -> Query | None:
    """Get a query from a file path or catalog URI.

    Args:
        app: Application context with client
        identifier: File path, full URI, or short URI (:uuid)
        catalog_graph: Catalog graph to fetch from if needed

    Returns:
        Query object if found, None otherwise

    """
    # Check if identifier is a local file (skip file check for obvious catalog URIs)
    if not is_catalog_uri(identifier):
        path = Path(identifier)
        if path.is_file():
            with path.open(encoding="utf-8") as f:
                query_text = f.read()
            query_obj = Query(
                text=query_text,
                label=f"query from file {identifier}",
                origin=QueryOrigin.FILE,
            )
            query_obj.query_type = query_obj.detect_query_type()
            return query_obj

    # Fetch from catalog (for URIs or if file doesn't exist)
    return app.client.queries.get(identifier, catalog_graph=catalog_graph)


QUERY_FILTER_HELP_TEXT = (
    "Filter queries based on execution status and time. "
    f"First parameter --filter CHOICE can be one of {QUERY_FILTER_TYPES!s}"
    ". The second parameter is based on CHOICE, e.g. int in case of"
    " slower-than, or a regular expression string."
)


class ReplayStatistics:
    """Capture and calculate statistics of a query replay command run."""

    run_id: str
    query_minimum: int | None
    query_maximum: int | None
    loop_count: int
    loop_durations: dict[str, int]
    query_durations: dict[str, list[int]]
    current_loop_key: str
    total_duration: int = 0
    query_count: int = 0
    error_count: int = 0
    query_average: float
    app: ApplicationContext

    def __init__(self, app: ApplicationContext, label: str | None = None):
        """Initialize instance."""
        self.app = app
        self.label = label
        self.run_id = str(uuid4())
        self.loop_count = 0
        self.query_minimum = None
        self.query_maximum = None
        self.loop_durations = {}
        self.query_durations = {}

    def init_loop(self) -> str:
        """Initialize a new loop and reset the loop counts/values."""
        loop_key = str(uuid4())
        self.current_loop_key = loop_key
        self.loop_durations[loop_key] = 0
        self.query_durations[loop_key] = []
        self.loop_count += 1
        self.app.echo_debug(f"Loop {self.loop_count} started: {loop_key}")
        return loop_key

    def _init_query(self, query_: dict) -> Query:
        """Initialize query from dict."""
        try:
            if "iri" in query_:
                iri = query_["iri"]
                catalog_entry = self.app.client.queries.get(iri)
                if catalog_entry is None:
                    raise CmemcError(f"measure_query - query {iri} is not in catalog.")
                return catalog_entry
            query_string = query_["queryString"]
            return Query(text=query_string)
        except KeyError as error:
            raise CmemcError("measure_query - given input dict has no queryString key.") from error

    def _update_statistic_on_success(self, duration: int) -> None:
        """Update statistics and counters."""
        self.query_durations[self.current_loop_key].append(duration)
        if self.query_minimum is None or duration < self.query_minimum:
            self.query_minimum = duration
        if self.query_maximum is None or duration > self.query_maximum:
            self.query_maximum = duration
        self.total_duration += duration
        self.loop_durations[self.current_loop_key] += duration
        self.query_average = self.total_duration / self.query_count

    def measure_query_duration(self, query_: dict) -> dict:
        """Execute a query and measure the duration."""
        # create the query object
        executed_query = self._init_query(query_)

        # update and return the query object
        if "id" not in query_:
            # create a UUID4, if needed
            query_["id"] = str(uuid4())
        # always re-hash
        query_["queryStringSha1Hash"] = sha1(  # nosec  # noqa: S324
            executed_query.text.encode("utf-8")
        ).hexdigest()
        if "queryString" not in query_:
            # add queryString for catalog queries
            query_["queryString"] = executed_query.text
        if "iri" in query_:
            # use the full IRI in case short one is given
            query_["iri"] = executed_query.url
        if "iri" in query_ and "label" not in query_:
            # extend with label if possible (and needed)
            query_["label"] = executed_query.label
        if "replays" not in query_:
            # create replays list if needed
            query_["replays"] = []

        # init replay object
        this_replay = {
            "runId": self.run_id,
            "loopId": str(self.current_loop_key),
            "replayId": str(uuid4()),
        }
        if self.label is not None:
            this_replay["runLabel"] = self.label

        # execute and measure the query
        try:
            start = round(time() * 1000)
            self.app.client.queries.execute_query(executed_query)
            end = round(time() * 1000)
            duration = end - start

            this_replay["executionStarted"] = str(start)
            this_replay["executionFinished"] = str(end)
            this_replay["executionTime"] = str(duration)

            # update statistics and counters
            self.query_count += 1
            self._update_statistic_on_success(duration)
        except (httpx.HTTPStatusError, httpx.RequestError) as error:
            self.error_count += 1
            this_replay["executionError"] = extract_error_message(error)

        query_["replays"].append(this_replay)
        self.app.echo_debug(
            f"Query {self.query_count + self.error_count} executed: {this_replay['replayId']}"
        )
        return query_

    def create_output(self) -> dict:
        """Create the structure for the output commands."""
        # create dict from object but ignore some internal vars on output
        output = {
            key: value
            for (key, value) in dict(vars(self)).items()
            if key not in ("current_loop_key", "app")
        }
        loop_average = 0
        loop_minimum = None
        loop_maximum = None
        for loop_duration in self.loop_durations.values():
            loop_average += loop_duration
            if loop_minimum is None or loop_duration < loop_minimum:
                loop_minimum = loop_duration
            if loop_maximum is None or loop_duration > loop_maximum:
                loop_maximum = loop_duration
        output["loop_minimum"] = loop_minimum
        output["loop_maximum"] = loop_maximum
        output["loop_average"] = loop_average / len(self.loop_durations)
        return output

    def output_table(self) -> None:
        """Output a table of the statistic values."""
        table = struct_to_table(self.create_output())
        self.app.echo_info_table(table, headers=["Key", "Value"], sort_column=0)

    def output_json(self) -> None:
        """Output json of the statistic values."""
        self.app.echo_info_json(self.create_output())


def get_queries(ctx: click.Context) -> list[dict]:
    """Get queries for object list"""
    app: ApplicationContext = ctx.obj
    status_list = app.client.queries.get_query_status()
    # Convert QueryStatus objects to dicts for filtering
    return [status.model_dump() for status in status_list]


def transform_status_names(ctx: Filter, value: str) -> str:  # noqa: ARG001
    """Transform: status names

    FINISHED_SUCCESS -> finished
    FINISHED_* -> *
    others -> same
    all lowercase
    """
    value = value.replace("FINISHED_", "").lower()
    if value == "success":
        value = "finished"
    return value


query_status_list = ObjectList(
    name="queries",
    get_objects=get_queries,
    filters=[
        DirectValuePropertyFilter(
            name="status",
            description="List only queries which have a certain status.",
            property_key="queryExecutionState",
            transform=transform_status_names,
            fixed_completion=[
                CompletionItem("running", help="List only queries which are currently running."),
                CompletionItem(
                    "finished", help="List only queries which are successfully finished."
                ),
                CompletionItem(
                    "error", help="List only queries which were NOT successfully executed."
                ),
                CompletionItem("cancelled", help="List only queries which were cancelled."),
                CompletionItem("timeout", help="List only queries which ran into a timeout."),
            ],
            fixed_completion_only=True,
        ),
        DirectValuePropertyFilter(
            name="type",
            description="List only queries of a certain query type.",
            property_key="type",
        ),
        DirectValuePropertyFilter(
            name="trace-id",
            description="List only queries which have the specified trace ID.",
            property_key="trace_id",
        ),
        DirectValuePropertyFilter(
            name="user",
            description="List only queries executed by the specified account (URL).",
            property_key="user",
        ),
        DirectListPropertyFilter(
            name="graph",
            description="List only queries which affected a certain graph (URL).",
            property_key="affected_graphs",
        ),
        DirectValuePropertyFilter(
            name="slower-than",
            description="List only queries which are slower than X milliseconds.",
            property_key="execution_time",
            compare=compare_int_greater_than,
            fixed_completion=[
                CompletionItem("5", help="List only queries which are executed slower than 5ms."),
                CompletionItem(
                    "100", help="List only queries which are executed slower than 100ms."
                ),
                CompletionItem(
                    "1000", help="List only queries which are executed slower than 1000ms."
                ),
                CompletionItem(
                    "5000", help="List only queries which are executed slower than 5000ms."
                ),
            ],
        ),
        DirectValuePropertyFilter(
            name="regex",
            description="List only queries which query text matches a regular expression.",
            property_key="query_string",
            compare=compare_regex,
            fixed_completion=[
                CompletionItem(
                    r"http://schema.org",
                    help="List only queries which somehow use the schema.org namespace.",
                ),
                CompletionItem(
                    r"http://www.w3.org/2000/01/rdf-schema#",
                    help="List only queries which somehow use the RDF schema namespace.",
                ),
                CompletionItem(
                    r"?value",
                    help="List only queries which are using the ?value projection variable.",
                ),
                CompletionItem(
                    "^CREATE SILENT GRAPH",
                    help="List only queries which start with CREATE SILENT GRAPH.",
                ),
            ],
        ),
    ],
)


def get_catalog_graph_label(catalog_graph: str) -> str:
    """Get the label of a catalog graph from the graph list, falling back to the IRI."""
    graphs = get_graphs_as_dict()
    graph = graphs.get(catalog_graph)
    return graph["label"]["title"] if graph else catalog_graph


def get_catalog_queries(ctx: click.Context) -> list[dict]:
    """Get queries from catalog for object list filtering.

    Converts Query objects to dictionaries with standardized keys.
    Requires 'catalog_graph' parameter in context.
    """
    app: ApplicationContext = ctx.obj
    catalog_graph = ctx.params.get("catalog_graph", "https://ns.eccenca.com/data/queries/")

    # Fetch queries from the specified catalog graph
    app.client.queries.fetch_data(catalog_graph=catalog_graph)
    queries_list = app.client.queries.values()

    result = []
    for _query in queries_list:
        query_dict = {
            "id": _query.short_url,
            "url": _query.url,
            "short_url": _query.short_url,
            "type": _query.query_type.value,
            "label": _query.label,
            "text": _query.text,
            "placeholders": list(_query.get_placeholder_keys()),
        }
        result.append(query_dict)

    return result


query_catalog_list = ObjectList(
    name="catalog queries",
    get_objects=get_catalog_queries,
    filters=[
        DirectValuePropertyFilter(
            name="id",
            description="Filter queries by ID/URI pattern (regex match on short_url).",
            property_key="short_url",
            compare=compare_regex,
            completion_method="values",
        ),
        DirectValuePropertyFilter(
            name="type",
            description="Filter queries by type (e.g., SELECT, CONSTRUCT, UPDATE).",
            property_key="type",
            fixed_completion=[
                CompletionItem("SELECT", help="List only SELECT queries."),
                CompletionItem("CONSTRUCT", help="List only CONSTRUCT queries."),
                CompletionItem("ASK", help="List only ASK queries."),
                CompletionItem("DESCRIBE", help="List only DESCRIBE queries."),
                CompletionItem("UPDATE", help="List only UPDATE queries."),
            ],
        ),
        DirectListPropertyFilter(
            name="placeholder",
            description="Filter queries that contain a specific placeholder key.",
            property_key="placeholders",
        ),
        MultiFieldPropertyFilter(
            name="regex",
            description="Filter queries by regex pattern (searches in text and label).",
            property_keys=["text", "label"],
            compare=compare_regex,
            match_mode="any",
        ),
        DirectMultiValuePropertyFilter(
            name="ids",
            description="Internal filter for multiple query IRIs.",
            property_key="url",
        ),
    ],
)


def _output_query_status_details(app: ApplicationContext, status_dict: dict) -> None:
    """Output key/value table as well as query string of a query.

    Args:
    ----
        app: application context
        status_dict: The dict from the query status list.

    """
    table = []
    for key, value in status_dict.items():
        if key != "query_string":
            row = [key, str(value)]
            table.append(row)
    app.echo_info_table(table, headers=["Key", "Value"], sort_column=0)
    app.echo_info("")
    app.echo_info_sparql(status_dict["query_string"])


@click.command(cls=CmemcCommand, name="list")
@click.option(
    "--catalog-graph",
    default="https://ns.eccenca.com/data/queries/",
    show_default=True,
    shell_complete=completion.catalog_graph_uris,
    help="The used query catalog graph.",
)
@click.option(
    "--id-only",
    is_flag=True,
    help="Lists only query identifier and no labels or other metadata. "
    "This is useful for piping the ids into other cmemc commands.",
)
@click.option(
    "--filter",
    "filter_",
    type=(str, str),
    multiple=True,
    help=query_catalog_list.get_filter_help_text(),
    shell_complete=query_catalog_list.complete_values,
)
@click.pass_context
def list_command(
    ctx: click.Context, catalog_graph: str, id_only: bool, filter_: tuple[tuple[str, str]]
) -> None:
    """List available queries from a query catalog.

    Outputs a list of query URIs which can be used as reference for
    the query execute command.

    You can filter queries based on ID, type, placeholder, or regex pattern.
    """
    app: ApplicationContext = ctx.obj

    if catalog_graph not in get_graphs_as_dict():
        raise CmemcError(UNKNOWN_GRAPH_ERROR.format(catalog_graph))

    # Apply filters to get query dictionaries
    query_dicts = query_catalog_list.apply_filters(ctx=ctx, filter_=filter_)

    if id_only:
        # Sort and output only IDs
        # Use short_url if available, otherwise use full url
        for query_dict in sorted(query_dicts, key=lambda k: (k["short_url"] or k["url"]).lower()):
            app.echo_info(query_dict["short_url"] or query_dict["url"])
    else:
        # Create a dict for QueryLink processor - need to fetch all queries for link processing
        app.client.queries.fetch_data(catalog_graph=catalog_graph)
        all_queries = app.client.queries.values()
        queries_dict = {query.url: query for query in all_queries}

        table = []
        for query_dict in query_dicts:
            row = [
                query_dict["short_url"] or query_dict["url"],
                query_dict["type"],
                ",".join(query_dict["placeholders"]),
                query_dict["url"],  # Use URL instead of label for processing
            ]
            table.append(row)

        filtered = len(filter_) > 0
        base_url = str(app.client.config.url_base)
        catalog_label = get_catalog_graph_label(catalog_graph)
        catalog_url = f"{base_url}/query?graph={quote(catalog_graph)}"
        instance = f"[link={catalog_url}]{catalog_label}[/link]"
        app.echo_info_table(
            table,
            headers=["Query URI", "Type", "Placeholder", "Label"],
            sort_column=3,
            cell_processing={
                3: QueryLink(catalog_graph=catalog_graph, queries=queries_dict, base_url=base_url)
            },
            caption=build_caption(
                len(table), "query", instance=instance, filtered=filtered, plural="queries"
            ),
            empty_table_message="No queries found for these filters."
            if filtered
            else f"There are no query available in the selected catalog ({catalog_graph}).",
        )


@click.command(cls=CmemcCommand, name="execute")
@click.argument(
    "QUERIES", nargs=-1, required=True, shell_complete=completion.remote_queries_and_sparql_files
)
@click.option(
    "--catalog-graph",
    default="https://ns.eccenca.com/data/queries/",
    show_default=True,
    shell_complete=completion.catalog_graph_uris,
    help="The used query catalog graph.",
)
@click.option(
    "--accept",
    default="default",
    show_default=True,
    shell_complete=completion.sparql_accept_types,
    help="Accept header for the HTTP request(s). Setting this to 'default' "
    "means that cmemc uses an appropriate output for terminals.",
)
@click.option(
    "--no-imports",
    is_flag=True,
    help="Graphs which include other graphs (using owl:imports) will be "
    "queried as merged overall-graph. This flag disables this "
    "default behaviour. The flag has no effect on update queries.",
)
@click.option(
    "--base64",
    is_flag=True,
    help="Enables base64 encoding of the query parameter for the "
    "SPARQL requests (the response is not touched). "
    "This can be useful in case there is an aggressive firewall between "
    "cmemc and Corporate Memory.",
)
@click.option(
    "--parameter",
    "-p",
    type=(str, str),
    shell_complete=placeholder,
    multiple=True,
    help="In case of a parameterized query (placeholders with the '{{key}}' "
    "syntax), this option fills all placeholder with a given value "
    "before the query is executed."
    "Pairs of placeholder/value need to be given as a tuple 'KEY VALUE'. "
    "A key can be used only once.",
)
@click.option(
    "--limit",
    type=int,
    help="Override or set the LIMIT in the executed SELECT query. Note that "
    "this option will never give you more results than the LIMIT given "
    "in the query itself.",
)
@click.option("--offset", type=int, help="Override or set the OFFSET in the executed SELECT query.")
@click.option(
    "--distinct", is_flag=True, help="Override the SELECT query by make the result set DISTINCT."
)
@click.option(
    "--timeout", type=int, help="Set max execution time for query evaluation (in milliseconds)."
)
@click.pass_obj
def execute_command(  # noqa: PLR0913
    app: ApplicationContext,
    queries: tuple[str, ...],
    catalog_graph: str,
    accept: str,
    no_imports: bool,
    base64: bool,
    parameter: tuple[tuple[str, str], ...],
    limit: int,
    offset: int,
    distinct: bool,
    timeout: int,
) -> None:
    """Execute queries which are loaded from files or a query catalog.

    Queries are identified either by a file path, a URI from the query
    catalog, or a shortened URI (qname, using a default namespace).

    If multiple queries are executed one after the other, the first failing
    query stops the whole execution chain.

    Limitations: All optional parameters (e.g. accept, base64, ...) are
    provided for ALL queries in an execution chain. If you need different
    parameters for each query in a chain, run cmemc multiple times and use
    the logical operators && and || of your shell instead.
    """
    placeholder = {}
    for key, value in parameter:
        if key in placeholder:
            raise click.UsageError(
                f"Parameter can be given only once, Value for '{key}' was given twice."
            )
        placeholder[key] = value
    app.echo_debug("Parameter: " + str(placeholder))
    for file_or_uri in queries:
        app.echo_debug(f"Start of execution: {file_or_uri} with placeholder {placeholder}")
        executed_query = get_query_from_file_or_catalog(app, file_or_uri, catalog_graph)
        if executed_query is None:
            raise click.UsageError(
                f"{file_or_uri} is neither a (readable) file nor "
                f"a query URI in the catalog graph {catalog_graph}"
            )

        # Check for placeholders in the query
        required_placeholders = executed_query.get_placeholder_keys()

        # Fill placeholders if needed
        if required_placeholders:
            # If query has placeholders, we must fill them (even if placeholder dict is empty)
            query_text = executed_query.fill_placeholders(placeholder)
            executed_query = Query(
                text=query_text,
                url=executed_query.url,
                label=executed_query.label,
                query_type=executed_query.query_type,
                description=executed_query.description,
                origin=executed_query.origin,
            )
            # Re-detect query type after filling placeholders
            # (important for file queries with placeholders that couldn't be detected before)
            executed_query.query_type = executed_query.detect_query_type()

        app.echo_debug(
            f"Execute ({executed_query.query_type}): {executed_query.label} < {executed_query.url}"
        )
        if accept == "default":
            submitted_accept = executed_query.get_default_accept_header()
            app.echo_debug(f"Accept header set to default value: '{submitted_accept}'")
        else:
            submitted_accept = accept

        results = app.client.queries.execute_query(
            executed_query,
            accept=submitted_accept,
            owl_imports_resolution=not no_imports,
            base64_encoded=base64,
            distinct=distinct,
            limit=limit,
            offset=offset,
            timeout=timeout,
        )
        if accept == "default" and submitted_accept == "text/csv":
            csv_reader = csv.reader(results.splitlines(), delimiter=",", quotechar='"')
            table = []
            headers = []
            for index, row in enumerate(csv_reader):
                if index == 0:
                    headers = row
                else:
                    table.append(row)
            app.echo_info_table(
                table,
                headers=headers,
                empty_table_message="No results for this query.",
                caption=f"Query results: {executed_query.label}",
            )
        else:
            app.echo_result(results)


@click.command(cls=CmemcCommand, name="open")
@click.argument(
    "QUERIES", nargs=-1, required=True, shell_complete=completion.remote_queries_and_sparql_files
)
@click.option(
    "--catalog-graph",
    default="https://ns.eccenca.com/data/queries/",
    show_default=True,
    shell_complete=completion.catalog_graph_uris,
    help="The used query catalog graph.",
)
@click.pass_obj
def open_command(app: ApplicationContext, queries: tuple[str, ...], catalog_graph: str) -> None:
    """Open queries in the editor of a query catalog in your browser.

    With this command, you can open (remote) queries from the query catalog in
    the query editor in your browser (e.g. in order to change them).
    You can also load local query files into the query editor, in order to
    import them into the query catalog.

    The command accepts multiple query URIs or files which results in
    opening multiple browser tabs.
    """
    for file_or_uri in queries:
        opened_query = get_query_from_file_or_catalog(app, file_or_uri, catalog_graph)
        if opened_query is None:
            raise click.UsageError(
                f"{file_or_uri} is neither a (readable) file nor "
                f"a query URI in the catalog graph {catalog_graph}"
            )
        # Get base URL from client config
        base_url = str(app.client.config.url_base)
        open_query_uri = opened_query.get_editor_url(base_url=base_url, graph=catalog_graph)
        app.echo_debug(f"Open {file_or_uri}: {open_query_uri}")
        click.launch(open_query_uri)


@click.command(cls=CmemcCommand, name="status")
@click.option(
    "--id-only",
    is_flag=True,
    help="Lists only query identifier and no labels or other metadata. "
    "This is useful for piping the ids into other cmemc commands.",
)
@click.option("--raw", is_flag=True, help="Outputs raw JSON response of the query status API.")
@click.option(
    "--filter",
    "filter_",
    type=(str, str),
    multiple=True,
    help=query_status_list.get_filter_help_text(),
    shell_complete=query_status_list.complete_values,
)
@click.argument("query_id", required=False, type=click.STRING)
@click.pass_context
def status_command(
    ctx: click.Context,
    id_only: bool,
    raw: bool,
    filter_: tuple[tuple[str, str]],
    query_id: str,
) -> None:
    """Get status information of executed and running queries.

    With this command, you can access the latest executed SPARQL queries
    on the Explore backend (DataPlatform).
    These queries are identified by UUIDs and listed
    ordered by starting timestamp.

    You can filter queries based on status and runtime in order to investigate
    slow queries. In addition to that, you can get the details of a specific
    query by using the ID as a parameter.
    """
    width, height = get_terminal_size((120, 20))
    max_query_string_width = width - 46 - 1
    app: ApplicationContext = ctx.obj
    app.echo_debug(f"Terminal size = {width} x {height}")

    if query_id:
        query_status_list.add_filter(
            DirectValuePropertyFilter(name="id", property_key="id", description="id")
        )
        queries = query_status_list.apply_filters(ctx=ctx, filter_=[("id", query_id)])
        query_status_list.remove_filter("id")
    else:
        queries = query_status_list.apply_filters(ctx=ctx, filter_=filter_)

    if query_id and len(queries) == 0:
        raise click.UsageError(f"Query with ID '{query_id}' does not exist (anymore).")

    if raw:
        app.echo_info_json(queries)
        return

    if id_only:
        for _ in queries:
            app.echo_info(_["id"])
        return

    if query_id:
        _output_query_status_details(app, queries[0])
        return
    table = []
    for _ in queries:
        query_id = _["id"]
        query_execution_time = str(_.get("execution_time", ""))
        query_string = " ".join(_["query_string"].splitlines())
        if len(query_string) > max_query_string_width:
            query_string = query_string[0:max_query_string_width] + "…"
        row = [query_id, query_execution_time, query_string]
        table.append(row)
    filtered = len(filter_) > 0
    app.echo_info_table(
        table,
        headers=["Query ID", "Time", "Query String"],
        caption=build_caption(len(table), "query", filtered=filtered, plural="queries"),
        empty_table_message="No queries found for these filters."
        if filtered
        else "No queries found.",
    )


@click.command(cls=CmemcCommand, name="replay")
@click.argument(
    "REPLAY_FILE",
    required=True,
    shell_complete=completion.replay_files,
    type=ClickSmartPath(exists=True, allow_dash=False, readable=True, dir_okay=False),
)
@click.option("--raw", is_flag=True, help="Output the execution statistic as raw JSON.")
@click.option(
    "--loops",
    required=False,
    default=1,
    show_default=True,
    type=int,
    help="Number of loops to run the replay file.",
)
@click.option(
    "--wait",
    required=False,
    default=0,
    show_default=True,
    type=int,
    help="Number of seconds to wait between query executions.",
)
@click.option(
    "--output-file",
    required=False,
    shell_complete=completion.replay_files,
    help="Save the optional output to this file. Input and output of the "
    "command can be the same file. The output is written at the end "
    "of a successful command execution. The output can be stdout "
    "('-') - in this case, the execution statistic output is "
    "oppressed.",
    type=ClickSmartPath(exists=False, allow_dash=True, writable=True, dir_okay=False),
)
@click.option("--run-label", type=click.STRING, help="Optional label of this replay run.")
@click.pass_obj
def replay_command(  # noqa: PLR0913
    app: ApplicationContext,
    replay_file: str,
    raw: bool,
    loops: int,
    wait: int,
    output_file: str,
    run_label: str,
) -> None:
    """Re-execute queries from a replay file.

    This command reads a REPLAY_FILE and re-executes the logged queries.
    A REPLAY_FILE is a JSON document which is an array of JSON objects with
    at least a key `queryString` holding the query text OR a key `iri`
    holding the IRI of the query in the query catalog.
    It can be created with the `query status` command.

    Example: query status --raw > replay.json

    The output of this command shows basic query execution statistics.

    The queries are executed one after another in the order given in the
    input REPLAY_FILE. Query placeholders / parameters are ignored. If a
    query results in an error, the duration is not counted.

    The optional output file is the same JSON document which is used as input,
    but each query object is annotated with an additional `replays` object,
    which is an array of JSON objects which hold values for the
    replay|loop|run IDs, start and end time as well as duration and
    other data.
    """
    if loops <= 0:
        raise click.UsageError("Please set a positive loops integer value (>=1).")
    try:
        with Path(replay_file).open(encoding="utf8") as _:
            input_queries = load(_)
    except JSONDecodeError as error:
        raise CmemcError(f"File {replay_file} is not a valid JSON document.") from error
    if len(input_queries) == 0:
        raise CmemcError(f"File {replay_file} contains no queries.")
    app.echo_debug(f"File {replay_file} contains {len(input_queries)} queries.")

    statistic = ReplayStatistics(app=app, label=run_label)
    for _loop in range(loops):
        statistic.init_loop()
        for _ in input_queries:
            statistic.measure_query_duration(_)
            if wait > 0:
                sleep(wait)

    if output_file:
        if output_file == "-":
            app.echo_info_json(input_queries)
            return
        with Path(output_file).open(mode="w", encoding="utf-8") as output:
            json.dump(input_queries, output, ensure_ascii=False, indent=2)

    if raw:
        statistic.output_json()
    else:
        statistic.output_table()


@click.command(cls=CmemcCommand, name="cancel")
@click.argument("query_id", required=True, type=click.STRING)
@click.pass_context
def cancel_command(ctx: click.Context, query_id: str) -> None:
    """Cancel a running query.

    With this command, you can cancel a running query.
    Depending on the backend triple store, this will result in a
    broken result stream (stardog, neptune and virtuoso) or a valid
    result stream with incomplete results (graphdb)
    """
    app = ctx.obj
    app.echo_info(f"Cancel query {query_id} ... ", nl=False)
    query_status_list.add_filter(
        DirectValuePropertyFilter(name="id", property_key="id", description="id")
    )
    queries = query_status_list.apply_filters(
        ctx=ctx, filter_=[("id", query_id), ("status", "running")]
    )
    if not queries:
        app.echo_error("not known or not running (anymore)")
        sys.exit(1)
    app.client.queries.cancel_query(queries[0]["id"])
    app.echo_success("done")


@click.command(cls=CmemcCommand, name="explain")
@click.argument(
    "QUERIES", nargs=-1, required=True, shell_complete=completion.remote_queries_and_sparql_files
)
@click.option(
    "--catalog-graph",
    default="https://ns.eccenca.com/data/queries/",
    show_default=True,
    shell_complete=completion.catalog_graph_uris,
    help="The used query catalog graph.",
)
@click.option(
    "--parameter",
    "-p",
    type=(str, str),
    shell_complete=placeholder,
    multiple=True,
    help="In case of a parameterized query (placeholders with the '{{key}}' "
    "syntax), this option fills all placeholder with a given value "
    "before the query is explained. "
    "Pairs of placeholder/value need to be given as a tuple 'KEY VALUE'. "
    "A key can be used only once.",
)
@click.pass_obj
def explain_command(
    app: ApplicationContext,
    queries: tuple[str, ...],
    catalog_graph: str,
    parameter: tuple[tuple[str, str], ...],
) -> None:
    """Explain queries by showing their logical execution plan.

    Queries are identified either by a file path, a URI from the query
    catalog, or a shortened URI (qname, using a default namespace).

    The explain command shows the query optimization plan, including:
    - Optimization groups and their evaluation order
    - Collection sizes and complexity estimates
    - Unique subject and object counts
    - Estimated number of iterations

    This helps understand query performance and identify optimization opportunities.
    """
    placeholder = {}
    for key, value in parameter:
        if key in placeholder:
            raise click.UsageError(
                f"Parameter can be given only once, value for '{key}' was given twice."
            )
        placeholder[key] = value

    app.echo_debug("Parameter: " + str(placeholder))

    for file_or_uri in queries:
        app.echo_debug(f"Start of explain: {file_or_uri} with placeholder {placeholder}")

        # Get the query from catalog or file
        query_obj = get_query_from_file_or_catalog(app, file_or_uri, catalog_graph)
        if query_obj is None:
            raise click.UsageError(
                f"{file_or_uri} is neither a (readable) file nor "
                f"a query URI in the catalog graph {catalog_graph}"
            )

        app.echo_debug(f"Explain ({query_obj.query_type}): {query_obj.label} < {query_obj.url}")

        # Check for placeholders in the query
        required_placeholders = query_obj.get_placeholder_keys()

        # Fill placeholders if needed
        if required_placeholders:
            # If query has placeholders, we must fill them (even if placeholder dict is empty)
            query_text = query_obj.fill_placeholders(placeholder)
        else:
            query_text = query_obj.text

        # Call the explain API using cmem-client QueryCatalog component
        try:
            logical_plan = app.client.queries.explain_query(query_text)

            if logical_plan.plan:
                # remove umbrella strings
                output = (
                    logical_plan.plan.removeprefix("plan")
                    .strip()
                    .removeprefix('"')
                    .removesuffix('"')
                )
                # output SPARQL highlighted
                app.echo_info_sparql(output)
            else:
                app.echo_warning(f"No plan returned for query: {query_obj.label}")

        except ValueError as error:
            # Placeholder errors
            app.echo_error(f"Failed to explain query {query_obj.label}: {error!s}")
            sys.exit(1)
        except httpx.HTTPStatusError as error:
            app.echo_error(f"Failed to explain query {query_obj.label}: {error.response.text}")
            sys.exit(1)
        except (httpx.RequestError, JSONDecodeError) as error:
            app.echo_error(f"Failed to explain query {query_obj.label}: {error!s}")
            sys.exit(1)


@click.command(cls=CmemcCommand, name="create")
@click.argument(
    "QUERY_FILE",
    required=True,
    type=ClickSmartPath(exists=True, allow_dash=False, readable=True, dir_okay=False),
    shell_complete=completion.sparql_files,
)
@click.option(
    "--catalog-graph",
    default="https://ns.eccenca.com/data/queries/",
    show_default=True,
    shell_complete=completion.catalog_graph_uris,
    help="The used query catalog graph.",
)
@click.option(
    "--id",
    "query_id",
    type=click.STRING,
    help="The local ID for the new query. The full URI will be catalog graph + ID. "
    "If not provided, a UUID is generated.",
)
@click.option(
    "--label",
    type=click.STRING,
    help="Human-readable label for the query. "
    "If not provided but an ID is given, the ID will be used. "
    "If neither label nor ID is given, a default label is used.",
)
@click.option(
    "--description",
    type=click.STRING,
    help="Description for the query.",
)
@click.pass_obj
def create_command(  # noqa: PLR0913
    app: ApplicationContext,
    query_file: str,
    query_id: str | None,
    catalog_graph: str,
    label: str | None,
    description: str | None,
) -> None:
    """Create a new query in a query catalog.

    Creates a new query in a query catalog from a SPARQL query file.

    The query type (SELECT, CONSTRUCT, UPDATE, etc.) is automatically detected
    from the query text.

    Example: cmemc query create my-query.sparql
    Example: cmemc query create my-query.sparql --id :customName
    """
    # Read query text from file
    with Path(query_file).open(encoding="utf-8") as f:
        query_text = f.read()

    # Use UUID as local name if not provided
    local_name = query_id or str(uuid4())
    query_uri = f"{catalog_graph}{local_name}"

    # Use id if label is not provided, and a default label if both is not available
    now = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d %H-%M")
    default_label = f"Created with cmemc ({now})"
    label = label or query_id or default_label

    # Create Query object
    query_obj = Query(
        text=query_text,
        url=query_uri,
        label=label or default_label,
        description=description,
        origin=QueryOrigin.REMOTE,
    )

    # Auto-detect query type
    query_obj.query_type = query_obj.detect_query_type()

    app.echo_info(f"Creating query :{local_name} ... ", nl=False)

    try:
        # Ensure repository is loaded with correct catalog graph
        app.client.queries.fetch_data(catalog_graph=catalog_graph)

        # Create the query
        app.client.queries.create_item(
            query_obj, configuration=QueriesCreateConfig(catalog_graph=catalog_graph)
        )
        app.echo_success("created")

    except httpx.HTTPStatusError as error:
        app.echo_error(f"failed - {extract_error_message(error)}")
        sys.exit(1)
    except httpx.RequestError as error:
        app.echo_error(f"failed - {error!s}")
        sys.exit(1)


@click.command(cls=CmemcCommand, name="update")
@click.argument("QUERY_ID", required=True, shell_complete=completion.remote_queries)
@click.option(
    "--catalog-graph",
    default="https://ns.eccenca.com/data/queries/",
    show_default=True,
    shell_complete=completion.catalog_graph_uris,
    help="The used query catalog graph.",
)
@click.option(
    "--query-file",
    type=ClickSmartPath(exists=True, allow_dash=False, readable=True, dir_okay=False),
    shell_complete=completion.sparql_files,
    help="Update the query text from a SPARQL file.",
)
@click.option(
    "--label",
    type=click.STRING,
    help="Update the query label.",
)
@click.option(
    "--description",
    type=click.STRING,
    help="Update the query description.",
)
@click.pass_obj
def update_command(  # noqa: PLR0913
    app: ApplicationContext,
    query_id: str,
    catalog_graph: str,
    query_file: str | None,
    label: str | None,
    description: str | None,
) -> None:
    """Update an existing query in a query catalog.

    Updates an existing query in the query catalog. You can update the query text,
    label, and/or description. At least one update option must be provided.

    The QUERY_ID can be either a short URI (e.g., :myQuery) or a full URI.
    """
    # Validation: require at least one update option
    if not query_file and not label and not description:
        raise click.UsageError(
            "At least one update option is required: --query-file, --label, or --description"
        )

    # Ensure repository is loaded with correct catalog graph
    app.client.queries.fetch_data(catalog_graph=catalog_graph)

    # Get existing query
    existing_query = app.client.queries.get(query_id, catalog_graph=catalog_graph)
    if existing_query is None:
        raise click.UsageError(f"Query '{query_id}' not found in catalog graph {catalog_graph}")

    app.echo_info(f"Updating query {query_id} ... ", nl=False)

    try:
        # Update query text if provided
        if query_file:
            with Path(query_file).open(encoding="utf-8") as f:
                query_text = f.read()
            existing_query.text = query_text
            # Re-detect query type after updating text
            existing_query.query_type = existing_query.detect_query_type()

        # Update label if provided
        if label:
            existing_query.label = label

        # Update description if provided
        if description:
            existing_query.description = description

        # Perform the update
        app.client.queries.update_item(
            existing_query, configuration=QueriesUpdateConfig(catalog_graph=catalog_graph)
        )
        app.echo_success("updated")

    except httpx.HTTPStatusError as error:
        app.echo_error(f"failed - {extract_error_message(error)}")
        sys.exit(1)
    except httpx.RequestError as error:
        app.echo_error(f"failed - {error!s}")
        sys.exit(1)


@click.command(cls=CmemcCommand, name="delete")
@click.argument("QUERY_IDS", nargs=-1, shell_complete=completion.remote_queries)
@click.option(
    "--catalog-graph",
    default="https://ns.eccenca.com/data/queries/",
    show_default=True,
    shell_complete=completion.catalog_graph_uris,
    help="The used query catalog graph.",
)
@click.option(
    "--filter",
    "filter_",
    type=(str, str),
    multiple=True,
    help=query_catalog_list.get_filter_help_text(),
    shell_complete=query_catalog_list.complete_values,
)
@click.option(
    "--all",
    "all_",
    is_flag=True,
    help="Delete all queries from the catalog. Use with caution!",
)
@click.pass_context
def delete_command(
    ctx: click.Context,
    query_ids: tuple[str, ...],
    catalog_graph: str,
    filter_: tuple[tuple[str, str], ...],
    all_: bool,
) -> None:
    """Delete queries from a query catalog.

    Permanently removes one or more queries from the query catalog.
    Queries are identified by their URI or shortened URI (qname).

    You can delete specific queries by providing their IDs, or use
    --filter to select queries based on criteria, or use --all to
    delete all queries in the catalog.

    Warning: Deleted queries cannot be recovered. Use with caution!
    """
    app: ApplicationContext = ctx.obj

    # Validation: require at least one selection method
    if not query_ids and not filter_ and not all_:
        raise click.UsageError(
            "Either provide at least one query ID, use --filter, or use the --all flag."
        )

    if query_ids and (all_ or filter_):
        raise click.UsageError("Either specify query IDs OR use --filter or the --all option.")

    # Expand short URIs (e.g. :queryName) to full URLs using the catalog graph namespace
    resolved_ids = tuple(
        f"{catalog_graph}{id_[1:]}" if id_.startswith(":") else id_ for id_ in query_ids
    )

    # Get queries to delete using the common pattern
    queries_to_delete = get_objects_to_delete(
        ctx=ctx,
        ids=resolved_ids,
        all_=all_,
        filter_=filter_,
        object_list=query_catalog_list,
        get_all_objects=lambda: query_catalog_list.apply_filters(ctx=ctx, filter_=[]),
        extract_id=lambda x: x["url"],
        error_message_template=f"Query '{{}}' not found in catalog graph {catalog_graph}",
    )

    # Ensure repository is loaded with correct catalog graph
    app.client.queries.fetch_data(catalog_graph=catalog_graph)

    # Avoid double removal and sort by short_url or url
    queries_to_delete = sorted(
        {q["url"]: q for q in queries_to_delete}.values(),
        key=lambda q: (q["short_url"] or q["url"]).lower(),
    )
    count = len(queries_to_delete)

    # Delete each query
    for current, query_dict in enumerate(queries_to_delete, start=1):
        current_string = str(current).zfill(len(str(count)))
        query_id = query_dict["short_url"] or query_dict["url"]
        query_url = query_dict["url"]
        app.echo_info(f"Delete query {current_string}/{count}: {query_id} ... ", nl=False)
        try:
            app.client.queries.delete_item(
                query_url, configuration=QueriesDeleteConfig(catalog_graph=catalog_graph)
            )
            app.echo_success("deleted")
        except (httpx.HTTPStatusError, httpx.RequestError) as error:
            app.echo_error(f"failed - {error}")
            sys.exit(1)


@click.group(cls=CmemcGroup)
def query() -> CmemcGroup:  # type: ignore[empty-body]
    """List, execute, get status or open SPARQL queries.

    Queries are identified either by a file path, a URI from the query
    catalog or a shortened URI (qname, using a default namespace).

    One or more queries can be executed one after the other with the
    execute command. With open command you can jump to the query editor in your
    browser.

    Queries can use a mustache like syntax to specify placeholder for
    parameter values (e.g. {{resourceUri}}). These parameter values need to
    be given as well, before the query can be executed (use the -p option).

    Note: In order to get a list of queries from the query catalog, execute
    the `query list` command or use tab-completion.
    """


query.add_command(execute_command)
query.add_command(list_command)
query.add_command(open_command)
query.add_command(status_command)
query.add_command(replay_command)
query.add_command(cancel_command)
query.add_command(explain_command)
query.add_command(create_command)
query.add_command(update_command)
query.add_command(delete_command)
