# Copyright © The Debusine Developers
# See the AUTHORS file at the top-level directory of this distribution
#
# This file is part of Debusine. It is subject to the license terms
# in the LICENSE file found in the top-level directory of this
# distribution. No part of Debusine, including this file, may be copied,
# modified, propagated, or distributed except according to the terms
# contained in the LICENSE file.

"""Debusine command line interface, collection management commands."""

import abc
import argparse
import sys
from collections.abc import Iterable
from typing import cast, override

import rich
import rich.json
from rich.table import Table

from debusine.artifacts.models import CollectionCategory
from debusine.client.commands.base import (
    InputDataCommand,
    ModelCommand,
    OptionalInputDataCommand,
    RequiredInputDataCommand,
    WorkspaceCommand,
)
from debusine.client.exceptions import DebusineError
from debusine.client.models import (
    CollectionData,
    CollectionDataNew,
    LookupChildType,
    LookupMultiple,
    LookupMultipleResponse,
    LookupSingleResponse,
)


class CollectionCommand(
    ModelCommand[CollectionData], WorkspaceCommand, abc.ABC
):
    """Common infrastructure for collection commands."""

    def markup_category(self, collection: CollectionData) -> str:
        """Markup a collection category for rich."""
        if collection.category == CollectionCategory.WORKFLOW_INTERNAL:
            return collection.category
        category_url = self.debusine.webui_url(
            collection.get_link_url("webui_category")
        )
        return f"[link={category_url}]{collection.category}[/]"

    def _url(self, instance: CollectionData) -> str:
        return self.debusine.webui_url(instance.get_link_url("webui_self"))

    @override
    def _list_rich(self, collections: Iterable[CollectionData]) -> None:
        table = Table(box=rich.box.MINIMAL_DOUBLE_HEAD)
        table.add_column("ID")
        table.add_column("Name")
        table.add_column("Category")
        table.add_column("Retention (history)")
        table.add_column("Retention (metadata)")
        table.add_column("Data")
        for collection in collections:
            url = self._url(collection)
            table.add_row(
                f"{collection.id}",
                f"[link={url}]{collection.name}[/]",
                self.markup_category(collection),
                (
                    str(period)
                    if (period := collection.full_history_retention_period)
                    is not None
                    else "unlimited"
                ),
                (
                    str(period)
                    if (period := collection.metadata_only_retention_period)
                    is not None
                    else "unlimited"
                ),
                (
                    rich.json.JSON.from_data(collection.data)
                    if collection.data
                    else None
                ),
            )
        rich.print(table)

    @override
    def _show_rich(self, collection: CollectionData) -> None:
        """Show a collection for humans."""
        url = self._url(collection)
        table = Table(box=rich.box.SIMPLE, show_header=False)
        table.add_column("Name", justify="right")
        table.add_row("ID:", f"#{collection.id}")
        table.add_row("Web URL:", f"[link={url}]{url}[/]")
        table.add_row("Name:", collection.name)
        table.add_row("Category:", self.markup_category(collection))
        full = (
            f"{period} days"
            if (period := collection.full_history_retention_period) is not None
            else "unlimited"
        )
        meta = (
            f"{period} days"
            if (period := collection.metadata_only_retention_period) is not None
            else "unlimited"
        )
        table.add_row("Full history retention:", full)
        table.add_row("Metadata only retention:", meta)
        table.add_row("Data:", rich.json.JSON.from_data(collection.data))
        if collection.stats:
            table.add_row("Stats:", rich.json.JSON.from_data(collection.stats))
        rich.print(table)


class List(CollectionCommand, group="collection"):
    """List collections in a workspace."""

    @override
    def run(self) -> None:
        with self._api_call_or_fail():
            collections = self.debusine.collection_iter(self.workspace)
            self.list(collections)


class SingleCollectionCommand(WorkspaceCommand, abc.ABC):
    """Command operating on a single collection."""

    def __init__(
        self, parser: argparse.ArgumentParser, args: argparse.Namespace
    ) -> None:
        """Identify the selected collection."""
        super().__init__(parser, args)
        if "@" in (cat := args.category_or_fullname):
            self.collection_name, self.collection_category = cat.split("@", 1)
            if args.name is not None:
                parser.error(
                    "collection name is given"
                    f" both in `{args.category_or_fullname}`"
                    " and in the name argument"
                )
        else:
            self.collection_category = cat
            self.collection_name = args.name or "_"

    @override
    @classmethod
    def configure(cls, parser: argparse.ArgumentParser) -> None:
        super().configure(parser)
        parser.add_argument(
            "category_or_fullname",
            type=str,
            metavar="[name@]category",
            help="collection category or name@category full name",
        )
        parser.add_argument(
            "name",
            type=str,
            nargs="?",
            help="collection name, only needed if not specified"
            " in the first argument. Defaults to '_'.",
        )


