# 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 relation commands."""

import argparse
import sys
from collections.abc import Iterable
from operator import attrgetter
from typing import override

import rich
from rich.table import Table

from debusine.artifacts.models import CollectionCategory
from debusine.client.commands.base import ModelCommand, WorkspaceCommand
from debusine.client.exceptions import DebusineError
from debusine.client.models import (
    CollectionIdentifierRequest,
    CollectionRelationResponse,
    CollectionRelationSetResponse,
    CollectionRelationType,
)
from debusine.utils.input import YamlEditor


def _validate_collection_identifier(value: str) -> None:
    """Raise ValueError if the format or category are invalid."""
    invalid_collection_identifier_error = (
        f"Invalid collection identifier: {value!r}. "
        "Expected format is 'name@category'"
    )

    if "@" not in value:
        raise ValueError(invalid_collection_identifier_error)

    name, category = value.split("@", maxsplit=1)

    if name == "" or category == "":
        raise ValueError(invalid_collection_identifier_error)

    valid_categories = [
        item
        for item in CollectionCategory
        if item is not CollectionCategory.TEST
    ]

    if category not in valid_categories:
        categories = ", ".join(item.value for item in sorted(valid_categories))

        raise ValueError(
            f"Invalid collection identifier: {value!r}. "
            f"Category must be one of: {categories}"
        )


def _collection_identifier_argument(value: str) -> str:
    """Validate a collection string. Raise ArgumentTypeError."""
    try:
        _validate_collection_identifier(value)
    except ValueError as exc:
        raise argparse.ArgumentTypeError(str(exc)) from exc

    return value


class List(
    ModelCommand[CollectionRelationResponse],
    WorkspaceCommand,
    group="collection.relation",
):
    """List relations between collections in a workspace."""

    @override
    @classmethod
    def configure(cls, parser: argparse.ArgumentParser) -> None:
        super().configure(parser)
        parser.add_argument(
            "--from",
            dest="source",
            type=_collection_identifier_argument,
            metavar="COLLECTION",
            help="Filter by source collection in name@category format",
        )
        parser.add_argument(
            "--to",
            dest="target",
            type=_collection_identifier_argument,
            metavar="COLLECTION",
            help="Filter by target collection in name@category format",
        )
        parser.add_argument(
            "--type",
            dest="relation_type",
            choices=sorted(CollectionRelationType, key=attrgetter("value")),
            metavar="TYPE",
            help="Filter by relation type",
        )

    @override
    def run(self) -> None:
        with self._api_call_or_fail():
            relations = self.debusine.collection_relation_list(
                self.workspace,
                source=self.args.source,
                target=self.args.target,
                relation_type=self.args.relation_type,
            )
        self.list(relations)

    @override
    def _list_rich(
        self, collection_relations: Iterable[CollectionRelationResponse]
    ) -> None:
        """Print the list as a table."""
        table = Table(box=rich.box.MINIMAL_DOUBLE_HEAD)
        table.add_column("From")
        table.add_column("To")
        table.add_column("Type")
        table.add_column("Position")

        for relation in collection_relations:
            table.add_row(
                (
                    f"{relation.source_collection.name}"
                    f"@{relation.source_collection.category}"
                ),
                (
                    f"{relation.target_collection.name}"
                    f"@{relation.target_collection.category}"
                ),
                str(relation.type),
                (
                    str(relation.position)
                    if relation.position is not None
                    else "-"
                ),
            )
        rich.print(table)

    @override
    def _show_rich(
        self, collection_relation: CollectionRelationResponse
    ) -> None:
        raise NotImplementedError()


def _collection_identifiers_to_strs(
    collection_relations: Iterable[CollectionRelationResponse],
) -> list[str]:
    """Return target collection identifiers as strings."""
    return [
        f"{relation.target_collection.name}@"
        f"{relation.target_collection.category}"
        for relation in collection_relations
    ]


def _collection_identifiers_strs_to_requests(
    targets: list[str],
) -> list[CollectionIdentifierRequest]:
    requests = []

    for target in targets:
        _validate_collection_identifier(target)
        name, category = target.split("@", maxsplit=1)
        requests.append(
            CollectionIdentifierRequest(
                name=name, category=CollectionCategory(category)
            )
        )

    return requests


