#!/usr/bin/env python3
# -*- coding:utf-8 -*-

from datetime import datetime, timedelta, timezone
from enum import Enum
from pathlib import Path
from typing import (
    Annotated,
    Any,
    Callable,
    Dict,
    List,
    Optional,
    Tuple,
    TypeVar,
    Union,
    overload,
)

import pysmiles
import typer
from pydantic import BaseModel
from rich.columns import Columns
from rich.console import Group
from rich.live import Live
from rich.table import Column, Table
from rich.text import Text
from typer import Argument, BadParameter, Option, Typer

from pendingai.api.models import ApiResourceList
from pendingai.cli.console import Console
from pendingai.cli.context import PendingAiContext
from pendingai.services.search.search_databases import SearchDatabase
from pendingai.services.search.searches import Search, SearchResult

SEARCH_TARGET_LIMIT: int = 1000
"""
Maximum number of target SMILES allowed for search submission.
"""

search_app: Typer = Typer(
    name="search",
    help="""
    Leverage a trillion-scale searchable chemical space to find novel
    compounds and analogs. Pending AI's Search service enables you to rapidly
    search databases for similar structures across large batch-based chemical
    screenings.

    [bold yellow]Features:[/bold yellow]
    • Search a trillion-scale chemical space for novel compounds and analogs.
    • Submit search tasks with a list of target SMILES and optional databases.
    • Check the status of search tasks in real-time and retrieve results when complete.
    """,
    short_help="Search a trillion-scale chemical space for novel compounds and analogs.",
    no_args_is_help=True,
)

console: Console = Console(highlight=False)


# region Parameter Callbacks


@overload
def _prepare_smiles_list(smiles_list: None) -> None: ...
@overload
def _prepare_smiles_list(smiles_list: List[str]) -> List[str]: ...
def _prepare_smiles_list(smiles_list: Optional[List[str]]) -> Optional[List[str]]:
    """
    Process and prepare the contents of a command-line list of SMILES structures given
    as input for a search submission. This includes stripping whitespace, validating the
    SMILES format, and deduplicating the list.

    Requirements:
        - At least one valid SMILES structure must be provided.
        - The total number of SMILES structures must not exceed the defined limit.
        - All SMILES structures must be valid according to the `pysmiles` parser.
        - Empty or whitespace-only entries are ignored and do not count in the limit.

    Args:
        smiles_list (List[str], optional): An optional list of SMILES strings provided
            as command-line input. The argument is aliased as `SMILES` in the CLI.

    Raises:
        typer.BadParameter: If any individual SMILES string is invalid according to the
            `pysmiles` parser, including the position in the list and the specific
            parsing error, or if the total number of valid SMILES strings exceeds the
            defined limit, including the count and the limit in the error message.

    Returns:
        List[str]: A processed list of valid, unique SMILES strings ready for search
            submission, or `None` if the input was `None`.
    """
    if smiles_list:
        smiles: List[str] = []

        for i, smi in enumerate(smiles_list, start=1):
            smi = smi.strip()
            if smi:
                try:
                    pysmiles.read_smiles(smi)
                except Exception as e:
                    raise typer.BadParameter(
                        f"Invalid SMILES at position {i}: '{smi}' -> {e}.",
                        param_hint="SMILES",
                    )
                smiles.append(smi)

            if len(smiles) > SEARCH_TARGET_LIMIT:
                raise typer.BadParameter(
                    f"Too many SMILES provided: {len(smiles)} exceeds the limit.",
                    param_hint="SMILES",
                )

        if len(smiles) == 0:
            raise typer.BadParameter(
                "At least one valid SMILES structure is required.",
                param_hint="SMILES",
            )

        return list(set(smiles))
    return smiles_list