class Show(SingleCollectionCommand, CollectionCommand, group="collection"):
    """Show an existing collection."""

    @override
    def run(self) -> None:
        with self._api_call_or_fail():
            collection = self.debusine.collection_get(
                self.workspace,
                self.collection_category,
                self.collection_name,
                with_stats=True,
            )
        self.show(collection)


def retention_period(value: str) -> int | str:
    """Validate a retention period from the command line."""
    if value.isdigit():
        return int(value)
    elif value == "-":
        return "-"
    else:
        raise argparse.ArgumentError(
            argument=None, message="retention period must be a number or '-'"
        )


class CollectionInput(CollectionCommand, abc.ABC):
    """Common code for commands that input data for collections."""

    @override
    @classmethod
    def configure(cls, parser: argparse.ArgumentParser) -> None:
        super().configure(parser)
        parser.add_argument(
            "--full-history-retention-period",
            type=retention_period,
            help="set full history retention period (in days)."
            " Use a dash ('-') to unset",
        )
        parser.add_argument(
            "--metadata-only-retention-period",
            type=retention_period,
            help="set metadata only retention period (in days)."
            " Use a dash ('-') to unset",
        )

    def update_collection(
        self, collection: CollectionDataNew | CollectionData
    ) -> None:
        """Update a collection record with data from command line arguments."""
        if (period := self.args.full_history_retention_period) is not None:
            if period == "-":
                collection.full_history_retention_period = None
            else:
                collection.full_history_retention_period = int(period)

        if (period := self.args.metadata_only_retention_period) is not None:
            if period == "-":
                collection.metadata_only_retention_period = None
            else:
                collection.metadata_only_retention_period = int(period)


class Create(
    SingleCollectionCommand,
    RequiredInputDataCommand,
    CollectionInput,
    group="collection",
):
    """Create a new collection."""

    @override
    def update_collection(
        self, collection: CollectionDataNew | CollectionData
    ) -> None:
        super().update_collection(collection)
        collection.data = self.input_data

    @override
    def run(self) -> None:
        collection = CollectionDataNew(
            category=self.collection_category,
            name=self.collection_name,
            data={},
        )
        self.update_collection(collection)

        with self._api_call_or_fail():
            collection_new = self.debusine.collection_create(
                self.workspace, collection
            )

        self.show(collection_new)


class Manage(
    SingleCollectionCommand,
    OptionalInputDataCommand,
    CollectionInput,
    group="collection",
):
    """Configure an existing collection."""

    @override
    @classmethod
    def configure(cls, parser: argparse.ArgumentParser) -> None:
        super().configure(parser)
        parser.add_argument("--rename", type=str, help="rename collection")

    @override
    def update_collection(
        self, collection: CollectionDataNew | CollectionData
    ) -> None:
        super().update_collection(collection)
        if self.input_data is not None:
            collection.data = self.input_data

    @override
    def run(self) -> None:
        with self._api_call_or_fail():
            collection = self.debusine.collection_get(
                self.workspace, self.collection_category, self.collection_name
            )
        self.update_collection(collection)

        if (name := self.args.rename) is not None:
            collection.name = name

        with self._api_call_or_fail():
            collection = self.debusine.collection_update(
                self.workspace, collection
            )

        self.show(collection)


class Delete(SingleCollectionCommand, WorkspaceCommand, group="collection"):
    """Delete a collection."""

    @override
    @classmethod
    def configure(cls, parser: argparse.ArgumentParser) -> None:
        super().configure(parser)
        parser.add_argument(
            "-f",
            "--force",
            action="store_true",
            help="force deleting non-empty collections",
        )

    @override
    def run(self) -> None:
        with self._api_call_or_fail():
            collection = self.debusine.collection_get(
                self.workspace,
                self.collection_category,
                self.collection_name,
                with_stats=not self.args.force,
            )

        if not self.args.force:
            assert collection.stats is not None
            items: list[str] = []
            for s in collection.stats:
                state = "active" if s["active"] else "deleted"
                items.append(
                    f"{state} {s["category"]} {s["type"]}: {s["count"]}"
                )
            if items:
                self._fail(
                    f"Collection is not empty ({", ".join(items)}):"
                    " use --force to delete it."
                )

        with self._api_call_or_fail():
            self.debusine.collection_delete(self.workspace, collection)

        self.feedback("Collection deleted.")