class Edit(WorkspaceCommand, group="collection.relation"):
    """Edit the relation target list for a given source collection and type."""

    @override
    @classmethod
    def configure(cls, parser: argparse.ArgumentParser) -> None:
        super().configure(parser)
        parser.add_argument(
            "source",
            type=_collection_identifier_argument,
            metavar="FROM-NAME@CATEGORY",
            help="Source collection in name@category format",
        )
        parser.add_argument(
            "relation_type",
            choices=sorted(CollectionRelationType, key=attrgetter("value")),
            metavar="TYPE",
            help="Relation type to edit",
        )
        parser.add_argument(
            "--append",
            type=_collection_identifier_argument,
            nargs="+",
            metavar="COLLECTION",
            help="Add collection(s) to the end of the target list",
        )
        parser.add_argument(
            "--prepend",
            type=_collection_identifier_argument,
            nargs="+",
            metavar="COLLECTION",
            help="Add collection(s) to the beginning of the target list",
        )
        parser.add_argument(
            "--remove",
            type=_collection_identifier_argument,
            nargs="+",
            metavar="COLLECTION",
            help="Remove collection(s) from the target list",
        )
        parser.add_argument(
            "--set",
            type=_collection_identifier_argument,
            nargs="+",
            metavar="COLLECTION",
            help="Replace the complete target list with collection(s)",
        )
        parser.add_argument(
            "--edit",
            action="store_true",
            help="Edit the complete target list using $EDITOR",
        )

    def _apply_target_list(
        self,
        *,
        source: str,
        relation_type: CollectionRelationType,
        targets: list[str],
        if_match: str,
    ) -> CollectionRelationSetResponse:
        """Apply a complete target list for (source, relation_type)."""
        request_targets = _collection_identifiers_strs_to_requests(targets)
        with self._api_call_or_fail():
            return self.debusine.collection_relation_set(
                self.workspace,
                source=source,
                relation_type=relation_type,
                targets=request_targets,
                if_match=if_match,
            )

    def _edit_and_apply_targets(
        self,
        source: str,
        relation_type: CollectionRelationType,
        initial: list[str],
        if_match: str,
    ) -> CollectionRelationSetResponse | None:
        """Run the editor and apply new targets."""
        editor: YamlEditor[list[str]] = YamlEditor(
            initial,
            help_text=[
                "Here you can edit the relation target list.",
                "",
                "This is a YAML list of collection identifiers",
                "written as NAME@CATEGORY.",
                "Each entry represents a target collection",
                f"from {source} and relation type {relation_type}.",
                "",
                "Lines starting with '#' are ignored.",
            ],
        )

        return editor.edit_apply_loop(
            apply=lambda new: self._apply_target_list(
                source=source,
                relation_type=relation_type,
                targets=new,
                if_match=if_match,
            ),
            apply_exception_class=DebusineError,
            print_exception=self.print_server_error,
            content_description="collection relation",
        )

    @override
    def run(self) -> None:
        if self.args.edit and (
            self.args.append
            or self.args.prepend
            or self.args.remove
            or self.args.set
        ):
            print(
                "--edit cannot be combined with --append, --prepend, "
                "--remove, or --set",
                file=sys.stderr,
            )
            raise SystemExit(1)

        with self._api_call_or_fail():
            current_relations, etag = (
                self.debusine.collection_relation_list_for_edit(
                    self.workspace,
                    source=self.args.source,
                    relation_type=self.args.relation_type,
                )
            )

        current = _collection_identifiers_to_strs(current_relations)
        new = list(current)

        if self.args.edit:
            result_applied = self._edit_and_apply_targets(
                source=self.args.source,
                relation_type=self.args.relation_type,
                initial=new,
                if_match=etag,
            )
            to_print = (
                result_applied.model_dump(mode="json")
                if result_applied is not None
                else current
            )
            self._print_yaml(to_print)
            return

        if self.args.set:
            new = self.args.set
        if self.args.append:
            new = new + self.args.append
        if self.args.prepend:
            new = self.args.prepend + new
        if self.args.remove:
            new = [target for target in new if target not in self.args.remove]

        if new != current:
            try:
                result_applied = self._apply_target_list(
                    source=self.args.source,
                    relation_type=self.args.relation_type,
                    targets=new,
                    if_match=etag,
                )
            except DebusineError as error:
                print("Error setting relation targets:\n", file=sys.stderr)
                self.print_server_error(error)
                raise SystemExit(3)

            self._print_yaml(result_applied.model_dump(mode="json"))