@overload
def _prepare_smiles_file(smiles_file: None) -> None: ...
@overload
def _prepare_smiles_file(smiles_file: Path) -> List[str]: ...
def _prepare_smiles_file(smiles_file: Optional[Path]) -> Optional[List[str]]:
    """
    Process and prepare the contents of a file containing SMILES structures given per
    line for a search submission. This includes stripping whitespace, validating the
    SMILES format, and deduplicating the list.

    Requirements:
        - At least one valid SMILES structure must be provided.
        - The total number of SMILES structures must not exceed the defined limit.
        - All SMILES structures must be valid according to the `pysmiles` parser.
        - Empty or whitespace-only entries are ignored and do not count in the limit.

    Args:
        smiles_file (Path, optional): An optional path to a file with SMILES strings
            provided per line. The option is aliased as `--file/-f` in the CLI.

    Raises:
        typer.BadParameter: If any individual SMILES string is invalid according to the
            `pysmiles` parser, including the position in the list and the specific
            parsing error, or if the total number of valid SMILES strings exceeds the
            defined limit, including the count and the limit in the error message.

    Returns:
        List[str]: A processed list of valid, unique SMILES strings ready for search
            submission, or `None` if the input was `None`.
    """
    if smiles_file:
        smiles: List[str] = []
        for i, smi in enumerate(smiles_file.read_text().splitlines(), start=1):
            smi = smi.strip()
            if smi:
                try:
                    pysmiles.read_smiles(smi)
                except Exception as e:
                    raise typer.BadParameter(
                        f"Invalid SMILES at position {i}: '{smi}' -> {e}.",
                        param_hint="--file",
                    )
                smiles.append(smi)

            if len(smiles) > SEARCH_TARGET_LIMIT:
                raise typer.BadParameter(
                    f"Too many SMILES provided: {len(smiles)} exceeds the limit.",
                    param_hint="--file",
                )

        if len(smiles) == 0:
            raise typer.BadParameter(
                "At least one valid SMILES structure is required.",
                param_hint="--file",
            )

        return list(set(smiles))
    return None


# Callbacks for the SMILES list and file parameters need only perform the validation step
# done by the main command function as defined above; then return the original input as
# typer requires the same data type returned and the logic can be re-run accordingly.


def smiles_list_callback(smiles_list: Optional[List[str]]) -> Optional[List[str]]:
    if smiles_list:
        _prepare_smiles_list(smiles_list)
    return smiles_list


def smiles_file_callback(smiles_file: Optional[Path]) -> Optional[Path]:
    if smiles_file:
        _prepare_smiles_file(smiles_file)
    return smiles_file


# endregion

# region Parameters


# TODO: Move into a shared module.


class Format(Enum):
    """
    Allowed output formats for command results.
    """

    DEFAULT = "default"
    JSON = "json"


OutputFormat = Annotated[
    Format,
    Option(
        "--format",
        help="Output format of the command result.",
        show_default=True,
        show_choices=True,
    ),
]
Force = Annotated[
    bool,
    Option(
        "--force",
        help="Force command execution and skip any confirmation prompts.",
    ),
]
OutputFile = Annotated[
    Optional[Path],
    Option(
        "--output",
        "-o",
        help="An optional file path to save the search results. If not provided, "
        + "results will be printed to the console.",
        writable=True,
        file_okay=True,
        dir_okay=False,
        resolve_path=True,
    ),
]
FollowStatus = Annotated[
    bool,
    Option(
        "--follow",
        help="Continuously follow the status of the search task until completion, "
        + "displaying updates in real-time.",
    ),
]
StartingAfter = Annotated[
    Optional[str],
    Option(
        "--starting-after",
        metavar="ID",
        help="An ID to point to paginated results that appear on the page after.",
    ),
]
EndingBefore = Annotated[
    Optional[str],
    Option(
        "--ending-before",
        metavar="ID",
        help="An ID to point to paginated results that appear on the page before.",
    ),
]
Limit = Annotated[
    int,
    Option(
        "--limit",
        "-l",
        help="An upper limit on the returned results.",
        show_default=True,
        min=1,
        max=100,
    ),
]

