"""Base parser from which all brokers are derived."""

from abc import ABC, abstractmethod
import argparse
from collections.abc import Iterable, Sequence
import csv
import logging
from pathlib import Path
from typing import ClassVar, TextIO

from cgt_calc.args_validators import (
    DeprecatedAction,
    existing_directory_type,
    existing_file_type,
)
from cgt_calc.exceptions import ParsingError, UnexpectedColumnCountError
from cgt_calc.model import BrokerTransaction

LOGGER = logging.getLogger(__name__)


class BaseParser(ABC):
    """Base parser from which all brokers are derived."""

    @classmethod
    @abstractmethod
    def register_arguments(cls, arg_group: argparse._ArgumentGroup) -> None:
        """Register argparse arguments for this broker."""

    @classmethod
    @abstractmethod
    def load_from_args(cls, args: argparse.Namespace) -> list[BrokerTransaction]:
        """Load broker data from parsed arguments."""


class BaseSingleFileParser(BaseParser):
    """Parser for single transaction file."""

    arg_name: str
    pretty_name: str
    format_name: str
    full_arg: str
    deprecated_flags: ClassVar[list[str]] = []

    def __init_subclass__(cls, **kwargs: object) -> None:
        """Compute full arg."""
        super().__init_subclass__(**kwargs)
        # compute full_arg once per subclass at class creation time
        suffix = "json" if getattr(cls, "format_name", None) == "JSON" else "file"
        cls.full_arg = f"{getattr(cls, 'arg_name', None)}-{suffix}"

    @classmethod
    def register_arguments(cls, arg_group: argparse._ArgumentGroup) -> None:
        """Register argparse arguments for this broker."""
        arg_group.add_argument(
            f"--{cls.full_arg}",
            type=existing_file_type,
            default=None,
            metavar="PATH",
            help=f"{cls.pretty_name} transaction history in {cls.format_name} format",
        )
        for deprecated_flag in cls.deprecated_flags:
            arg_group.add_argument(
                deprecated_flag,
                action=DeprecatedAction,
                dest=cls.full_arg.replace("-", "_"),
                type=existing_file_type,
                help=argparse.SUPPRESS,
            )

    @classmethod
    def load_from_args(cls, args: argparse.Namespace) -> list[BrokerTransaction]:
        """Load broker data from parsed arguments."""
        file_path = getattr(args, cls.full_arg.replace("-", "_"))
        if file_path:
            return cls.load_from_file(file_path)
        return []

    @classmethod
    def load_from_file(
        cls, file_path: Path, warn_on_empty: bool = True, show_parsing_msg: bool = True
    ) -> list[BrokerTransaction]:
        """Load broker data from file path."""
        with file_path.open(encoding="utf-8") as file:
            if show_parsing_msg:
                print(f"Parsing {file_path}...")
            transactions = cls.read_transactions(file, file_path)
            if not transactions and warn_on_empty:
                LOGGER.warning("No transactions detected in file %s", file_path)
            return transactions

    @classmethod
    @abstractmethod
    def read_transactions(
        cls, file: TextIO, file_path: Path
    ) -> list[BrokerTransaction]:
        """Parse broker transactions from open file."""


class StandardCSVParser(BaseSingleFileParser):
    """Parser for RAW format transaction files."""

    columns: ClassVar[set[str]]
    optional_columns: ClassVar[set[str]] = set()

    @classmethod
    def _validate_header(cls, header: Sequence[str], file_path: Path) -> None:
        """Validate optional header row."""
        header_set = set(header)
        missing = cls.columns - header_set
        extra = header_set - cls.columns - cls.optional_columns
        if missing or extra:
            raise ParsingError(
                file_path,
                f"CSV header mismatch. "
                f"Missing: {missing or 'none'}, Extra: {extra or 'none'}",
            )

    @classmethod
    @abstractmethod
    def read_row(cls, row: dict[str, str], file_path: Path) -> BrokerTransaction | None:
        """Read a single transaction from a row in the CSV."""

    @classmethod
    def pre_reading(cls, file: TextIO, file_path: Path) -> Iterable[str]:
        """Do any preprocessing of the file before parsing the csv."""
        return file

    @classmethod
    def read_transactions(
        cls, file: TextIO, file_path: Path
    ) -> list[BrokerTransaction]:
        """Read Raw transactions from file."""
        reader = csv.DictReader(cls.pre_reading(file, file_path))
        if reader.fieldnames is None:
            raise ParsingError(
                file_path, f"{cls.pretty_name} {cls.format_name} doesn't have a header"
            )
        cls._validate_header(reader.fieldnames, file_path)
        expected_col_count = len(reader.fieldnames)

        if not reader:
            raise ParsingError(
                file_path, f"{cls.pretty_name} {cls.format_name} file is empty"
            )

        transactions: list[BrokerTransaction] = []
        for index, row in enumerate(reader):
            try:
                if len(row) != expected_col_count:
                    raise UnexpectedColumnCountError(
                        list(row.values()), expected_col_count, file_path
                    )
                transaction = cls.read_row(row, file_path)
                if transaction:
                    transactions.append(transaction)
            except ParsingError as err:
                err.add_row_context(index)
                raise
            except ValueError as err:
                raise ParsingError(file_path, str(err), row_index=index) from err
        return transactions


class BaseDirParser(BaseSingleFileParser):
    """Parser for loading all files within a directory."""

    glob_dir: str
    deprecated_flags: ClassVar[list[str]] = []

    def __init_subclass__(cls, **kwargs: object) -> None:
        """Compute full arg."""
        suffix = "dir"
        cls.full_arg = f"{getattr(cls, 'arg_name', None)}-{suffix}"

    @classmethod
    def register_arguments(cls, arg_group: argparse._ArgumentGroup) -> None:
        """Register argparse arguments for this broker."""
        arg_group.add_argument(
            f"--{cls.full_arg}",
            type=existing_directory_type,
            default=None,
            metavar="DIR",
            help=f"directory with {cls.pretty_name} reports in {cls.format_name} format",
        )
        for deprecated_flag in cls.deprecated_flags:
            arg_group.add_argument(
                deprecated_flag,
                action=DeprecatedAction,
                dest=cls.full_arg.replace("-", "_"),
                type=existing_directory_type,
                help=argparse.SUPPRESS,
            )

    @classmethod
    def load_from_args(cls, args: argparse.Namespace) -> list[BrokerTransaction]:
        """Load broker data from parsed arguments."""
        dir_path = getattr(args, cls.full_arg.replace("-", "_"))
        if dir_path:
            return cls.load_from_dir(dir_path)
        return []

    @classmethod
    def load_from_dir(cls, dir_path: Path) -> list[BrokerTransaction]:
        """Load broker data from dir path."""
        transactions: list[BrokerTransaction] = []
        for file_path in sorted(dir_path.glob(cls.glob_dir)):
            if cls.file_path_filter(file_path):
                transactions += cls.load_from_file(file_path, warn_on_empty=False)
        if not transactions:
            LOGGER.warning(
                "No transactions detected in directory %s for broker %s",
                dir_path,
                cls.pretty_name,
            )
        return cls.post_process_transactions(transactions)

    @classmethod
    def file_path_filter(cls, file_path: Path) -> bool:
        """Choose which files to parse."""
        return True

    @classmethod
    def post_process_transactions(
        cls, transactions: list[BrokerTransaction]
    ) -> list[BrokerTransaction]:
        """Do any required post processing after loading all the transactions in the dir."""
        return transactions