class LookupCommand(WorkspaceCommand, abc.ABC):
    """Base class for Lookup commands."""

    @override
    @classmethod
    def configure(cls, parser: argparse.ArgumentParser) -> None:
        super().configure(parser)
        parser.add_argument(
            "--workflow",
            type=int,
            metavar="ID",
            help=(
                "Execute the lookup within workflow ID. Specify this to query "
                "the internal workflow's internal collection."
            ),
        )
        parser.add_argument(
            "--default-category",
            type=CollectionCategory,
            metavar="CATEGORY",
            help=(
                "Limit search to collections of category CATEGORY, "
                "if not specified in the lookup."
            ),
        )
        parser.add_argument(
            "--expect-type",
            type=LookupChildType,
            default=LookupChildType.ANY,
            metavar="TYPE",
            help="Limit results to items of TYPE. (Default: ANY)",
        )

    def _list_yaml(
        self, result: LookupMultipleResponse[LookupSingleResponse]
    ) -> None:
        self._print_yaml(
            result.model_dump(exclude_unset=True, exclude_none=True)
        )

    def _list_rich(
        self, results: LookupMultipleResponse[LookupSingleResponse]
    ) -> None:
        table = Table(box=rich.box.MINIMAL_DOUBLE_HEAD)
        table.add_column("Type")
        table.add_column("Artifact")
        table.add_column("Collection")
        table.add_column("Parent Collection")
        table.add_column("Collection Item")
        for result in results:
            artifact = ""
            if result.artifact:
                url = self.debusine.webui_url(result.get_link_url("webui_self"))
                artifact = f"[link={url}]{result.artifact}[/]"

            collection = ""
            if result.collection:
                url = self.debusine.webui_url(result.get_link_url("webui_self"))
                collection = f"[link={url}]{result.collection}[/]"

            parent_collection = ""
            if result.parent_collection_lookup:
                lookup = result.parent_collection_lookup
                url = self.debusine.webui_url(
                    result.get_link_url("webui_parent_collection")
                )
                parent_collection = f"[link={url}]{lookup}[/]"

            table.add_row(
                result.result_type.name,
                artifact,
                collection,
                parent_collection,
                (
                    rich.json.JSON.from_data(
                        result.collection_item.model_dump()
                    )
                    if result.collection_item
                    else ""
                ),
            )
        rich.print(table)


class LookupSingleCommand(
    LookupCommand, name="lookup-single", group="collection"
):
    """Perform a lookup with a single result."""

    @override
    @classmethod
    def configure(cls, parser: argparse.ArgumentParser) -> None:
        super().configure(parser)
        parser.add_argument(
            "lookup",
            type=str,
            metavar="lookup",
            help="Lookup string",
        )

    @override
    def run(self) -> None:
        with self._api_call_or_fail():
            try:
                result = self.debusine.lookup_single(
                    self.args.lookup,
                    self.args.workflow,
                    self.workspace,
                    self.args.expect_type,
                    self.args.default_category,
                )
            except DebusineError as err:
                output = {"result": "failure", "error": err.asdict()}
                self._print_yaml(output, file=sys.stderr)
                raise SystemExit(3)
        if self.args.yaml:
            self._print_yaml(
                result.model_dump(exclude_unset=True, exclude_none=True)
            )
        else:
            self._list_rich(LookupMultipleResponse([result]))


class LookupMultipleCommand(
    LookupCommand, InputDataCommand, name="lookup-multiple", group="collection"
):
    """Perform a lookup with multiple results."""

    lookup: LookupMultiple

    @override
    @classmethod
    def configure(cls, parser: argparse.ArgumentParser) -> None:
        super().configure(parser)
        parser.add_argument(
            "lookup",
            type=str,
            metavar="lookup",
            nargs="*",
            help="Lookup string",
        )
        parser.add_argument(
            "--data",
            type=str,
            default="-",
            help=(
                "File path (or - for stdin) to read the lookups from. "
                "YAML format with a single key: lookups."
            ),
        )

    def __init__(
        self, parser: argparse.ArgumentParser, args: argparse.Namespace
    ) -> None:
        """Read lookups from input."""
        super().__init__(parser, args)
        if self.args.lookup != []:
            self.lookup = self.args.lookup
        else:
            data = self.read_input_data(self.args.data)
            if "lookups" not in data:
                sys.stderr.write("Key 'lookups' not found in YAML input.\n")
                raise SystemExit(2)
            if not isinstance(data["lookups"], list):
                sys.stderr.write("Key 'lookups' must be a list of lookups.\n")
                raise SystemExit(2)
            self.lookup = cast(LookupMultiple, data["lookups"])

    @override
    def run(self) -> None:
        with self._api_call_or_fail():
            try:
                result = self.debusine.lookup_multiple(
                    self.lookup,
                    self.args.workflow,
                    self.workspace,
                    self.args.expect_type,
                    self.args.default_category,
                )
            except DebusineError as err:
                output = {"result": "failure", "error": err.asdict()}
                self._print_yaml(output, file=sys.stderr)
                raise SystemExit(3)
        if self.args.yaml:
            self._list_yaml(result)
        else:
            self._list_rich(result)