SmilesList = Annotated[
    Optional[List[str]],
    Argument(
        callback=smiles_list_callback,
        help="A set of SMILES structures to search against.",
    ),
]
SmilesFile = Annotated[
    Optional[Path],
    Option(
        "--file",
        "-f",
        callback=smiles_file_callback,
        help="Path to a file containing line-delimited SMILES structures.",
        exists=True,
        readable=True,
        file_okay=True,
        dir_okay=False,
    ),
]
Databases = Annotated[
    Optional[List[str]],
    Option(
        "--databases",
        "-d",
        help="Optional list of database names to search against.",
    ),
]
SearchName = Annotated[
    Optional[str],
    Option(
        "--name",
        help="An optional name for the search task.",
    ),
]
SearchDescription = Annotated[
    Optional[str],
    Option(
        "--description",
        help="An optional description for the search task.",
    ),
]
SearchIdList = Annotated[
    Optional[List[str]],
    Argument(
        metavar="ID",
        help="A list of search IDs used to delete search tasks.",
    ),
]
SearchIdFile = Annotated[
    Optional[Path],
    Option(
        "--file",
        "-f",
        help="Path to a file containing line-delimited search IDs to delete.",
        exists=True,
        readable=True,
        file_okay=True,
        dir_okay=False,
    ),
]

SearchId = Annotated[
    str,
    Argument(
        metavar="ID",
        help="A search ID used to retrieve the status of a search task.",
    ),
]


# endregion

# region Renderables


class SuccessMessage(Table):
    def __init__(self, **row_entries: str):
        super().__init__(
            show_header=False,
            padding=(0, 2),
            title=":white_check_mark: Submitted Successfully",
            title_justify="left",
            title_style="bold green",
            box=None,
        )
        self.add_column("Key", style="bold yellow", justify="left")
        self.add_column("Value", style="white")
        self.add_row("", "")
        for key, value in row_entries.items():
            self.add_row(f"{key}:", value)


# endregion


def format_timedelta(delta: timedelta) -> str:
    """
    Converts a timedelta into a human-readable string (e.g., '3 hours', '2 days').
    """
    seconds = int(delta.total_seconds())
    intervals = (
        ("day", 86400),  # 60 * 60 * 24
        ("hour", 3600),  # 60 * 60
        ("minute", 60),
        ("second", 1),
    )

    for name, count in intervals:
        value = seconds // count
        if value >= 1:
            suffix = "s" if value > 1 else ""
            return f"{value} {name}{suffix}"

    return "just now"


def format_relative_time(dt: datetime) -> str:
    """
    Converts a timezone-aware datetime into a human-readable string
    (e.g., '3 hours ago', '2 days ago').
    """
    # 1. Ensure the comparison is timezone-aware
    now = datetime.now(timezone.utc)

    # If the input is naive, this will fail; if it's aware, it works perfectly.
    # It's safest to convert the input to UTC to match 'now'.
    delta = now - dt.astimezone(timezone.utc)

    seconds = int(delta.total_seconds())

    # Handle future dates or very recent past
    if seconds < 0:
        return "in the future"
    if seconds < 10:
        return "just now"

    # 2. Define thresholds
    intervals = (
        ("day", 86400),  # 60 * 60 * 24
        ("hour", 3600),  # 60 * 60
        ("minute", 60),
        ("second", 1),
    )

    for name, count in intervals:
        value = seconds // count
        if value >= 1:
            # Handle pluralization
            suffix = "s" if value > 1 else ""
            return f"{value} {name}{suffix} ago"

    return "just now"


def get_readable_filesize(path: Path) -> str:
    """
    Converts a file's size in bytes into a human-readable string.
    """
    # 1. Get size in bytes
    try:
        size_bytes = path.stat().st_size
    except FileNotFoundError:
        return "0 B"

    if size_bytes == 0:
        return "0 B"

    # 2. Define the units
    units = ("B", "KB", "MB", "GB", "TB", "PB")

    # 3. Calculate the magnitude
    # We use i = floor(log(size, 1024)) to find the unit index
    import math

    i = int(math.floor(math.log(size_bytes, 1024)))

    # Ensure we don't go out of bounds of our 'units' tuple
    i = min(i, len(units) - 1)

    # 4. Format the result
    p = math.pow(1024, i)
    s = round(size_bytes / p, 2)

    return f"{s} {units[i]}"


# region Commands


