import itertools
import re
import typing as tp

from sqlalchemy import Column, Join, Label, Table

from .logger import logger
from .ty import SelectStatement


def get_join(text: str) -> list[str]:
    """Extract join conditions from SQL text.

    Finds all table.column = table.column patterns and returns them
    without whitespace.

    Args:
        text: SQL query string containing JOIN conditions (one or more)

    Returns:
        List of join condition strings in format "table.column=table.column"
    """
    matches = re.findall(r"([A-Za-z]+\.[A-Za-z]+\s*=\s*[A-Za-z]+\.[A-Za-z]+)", text)
    return [match.replace(" ", "") for match in matches]


def remove_cast(sql_string: str) -> str:
    """Remove CAST(...) expressions from SQL, keeping the column reference..

    Args:
        sql_string: SQL query string potentially containing CAST expressions

    Returns:
        SQL string with CAST expressions removed

    Example:
        >>> remove_cast("SELECT CAST(score AS INT)")
        'SELECT score'
    """
    pattern = r"CAST\(([^)]+)\s+AS\s+\w+\)"
    return re.sub(pattern, r"\1", sql_string, flags=re.IGNORECASE)


def remove_quote(sql_string: str) -> str:
    """Remove all double quotes from SQL string."""
    return sql_string.replace('"', "")


class CargoQuery(tp.TypedDict):
    """CargoQuery kwargs"""

    tables: str
    fields: str
    where: str | None
    join_on: str | None
    group_by: str | None
    having: str | None
    order_by: str | None
    offset: int | None
    limit: int | None


class OrderedSet:
    """Utility class to avoid duplication while maintaining order. This could be done much better but work well enough for now."""

    def __init__(self, iterable=None):
        self.items = []
        self._set = set()
        if iterable is not None:
            for item in iterable:
                self.add(item)

    def add(self, *items):
        for item in items:
            if item not in self._set:
                self.items.append(item)
                self._set.add(item)


class CargoQueryCompiler:
    def __init__(self) -> None:
        pass

    def _apply_callback(
        self, input: tp.List[str], callback: tp.Callable
    ) -> tp.List[str]:
        """Apply a callback to all the string in the list."""
        output = []

        for string in input:
            processed = callback(string)

            if isinstance(processed, list) and len(processed) > 0:
                output.extend(processed)
            elif isinstance(processed, str) and len(processed) > 0:
                output.append(processed)

        return output

    def _compile_many(
        self,
        criteria_or_clause,
        callback: tp.Callable[[str], list[str] | str | None] | None = None,
        join_str: str = ",",
    ) -> tp.Any | None:
        """Compile all the criteras or clauses and process it to fit to cargo expected format.

        Args:
            criteria_or_clause: criteria or clause (ex: where clause etc...)
            callback (tp.Callable[[str], list[str]  |  str  |  None] | None, optional): Callback to apply. Defaults to None.

        Returns:
            tp.Any | None: Formatted compiled query if there is any. Otherwise none.
        """
        if not criteria_or_clause:
            return None

        compiled = [
            str(crit.compile(compile_kwargs={"literal_binds": True}))
            for crit in criteria_or_clause
        ]

        # Remove all quotes & cast in the query as cargo will refuse it
        compiled = self._apply_callback(compiled, remove_quote)
        compiled = self._apply_callback(compiled, remove_cast)

        if callback:
            compiled = self._apply_callback(compiled, callback)

        if len(compiled) > 0:
            return join_str.join(compiled)

        return None

    def _compile_single(self, criteria_or_clause):
        """Just get the value of a clause that contains a single value."""
        if criteria_or_clause is None:
            return None

        return criteria_or_clause.value

    def _get_fields_and_table(
        self, joins, from_clause, selected_columns
    ) -> tp.Tuple[tp.List[str], tp.List[str]]:
        """Extract table names and field names from query components.

        Processes joins, from clauses, and selected columns to build lists of
        tables and fields required for the Cargo query.

        Args:
            joins: Join clauses from the statement
            from_clause: FROM clause components
            selected_columns: Selected column expressions

        Returns:
            tuple: (list of table names, list of field names)
        """
        tables = OrderedSet()
        fields = OrderedSet()

        def add_col(col: Column):
            table_name = col.table.name
            field_name = f"{table_name}.{col.name}"
            tables.add(table_name)
            fields.add(field_name)

        # chain all the iterable to process sequentially
        for st in itertools.chain(joins, from_clause, selected_columns):
            if isinstance(st, Column):
                # In case of column, we add both table and name
                # into known attrs
                add_col(st)

            elif isinstance(st, Label):
                # This is the case we do things like
                # func.max(Table.Attribute).label('name') on a group_by statement
                # Note: all cargo expression will fail if there is
                # arithmetic fields defined
                expression = remove_quote(str(st.expression))
                fields.add(f"{expression}={st.name}")

            elif isinstance(st, Table):
                # This might come from or the joins
                # in case of table, we have to ensure we use it
                # in the query.
                tables.add(st.name)

            elif isinstance(st, Join):
                # Only in joins, add both side tables as known tables
                for side in (st.right, st.left):
                    if isinstance(side, Table):  # only if it's actually tables
                        tables.add(side.name)

            elif (
                isinstance(st, tuple)
                and len(st) == 2
                and all((isinstance(col, Column) for col in st))
            ):
                # Only in joins, add both side tables as known tables
                left, right = st
                tables.add(left.table.name)
                tables.add(right.table.name)

            else:
                raise TypeError(f"Couldn't process: {type(st)} {st}")

        return (tables.items, fields.items)

    def compile(self, statement: SelectStatement) -> CargoQuery:
        """Compile a SQLAlchemy SelectStatement into a CargoQuery.

        Extracts and processes all query components (tables, fields, clauses)
        from the statement and formats them according to Cargo's query syntax.

        If there is multiple where, there will be merged in a single where like " AND ".join(where_clauses)

        Args:
            statement: SQLAlchemy SelectStatement to compile

        Returns:
            CargoQuery: Compiled query object ready for Cargo API
        """
        tables = []
        fields = []

        tables, fields = self._get_fields_and_table(
            [
                (onclause.left, onclause.right)  # type: ignore
                for (_, onclause, *_) in statement._setup_joins
                if onclause is not None
            ],
            statement.get_final_froms(),
            statement.selected_columns,
        )

        where = self._compile_many(statement._where_criteria, join_str=" AND ")
        order_by = self._compile_many(statement._order_by_clauses)
        group_by = self._compile_many(statement._group_by_clauses)
        having = self._compile_many(statement._having_criteria)

        join_on = self._compile_many(statement.get_final_froms(), callback=get_join)

        offset = self._compile_single(statement._offset_clause)
        limit = self._compile_single(statement._limit_clause)

        logger.debug(
            f"Created cargo query with "
            f"join='{join_on}' - "
            f"tables='{tables}' - "
            f"where='{where}' - "
            f"group_by='{group_by}' - "
            f"having='{having}' - "
            f"offset='{offset}' - "
            f"limit='{limit}' - "
            f"number of fields selected={len(fields)}"
        )

        return CargoQuery(
            tables=",".join(tables),
            fields=",".join(fields),
            where=where,
            group_by=group_by,
            having=having,
            order_by=order_by,
            join_on=join_on,
            limit=limit,
            offset=offset,
        )