@search_app.command(
    name="submit",
    help="""
    Submit a new search task with a list of target SMILES and optional databases. Track
    the progress of the search task in real-time with the status command and retrieve
    results when complete with the results command.

    [bold yellow]Examples:[/bold yellow]

    [yellow]Submit a search with SMILES structures:[/yellow]
      $ pendingai search submit CCO CCN CCC

    [yellow]Submit a search with a SMILES file:[/yellow]
      $ pendingai search submit -f smiles.smi

    [yellow]Submit a search with specific databases:[/yellow]
      $ pendingai search submit CCO -d db1 -d db2

    [yellow]Submit a search with a name and description:[/yellow]
      $ pendingai search submit CCO --name "My Search" --description "My Description"

    [yellow]Submit a search and output results in JSON format:[/yellow]
      $ pendingai search submit CCO --format json
    """,
    short_help="Submit a new search task for a set of SMILES structures.",
    rich_help_panel="Search Commands",
)
def submit_search(
    ctx: PendingAiContext,
    smiles_list: SmilesList = None,
    smiles_file: SmilesFile = None,
    databases: Databases = None,
    name: SearchName = None,
    description: SearchDescription = None,
    output_format: OutputFormat = Format.DEFAULT,
) -> Search:
    # Validation of input parameters. Callback methods ensure that the preparation
    # methods will evaluate the input SMILES source and yield unique SMILES lists.

    # 1. Check at least one SMILES source is provided.
    if not smiles_list and not smiles_file:
        raise typer.BadParameter("Must provide either SMILES or --file.")

    # 2. Check both SMILES sources are not provided.
    if smiles_list and smiles_file:
        raise typer.BadParameter("Cannot provide both SMILES and --file.")

    # 3. Prepare the SMILES input source from the list or file input parameters.
    smiles: List[str] = _prepare_smiles_list(smiles_list) if smiles_list else []
    if smiles_file:
        smiles = _prepare_smiles_file(smiles_file)

    # Perform the search submission request to the Pending AI API with the prepared
    # SMILES list and other optional parameters. Prepare and output the results.

    search: Search = ctx.obj["client"].search.searches.create(
        targets=smiles,
        databases=databases,
        name=name,
        description=description,
    )

    if output_format == Format.DEFAULT:
        table = Table(Column("Key", style="bold"), "Value", show_header=False, box=None)
        table.add_row("ID:", search.id)
        table.add_row("STATUS:", search.status.upper())
        table.add_row("DATABASES:", ", ".join(databases) if databases else "*")
        table.add_row("TARGETS:", str(len(smiles)))

        console.print()
        console.print("[green]✔ Search submitted successfully![/green]")
        console.print()
        console.print(table)
        console.print()
        console.print(
            f"[dim]Run 'pendingai search status {search.id}' to check progress.",
        )

    elif output_format == Format.JSON:
        console.print_json(search.model_dump_json())

    return search


@search_app.command(
    name="status",
    help="""
    Check the status of a submitted search task by its ID. Allows tracking the progress
    of the search task in real-time and viewing the changing status as the search is
    processed by the engine. Retrieve the results of the search when complete with the
    results command.

    [bold yellow]Examples:[/bold yellow]

    [yellow]Check the status of a search task:[/yellow]
      $ pendingai search status search_AbxeQ7aCqDD5vOsr

    [yellow]Follow the status of a search task until completion:[/yellow]
      $ pendingai search status search_AbxeQ7aCqDD5vOsr --follow
    """,
    short_help="Check the status of a search task by its ID.",
    rich_help_panel="Search Commands",
    deprecated=True,
    hidden=True,
)
def retrieve_search_status(ctx: PendingAiContext, search_id: SearchId) -> None:
    raise NotImplementedError("Work in progress")


@search_app.command(
    name="results",
    help="""
    Retrieve the results of a specific search task by its ID. Displays summary statistics
    about the search, a few of the top hits, and saves the full results to a file with a
    variety of supported data formats.

    [bold yellow]Examples:[/bold yellow]

    [yellow]Retrieve the results of a search task:[/yellow]
      $ pendingai search results search_AbxeQ7aCqDD5vOsr

    [yellow]Retrieve search results and save to a specific file:[/yellow]
      $ pendingai search results search_AbxeQ7aCqDD5vOsr -o results.json
    """,
    short_help="Retrieve the results of a search task by its ID.",
    rich_help_panel="Search Commands",
)
def retrieve_search_result(
    ctx: PendingAiContext,
    search_id: str = Argument(
        ...,
        metavar="ID",
        help="A search ID used to retrieve the results of a search task.",
    ),
    output_file: OutputFile = None,
) -> None:
    console.print()
    if output_file is None:
        output_file = Path(f"search_results_{search_id}.json")
    search_result: SearchResult = ctx.obj["client"].search.searches.result(search_id)

    results: List[Tuple[str, str, float, str, str]] = []
    for target in search_result.targets:
        for result in target["matches"]:
            results.append(
                (
                    target["smiles"],
                    result["smiles"],
                    result["score"],
                    result["database"],
                    result["collection"],
                )
            )

    total: int = len(results)
    elapsed: timedelta = search_result.updated_at - search_result.created_at
    console.print(
        f"✔ Search results retrieved: Found {total:,} similar structures.", style="green"
    )
    console.print()

    # Calculate summary statistics from the search at a very high-level.
    results.sort(key=lambda x: x[2], reverse=True)
    top_score, top_smiles, avg_score = 0.0, "", 0.0
    if results:
        top_score = results[0][2]
        top_smiles = results[0][1]
    for result in results:
        avg_score += result[2]
    avg_score /= total if total > 0 else 0.0

    # Output summary statistics about the search results.
    console.print("STATISTICS:")
    console.print(f"• Max Similarity (Tanimoto):  {top_score:.2f} ({top_smiles})")
    console.print(f"• Avg Similarity (Tanimoto):  {avg_score:.2f}")
    console.print(f"• Elapsed Time:               {format_timedelta(elapsed)}")

    # Output the top hits from the search results in a table format.
    table = Table(
        Column("TARGET", max_width=20, overflow="ellipsis"),
        Column("SMILES", max_width=20, overflow="ellipsis"),
        Column("DATABASE", max_width=10, overflow="ellipsis"),
        "SCORE",
        caption_justify="left",
        caption_style="dim",
        header_style="none",
        box=None,
        padding=(0, 1),
    )
    for x in range(5):
        table.add_row(
            results[x][0],
            results[x][1],
            results[x][3],
            f"{results[x][2]:.4f}",
        )

    table.caption = f"... {total - 5:,} more results hidden"

    console.print("")
    console.print("TOP HITS:")
    console.print(table)
    console.print()

    _write_results_to_json_file(search_result, output_file)


@search_app.command(
    name="delete",
    help="""
    Delete one or more submitted search tasks by their IDs. The command is not reversible
    and will permanently remove the specified search tasks and their results from your
    account. Contact support if you need assistance or believe an error has occurred.

    [bold yellow]Examples:[/bold yellow]

    [yellow]Delete a search task by ID:[/yellow]
      $ pendingai search delete search_AbxeQ7aCqDD5vOs

    [yellow]Delete multiple search tasks by IDs:[/yellow]
      $ pendingai search delete search_AbxeQ7aCqDD5vOs search_BcyeQ7aCqDD5vOst

    [yellow]Delete search tasks from a file containing their IDs:[/yellow]
      $ pendingai search delete -f search_ids.txt

    [yellow]Force delete a search task without confirmation:[/yellow]
      $ pendingai search delete search_AbxeQ7aCqDD5vOs --force
    """,
    short_help="Delete one or more search tasks by ID.",
    rich_help_panel="Search Commands",
)
def delete_searches(
    ctx: PendingAiContext,
    searches_list: SearchIdList = None,
    searches_file: SearchIdFile = None,
    output_format: OutputFormat = Format.DEFAULT,
    force: Force = False,
) -> Tuple[List[str], List[str]]:
    # 1. Validate input parameters for search IDs check mutual exclusivity.
    if not searches_list and not searches_file:
        raise typer.BadParameter("Must provide at least one Search ID or ID file.")
    elif searches_list and searches_file:
        raise typer.BadParameter("Cannot provide both Search IDs and ID file.")

    # 2. Collect search IDs and deduplicate list if necessary to avoid failures.
    search_ids: List[str] = searches_list if searches_list else []
    if searches_file:
        search_ids = [
            line.strip()
            for line in searches_file.read_text().splitlines()
            if line.strip()
        ]
    search_ids = list(set(search_ids))

    # 3. Confirm deletion action with the user if not forced.
    if not force and len(search_ids) == 1:
        typer.confirm(
            f"Are you sure you want to delete search task '{search_ids[0]}'?",
            abort=True,
        )
    elif not force and len(search_ids) > 1:
        typer.confirm(
            f"Are you sure you want to delete {len(search_ids)} search tasks?",
            abort=True,
        )

    total, deleted, failed = len(search_ids), [], []
    with Live(console=console, transient=True) as live:
        for i, search_id in enumerate(search_ids, start=1):
            try:
                ctx.obj["client"].search.searches.delete(search_id)
                deleted.append(search_id)
            except Exception:
                failed.append(search_id)

            live.update(
                f"[reset]Deleting search tasks... ({len(deleted)} of {total}; {len(failed)} "  # noqa: E501
                + "failed)",
                refresh=True,
            )

    if failed:
        console.print(
            f"[red]Failed to delete {len(failed)} search tasks:\n→ "
            + "[red]\n→ ".join(failed)
        )
    if len(deleted):
        console.print(
            f"[green]Successfully deleted {len(deleted)} search tasks.[/green]"
        )

    return deleted, failed


# region List Helper Methods

T = TypeVar("T", bound=Union[Search, SearchDatabase])
"""
Generic listable API resource types.
"""


def _get_pagination_commands(has_next: bool = False, has_prev: bool = False) -> Columns:
    """
    A footer with available commands for paginating through API list responses. Dynamic
    logic determines if the next and previous page commands are available.

    Args:
        has_next (bool): Whether there is a next page available.
        has_prev (bool): Whether there is a previous page available.

    Returns:
        Columns: A rich Columns renderable with the available pagination commands.
    """
    commands: List[Tuple[str, str]] = []
    if has_next:
        commands.append(("[bold cyan]N[/] Next", "Move forward"))
    if has_prev:
        commands.append(("[bold cyan]P[/] Prev", "Move back"))
    commands.append(("[bold red]Q[/] Quit", "Exit view"))

    items: List[Text] = [Text.from_markup(f"{k} [dim]{v}[/]") for k, v in commands]
    return Columns(items, equal=True)


def _render_page_table(page: ApiResourceList, table: Table) -> Group:
    """
    Render a pagination table and page commands together in group for live displays.

    Args:
        page (ApiResourceList): The current page of results, used to determine available
            pagination commands.
        table (Table): The rich Table renderable containing the current page of results.

    Returns:
        Group: A rich Group renderable containing the table and pagination commands for
            display in a live view.
    """
    return Group(
        table,
        Text(""),
        _get_pagination_commands(
            "next" in page.links.keys() if page.links else False,
            "prev" in page.links.keys() if page.links else False,
        ),
    )


def _render_search_tasks_page(page: ApiResourceList[Search]) -> Group:
    """
    Render a page of search tasks in a rich Table for display in a live view, including
    pagination commands.

    Args:
        page (ApiResourceList[Search]): A page of Search resources to render in a table.

    Returns:
        Group: A Group renderable storing the table of search tasks and pagination
            commands for display in a live view.
    """
    table: Table = Table(box=None)
    table.add_column("Name")
    table.add_column("ID", style="cyan")
    table.add_column("Created", style="green")
    table.add_column("Updated", style="green")
    table.add_column("Targets", justify="right")
    table.add_column("Status")

    mapping: Dict[str, str] = {
        "waiting": "[dim]waiting[/]",
        "processing": "[yellow]processing[/]",
        "completed": "[green]completed[/]",
        "failed": "[red]failed[/]",
    }

    for item in page.data:
        table.add_row(
            item.name or "[dim]unknown[/]",
            item.id,
            format_relative_time(item.created_at),
            format_relative_time(item.updated_at),
            f"{len(item.targets):,}",
            mapping.get(item.status.lower(), item.status.lower()),
        )

    return _render_page_table(page, table)


def _render_search_databases_page(page: ApiResourceList[SearchDatabase]) -> Group:
    """
    Render a page of search databases in a rich Table for display in a live view,
    including pagination commands.

    Args:
        page (ApiResourceList[SearchDatabase]): A page of SearchDatabase resources to
            render in a table.

    Returns:
        Group: A Group renderable storing the table of search databases and pagination
            commands for display in a live view.
    """
    table: Table = Table(box=None)
    table.add_column("Name")
    table.add_column("ID", style="cyan")
    table.add_column("Created", style="green")
    table.add_column("Updated", style="green")

    for item in page.data:
        table.add_row(
            item.name or "[dim italic]unknown[/]",
            item.id,
            format_relative_time(item.created_at),
            format_relative_time(item.updated_at),
        )

    return _render_page_table(page, table)


def _validate_cursor_parameters(
    cursor_validator: Callable[[str], Any],
    starting_after: Optional[str],
    ending_before: Optional[str],
    limit: int,
) -> Dict[str, Any]:
    """
    Check cursor pagination parameters are valid and catch invalid input patterns such as
    enforcing mutual exclusivity of `starting_after` and `ending_before`, validating the
    format of the provided IDs with the `cursor_validator` function, and preparing a
    cursor dictionary for API requests with the appropriate parameters.

    Args:
        cursor_validator (Callable[[str], Any]): A function that validates the format of
            the provided cursor IDs (e.g., `starting_after` and `ending_before`) and
            raises an exception if the format is invalid.
        starting_after (Optional[str]): An optional ID string used for pagination to
            specify the starting point of the page.
        ending_before (Optional[str]): An optional ID string used for pagination to
            specify the ending point of the page.
        limit (int): An integer specifying the maximum number of results to return in
            the page.

    Raises:
        BadParameter: If both `starting_after` and `ending_before` are provided, or
            if either of the provided IDs fail validation by the `cursor_validator`.

    Returns:
        Dict[str, Any]: A dictionary containing the validated cursor parameters ready to
            be unpacked in an API request for paginated results.
    """
    if starting_after and ending_before:
        raise BadParameter("Cannot use both --starting-after and --ending-before.")

    cursor: Dict[str, Any] = {}
    if starting_after:
        cursor["starting_after"] = starting_after
    if ending_before:
        cursor["ending_before"] = ending_before

    for p, v in cursor.items():
        try:
            cursor_validator(v) if v else None
        except Exception:
            raise BadParameter(f"Invalid --{p.replace('_', '-')} ID: '{v}'.")

    cursor["limit"] = limit

    return cursor


def _write_results_to_json_file(data: BaseModel, path: Path) -> Path:
    """
    Save the result of a `pydantic` data model such as content from an API request, into
    a JSON file for a specified path. Data is serialised and indented in the output, and
    a success message is printed to the console with the file path and size.

    Args:
        data (BaseModel): A `pydantic` BaseModel instance containing the data to
            serialise and save to a JSON file.
        path (Path): A `pathlib.Path` instance specifying the file path to save the
            JSON output to.

    Returns:
        Path: The specified file path.
    """
    with open(path, "w") as fp:
        fp.write(data.model_dump_json(indent=2))

    fs: str = get_readable_filesize(path)
    console.print(f"[green]✔ Saved output to file: [magenta][u]{path}[/u] ({fs})")
    return path


def _render_live_pagination(
    page_fetcher: Callable[[Dict[str, Any]], ApiResourceList[T]],
    page_renderer: Callable[[ApiResourceList[T]], Group],
    cursor: Dict[str, Any],
    limit: int,
) -> ApiResourceList[T]:
    """
    Render an interactive live view in the console for paginated API list responses. The
    view displays the current page of results rendered by the `page_renderer` function,
    and accepts user input to navigate to the next or previous page based on available
    links in the API response.

    Args:
        page_fetcher (Callable[[Dict[str, Any]], ApiResourceList[T]]): A function that
            takes a cursor dictionary as input and returns a page of results as an
            `ApiResourceList` of a generic type `T` (e.g., `Search` or `SearchDatabase`).
        page_renderer (Callable[[ApiResourceList[T]], Group]): A function that takes a
            page of results as input and returns a `Group` renderable for display in the
            live view (e.g., a table with pagination commands).
        cursor (Dict[str, Any]): A dictionary containing the initial cursor parameters
            for fetching the first page of results.
        limit (int): An integer specifying the maximum number of results to return.

    Returns:
        ApiResourceList[T]: The last fetched page of results after the user exits
            the live view.
    """
    console.print()
    with Live(console=console, screen=False, auto_refresh=False) as live:
        while True:
            page: ApiResourceList[T] = page_fetcher(cursor)
            live.update(page_renderer(page), refresh=True)

            try:
                key: str = typer.getchar().lower()
            except KeyboardInterrupt:
                break

            if key == "n" and page.data and page.links and "next" in page.links.keys():
                cursor = {"starting_after": page.data[-1].id, "limit": limit}
            elif key == "p" and page.data and page.links and "prev" in page.links.keys():
                cursor = {"ending_before": page.data[0].id, "limit": limit}
            elif key == "q":
                break
    return page


# endregion


@search_app.command(
    name="tasks",
    help="""
    List all search tasks in an interactive table with associated metadata. Pagination
    commands can be used to navigate through the list if available. A specific page of
    search tasks can also be retrieved directly with the list command and appropriate
    pagination parameters, and saved to an output file.

    [bold yellow]Examples:[/bold yellow]

    [yellow]List search tasks in an interactive table:[/yellow]
      $ pendingai search tasks

    [yellow]List search tasks and save to a file:[/yellow]
      $ pendingai search tasks -o tasks.json

    [yellow]List search tasks with a specific page:[/yellow]
      $ pendingai search tasks -o tasks.json --starting-after <id>
    """,
    short_help="List search tasks in an interactive table.",
    rich_help_panel="Search Commands",
)
def list_search_tasks(
    ctx: PendingAiContext,
    output_file: OutputFile = None,
    output_format: OutputFormat = Format.JSON,
    starting_after: StartingAfter = None,
    ending_before: EndingBefore = None,
    limit: Limit = 50,
) -> ApiResourceList[Search]:
    cursor: Dict[str, Any] = _validate_cursor_parameters(
        ctx.obj["client"].search.searches.retrieve,
        starting_after,
        ending_before,
        limit,
    )

    page: ApiResourceList = ctx.obj["client"].search.searches.list(**cursor)

    if output_file:
        _write_results_to_json_file(page, output_file)
        return page

    return _render_live_pagination(
        lambda c: ctx.obj["client"].search.searches.list(**c),
        _render_search_tasks_page,
        cursor,
        limit,
    )


@search_app.command(
    name="databases",
    help="""
    List all search databases in an interactive table with associated metadata.
    Pagination commands can be used to navigate through the list if available. A specific
    page of search databases can also be retrieved directly with the list command and
    appropriate pagination parameters, and saved to an output file.

    [bold yellow]Examples:[/bold yellow]

    [yellow]List search databases in an interactive table:[/yellow]
      $ pendingai search databases

    [yellow]List search databases and save to a file:[/yellow]
      $ pendingai search databases -o databases.json

    [yellow]List search databases with a specific page:[/yellow]
      $ pendingai search databases -o databases.json --starting-after <id>
    """,
    short_help="List search databases in an interactive table.",
    rich_help_panel="Search Commands",
)
def list_search_databases(
    ctx: PendingAiContext,
    output_file: OutputFile = None,
    output_format: OutputFormat = Format.JSON,
    starting_after: StartingAfter = None,
    ending_before: EndingBefore = None,
    limit: Limit = 50,
) -> ApiResourceList[SearchDatabase]:
    cursor: Dict[str, Any] = _validate_cursor_parameters(
        ctx.obj["client"].search.search_databases.retrieve,
        starting_after,
        ending_before,
        limit,
    )

    page: ApiResourceList = ctx.obj["client"].search.search_databases.list(**cursor)

    if output_file:
        _write_results_to_json_file(page, output_file)
        return page

    return _render_live_pagination(
        lambda c: ctx.obj["client"].search.search_databases.list(**c),
        _render_search_databases_page,
        cursor,
        limit,
    )


# endregion
