"""Build and query RelationalAI semantic models.

The :mod:`relationalai.semantics` package provides a small, composable Python
DSL for:

- Creating a :class:`frontend.base.Model`
- Declaring concepts (entity types) and relationships with :class:`frontend.base.Concept`
  and :class:`frontend.base.Relationship`
- Adding definitions (base data and logic) with :func:`define`
- Filtering and composing queries with :func:`where`
- Selecting values with :func:`select` and  materializing results (for example with
  :meth:`frontend.base.Fragment.to_df`)

Most functions in this module are convenience wrappers. They operate on the
single "active" model (the only :class:`frontend.base.Model` that has been created).
For quick interactive use (e.g., notebooks) these convenience wrappers
(:func:`select`, :func:`where`, etc.) can keep code terse. For larger applications
and libraries, prefer calling methods on an explicit model instance
(e.g., :meth:`frontend.base.Model.select`, :meth:`frontend.base.Model.where`,
and :meth:`frontend.base.Model.define`). This avoids ambiguity if another module
(or test) creates a second :class:`frontend.base.Model`.

In addition to the query helpers, this module re-exports commonly used DSL
types such as :class:`frontend.base.Concept`, :class:`frontend.base.Relationship`, and
:class:`frontend.base.Property` from :mod:`frontend.base`, as well as core value types such as
:data:`Any`, :data:`String`, and :data:`Number` from :mod:`frontend.core`.

Examples
--------
Create a semantic model:

>>> from relationalai.semantics import Model, define, select, where, String, Integer
>>> m = Model()

Declare a Person concept:

>>> Person = m.Concept("Person")

Declare properties on Person:

>>> Person.name = m.Property(f"{Person} is named {String:name}")
>>> Person.age = m.Property(f"{Person} is {Integer:age} years old")

Define some Person entities:

>>> define(
...     Person.new(name="Alice", age=10),
...     Person.new(name="Bob", age=30),
... )

Select names of people over 18:

>>> where(Person.age > 18).select(Person.name).to_df()
"""
from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from pandas import DataFrame

from ..util.error import exc
from ..util.docutils import include_in_docs
from .frontend.base import (
    Alias, CoreLibrary, Concept, Data, Distinct, Expression, Field, FieldRef, Fragment, Library,
    Literal, Match, Model, New, Not, Chain, Property, Reading, Relationship, Statement, StatementAndSchema, Table, Union, Value,
    Variable,
)
# populate the core library
from .frontend import core
from .frontend.core import (
    Any, AnyEntity, Number, String, Integer, Int, Int64, Int128, Float, Decimal, Bool, Boolean, Date, DateTime, Error
)

# include this module in documentation
__include_in_docs__ = True

    # String, Integer, Int64, Int128, Float, Decimal, Bool,
    # Date, DateTime,
    # RawSource, Hash,
    # select, where, require, define, distinct, union, data,
    # rank, asc, desc,
    # count, sum, min, max, avg, per,
    # not_

#------------------------------------------------------
# Convenience model functions
#------------------------------------------------------

def _check_model():
    if len(Model.all_models) == 0:
        exc("Missing Model", "No Model has been defined. Please create a Model before using the semantics API.")
    elif len(Model.all_models) > 1:
        exc("Ambiguous model", "Multiple Models have been defined. Please use functions on the specific Model.")
    return Model.all_models[0]

@include_in_docs
def select(*args:StatementAndSchema) -> Fragment:
    """Return a selection fragment from the active model.

    This is a convenience wrapper around :meth:`frontend.base.Model.select` that operates on the single active model.
    It returns a composable :class:`frontend.base.Fragment` that can be further refined with
    :meth:`frontend.base.Fragment.where`, :meth:`frontend.base.Fragment.define`,
    and :meth:`frontend.base.Fragment.require`, and materialized via :meth:`frontend.base.Fragment.to_df`.

    If you have more than one model, call :meth:`frontend.base.Model.select` on the specific model instead.

    Parameters
    ----------
    *args : StatementAndSchema
                Items to select. You can pass either:

                - A :data:`frontend.base.Statement` (e.g., a property/relationship,
                  expression, or literal)
                - A :class:`frontend.base.TableSchema` (typically created via
                  :meth:`frontend.base.Table.to_schema`), which is expanded into
                  selecting each column of the table.

    Returns
    -------
    Fragment
        A composable query fragment representing the selection.

    Raises
    ------
    relationalai.util.error.RAIException
        If there is no active model, or if multiple models exist and the active
        model would be ambiguous.

    Examples
    --------
    Select a literal value and materialize the result as a pandas DataFrame:

    >>> from relationalai.semantics import Model, select, where
    >>> m = Model()
    >>> select(1).to_df()

    Combine with filters:

    >>> Person = m.Concept("Person")
    >>> define(
    ...     Person.new(name="Alice", age=10),
    ...     Person.new(name="Bob", age=30),
    ... )
    >>> select(Person.name, Person.age).where(Person.age > 21).to_df()

    Extract the selected values by unpacking the fragment:

    >>> name, age = select(Person.name, Person.age)
    >>> where(age >= 18).select(name).to_df()
    """
    model = _check_model()
    return model.select(*args)

@include_in_docs
def define(*args:Statement) -> Fragment:
    """Add definitions (facts and logic) to the active model.

    This is a convenience wrapper around :meth:`frontend.base.Model.define` that operates on the single active model.
    Definitions can be unconditional (calling :func:`define` directly) or conditional by chaining
    :meth:`frontend.base.Fragment.where` on the returned fragment. You can also write
    conditional definitions in the opposite order (filtering first with :func:`where`
    and then calling :meth:`frontend.base.Fragment.define`).

    If you have more than one model, call :meth:`frontend.base.Model.define` on the specific model instead.

    Parameters
    ----------
    *args : Statement
        One or more :data:`frontend.base.Statement` items to define. Common examples include:

        - Creating entities: `Person.new(name="Alice", age=10)`
        - Setting properties/relationships: `Person.age == 10`
        - Defining derived values: `Person.age_days == (Person.age * 365)`

    Returns
    -------
    Fragment
        A composable fragment representing the definition(s).

    Raises
    ------
    relationalai.util.error.RAIException
        If there is no active model, or if multiple models exist and the active
        model would be ambiguous.

    Examples
    --------
    Define some base facts (data):

    >>> from relationalai.semantics import Model, define
    >>> m = Model()
    >>> Person = m.Concept("Person")
    >>> define(
    ...     Person.new(name="Alice", age=10),
    ...     Person.new(name="Bob", age=30),
    ... )

    Define a derived property:

    >>> define(Person.age_days(Person.age * 365))

    Define Adult as a derived concept based on age:

    >>> Adult = m.Concept("Adult", extends=[Person])
    >>> define(Adult(Person)).where(Person.age >= 18)
    """
    model = _check_model()
    return model.define(*args)

@include_in_docs
def where(*args:Statement) -> Fragment:
    """Return a fragment filtered by the given conditions.

    This is a convenience wrapper around :meth:`frontend.base.Model.where` that operates on
    the single active model. It returns a composable :class:`frontend.base.Fragment` that can be further
    refined with :meth:`frontend.base.Fragment.define`, :meth:`frontend.base.Fragment.require`, and
    :meth:`frontend.base.Fragment.select`. You can also add additional filters by chaining
    :meth:`frontend.base.Fragment.where` on an existing fragment.

    If you have more than one model, call :meth:`frontend.base.Model.where` on the specific model instead.

    Parameters
    ----------
    *args : Statement
        One or more :data:`frontend.base.Statement` items to use as filter conditions.
        Common examples include:

        - Comparison operators: `Person.age > 21`
        - String operators: `Person.name.startswith("A")`
        - Logical operators: `not_(Person.name.startswith("A"))`
        - Property existence: `Person.age` (filters to entities with an age property)

    Returns
    -------
    Fragment
        A composable fragment representing the filter(s).

    Raises
    ------
    relationalai.util.error.RAIException
        If there is no active model, or if multiple models exist and the active
        model would be ambiguous.

    Examples
    --------
    Filter and select:

    >>> from relationalai.semantics import Model, where, select
    >>> m = Model()
    >>> Person = m.Concept("Person")
    >>> where(Person.age > 21).select(Person.name).to_df()

    Filter a definition:

    >>> Adult = m.Concept("Adult", extends=[Person])
    >>> where(Person.age >= 18).define(Adult(Person))

    Multiple conditions are combined with AND:

    >>> where(Person.age > 21, Person.name.startswith("A")).select(Person.name).to_df()

    Chained filters are also combined with AND:

    >>> where(Person.age > 21).where(Person.name.startswith("A")).select(Person.name).to_df()
    """
    model = _check_model()
    return model.where(*args)

@include_in_docs
def require(*args:Statement) -> Fragment:
    """Add requirements (constraints) to the active model.

    This is a convenience wrapper around :meth:`frontend.base.Model.require` that operates on
    the single active model. It returns a composable :class:`frontend.base.Fragment` representing one or
    more requirements. Requirements can be unconditional (calling :func:`require` directly) or
    conditional by chaining :meth:`frontend.base.Fragment.where` on the returned fragment.
    You can also apply requirements to an existing fragment by calling :meth:`frontend.base.Fragment.require`.

    If you have more than one model, call :meth:`frontend.base.Model.require` on the specific model instead.

    A requirement is an existence check: it fails when there are *no* matches.
    Roughly speaking, ``require(expr)`` asserts ``EXISTS(expr)`` and fails
    when ``NOT EXISTS(expr)``.  For example, ``require(Person.age > 0)`` fails
    if there are no people, if no one has an age value, or if no age values are
    greater than zero.

    To enforce a per-entity requirement, scope it with :func:`where` (for example,
    ``where(Person).require(Person.age > 0)``) or use :meth:`frontend.base.Concept.require`
    (for example, ``Person.require(Person.age > 0)``). Both of those forms are
    equivalent and the choice is mostly a matter of style.

    Parameters
    ----------
    *args : Statement
        One or more :data:`frontend.base.Statement` items
        to enforce as requirements.

        Common examples include:

        - Comparison operators: `Person.age >= 0` (requires at least one matching fact)
        - Property existence: `Person.name` (requires at least one name fact)
        - Entity existence: `Person` (requires at least one Person entity)

    Returns
    -------
    Fragment
        A composable fragment representing the requirement(s).

    Raises
    ------
    relationalai.util.error.RAIException
        If there is no active model, or if multiple models exist and the active
        model would be ambiguous.

    Notes
    -----
    Requirements can have performance implications (they may add work, but can
    also make queries faster by narrowing what needs to be considered).
    For more detail and guidance, see :meth:`frontend.base.Model.require`.

    Examples
    --------
    Require that at least one match exists:

    >>> from relationalai.semantics import Integer, Model, require, where
    >>> m = Model()
    >>> Person = m.Concept("Person")
    >>> m.define(Person.new(), Person.new(age=1))
    >>> # Passes because there exists a Person with age >= 0.
    >>> require(Person.age >= 0)

    Enforce a requirement per entity by scoping it with ``where``:

    >>> # Requires every Person to have an age >= 0.
    >>> where(Person).require(Person.age >= 0)

    Add a requirement on an existing fragment:

    >>> from relationalai.semantics import where
    >>> where(Person).require(Person.age >= 0)
    """
    model = _check_model()
    return model.require(*args)

@include_in_docs
def union(*args:Value) -> Union:
    """Combine items with a logical OR or a set-style union.

    This is a convenience wrapper around :meth:`frontend.base.Model.union` that operates on
    the single active model. It returns a :class:`frontend.base.Union` object.

    Unlike the ``|`` operator (see :meth:`frontend.base.Variable.__or__`),
    which picks the first branch that can succeed and is commonly used for
    fallback/default values, `union` combines the results from all branches.

    `union` is used in two common ways:

    - As an OR-combinator in :func:`where` clauses, e.g., ``where(union(cond1, cond2))``.
    - As a union of multiple fragments/tables that return the same number of values.
      This produces a derived table whose columns can be selected or unpacked.
      (All branches must have the same “shape”, i.e. number of selected values.)

    Nested unions are flattened, so ``union(union(a, b), c)`` is equivalent to
    ``union(a, b, c)``.

    If you have more than one model, call :meth:`frontend.base.Model.union` on the specific model instead.

    Parameters
    ----------
    *args : Value
        One or more :data:`frontend.base.Value` items to
        union. Each branch must return the same number of values.

    Returns
    -------
    Union
        A composable union object.

    Raises
    ------
    relationalai.util.error.RAIException
        Raised in the following cases:

        - There is no active model, or multiple models exist and the active model would be ambiguous.
        - The union branches return different numbers of values.

    Examples
    --------
    Use `union` to OR conditions:

    >>> from relationalai.semantics import Model, select, union, where
    >>> m = Model()
    >>> Person = m.Concept("Person")
    >>> define(
    ...     Person.new(name="Alice", age=10),
    ...     Person.new(name="Bob", age=30),
    ...     Person.new(name="Charlie", age=70),
    ... )
    >>> where(union(Person.age < 18, Person.age >= 65)).select(Person.name).to_df()

    Union two fragments that select the same number of values:

    >>> Edge = m.Concept("Edge")
    >>> e, src, dst = union(
    ...     where(Edge, Edge.src <= Edge.dst).select(Edge, Edge.src, Edge.dst),
    ...     where(Edge, Edge.src > Edge.dst).select(Edge, Edge.dst, Edge.src),
    ... )
    >>> where(Edge).select(src, dst).to_df()
    """
    model = _check_model()
    return model.union(*args)

@include_in_docs
def data(data: DataFrame | list[tuple] | list[dict], columns: list[str]|None = None, schema: dict[str, Concept]|None = None) -> Data:
    """Create a temporary table from in-memory Python or pandas data.

    This is a convenience wrapper around :meth:`frontend.base.Model.data` that operates on
    the single active model. The returned :class:`frontend.base.Data`
    object acts like a table: you can refer to its columns (e.g., `d.a`, `d["a"]`)
    in :func:`select`, :func:`where`, and :func:`define`. You can also convert it to a
    schema via :meth:`frontend.base.Table.to_schema` and use that schema when creating new entities.

    If you have more than one model, call :meth:`frontend.base.Model.data` on the specific model instead.

    Parameters
    ----------
    data : pandas.DataFrame | list[tuple] | list[dict]
        The input data. Supported forms are:

        - A pandas `DataFrame`
        - A list of tuples (each tuple is a row)
        - A list of dicts (each dict is a row)

    columns : list[str], optional
        Column names to use when `data` is a list of tuples.
        If omitted, the data will use default integer column labels `0`, `1`,
        `2`, ... . Those default labels are exposed as `col0`, `col1`, `col2`,
        ... so you can write `d.col0`, `d.col1`, etc.

    schema : dict[str, Concept], optional
        Explicit column type overrides. Keys are column names and values are
        :class:`Concept` types (for example ``Integer`` or ``String``). When
        provided, the type for each named column is taken from this mapping
        instead of being inferred from the data. Columns not present in the
        mapping are still inferred from the data as usual.

    Returns
    -------
    Data
        A table-like object backed by the provided data.

    Raises
    ------
    relationalai.util.error.RAIException
        If there is no active model, or if multiple models exist and the active
        model would be ambiguous.
    TypeError
        If `data` cannot be converted into a pandas `DataFrame`.

    Examples
    --------
    Create data from a list of dicts and use it directly:

    >>> from relationalai.semantics import Model, data, define, select
    >>> m = Model()
    >>> people_rows = data([
    ...     {"name": "Alice", "age": 10},
    ...     {"name": "Bob", "age": 30},
    ...     {"name": "Charlie", "age": 70},
    ... ])
    >>> select(people_rows.name, people_rows.age).to_df()

    Use data to create entities by converting to a schema using :meth:`frontend.base.Table.to_schema`
    and passing that schema to :meth:`frontend.base.Concept.new`:

    >>> Person = m.Concept("Person")
    >>> define(Person.new(people_rows.to_schema()))
    >>> select(Person.name, Person.age).to_df()

    Provide column names when loading tuple rows:

    >>> measurements = data([(0, 72.5), (1, 71.9)], columns=["minute", "temperature"])
    >>> select(measurements.minute, measurements.temperature).to_df()
    """
    model = _check_model()
    return model.data(data, columns, schema=schema)

@include_in_docs
def distinct(*args:Value) -> Distinct:
    """Mark values as distinct (unique) for use in :func:`select` and aggregates.

    This is a convenience wrapper around :meth:`frontend.base.Model.distinct` that operates
    on the single active model. It returns a :class:`frontend.base.Distinct` object.
    `distinct` is used in two common ways:

    - As a `SELECT DISTINCT`-style wrapper by passing it as the *only* argument
      to :func:`select`, e.g. `select(distinct(x, y))`.
    - As an argument wrapper for aggregates to make them count/sum/etc. over
      unique values, e.g. `count(distinct(Person.name))`.

    If you have more than one model, call :meth:`frontend.base.Model.distinct` on the specific model instead.

    Parameters
    ----------
    *args : Value
        One or more :data:`frontend.base.Value` items
        to consider when removing duplicates.

    Returns
    -------
    Distinct
        An object you pass to :func:`select` or to
        aggregate functions to request “unique values only”.

    Raises
    ------
    relationalai.util.error.RAIException
        Raised in the following cases:

        - There is no active model, or multiple models exist and the active
          model would be ambiguous.
        - `distinct` is used incorrectly (for example, not applied to the
          entire `select(...)`, or not applied to all aggregate arguments).

    Examples
    --------
    Count unique names (like SQL `COUNT(DISTINCT name)`):

    >>> from relationalai.semantics import Model, Concept, count, distinct, select
    >>> m = Model()
    >>> Person = m.Concept("Person")
    >>> define(
    ...     Person.new(id=1, name="Alice"),
    ...     Person.new(id=2, name="Bob"),
    ...     Person.new(id=3, name="Alice"),
    ... )
    >>> select(count(distinct(Person.name))).to_df()

    Select unique rows by wrapping all selected values:

    >>> counts = count(Person).per(Person.name)
    >>> select(distinct(Person.name, counts)).to_df()

    Incorrect vs correct usage for distinct rows:

    >>> # Incorrect: only one column wrapped; this is *not* a distinct-row select.
    >>> select(Person.name, distinct(Person.age))
    >>> # Correct: wrap the full selected row.
    >>> select(distinct(Person.name, Person.age))
    """
    model = _check_model()
    return model.distinct(*args)

@include_in_docs
def not_(*args:Statement) -> Not:
    """Return a negated condition for use in query filters.

    This is a convenience wrapper around :meth:`frontend.base.Model.not_` that operates on
    the single active model. You typically use :func:`not_` inside :func:`where` to say
    “exclude anything that matches this pattern”. For example, `not_(Person.pets)` can
    be read as “people with no pets”.

    If you pass multiple statements, they are treated as a single grouped
    condition and the *whole group* is negated. In other words,
    ``not_(a, b)`` means “NOT (a AND b)”, not “(NOT a) AND (NOT b)”. Note that
    ``NOT (a AND b)`` is equivalent to ``(NOT a) OR (NOT b)``.

    If you have more than one model, call
    :meth:`frontend.base.Model.not_` on the specific
    model instead.

    Parameters
    ----------
    *args : Statement
        One or more :data:`frontend.base.Statement` items to negate (for example,
        comparisons, relationship/property patterns, or other filter statements).

    Returns
    -------
    Not
        A negation expression.

    Raises
    ------
    relationalai.util.error.RAIException
        If there is no active model, or if multiple models exist and the active
        model would be ambiguous.

    Examples
    --------
    Filter to people who have no pets:

    >>> from relationalai.semantics import Model, String, define, where, not_
    >>> m = Model()
    >>> Person = m.Concept("Person")
    >>> Pet = m.Concept("Pet")
    >>> Person.pets = m.Relationship(f"{Person} has {Pet}")
    >>> Pet.name = m.Property(f"{Pet} is named {String:name}")
    >>> define(
    ...     alice := Person.new(name="Alice"),
    ...     bob := Person.new(name="Bob"),
    ...     boots := Pet.new(name="boots"),
    ...     bob.pets(boots),
    ... )
    >>> where(Person, not_(Person.pets)).select(Person.name).to_df()

    Exclude people who have a pet named "boots":

    >>> where(Person, not_(Person.pets.name == "boots")).select(Person.name).to_df()

    Contrast grouped vs separate negation:

    >>> # Grouped negation means NOT (A AND B). This excludes only people who
    >>> # have a pet AND have a pet named "boots".
    >>> where(Person, not_(Person.pets, Person.pets.name == "boots"))

    >>> # Separate negation means (NOT A) AND (NOT B). This is stricter.
    >>> # Here it keeps only people who have no pets (and therefore also do not
    >>> # have a pet named "boots").
    >>> where(Person, not_(Person.pets), not_(Person.pets.name == "boots"))
    """
    model = _check_model()
    return model.not_(*args)

#------------------------------------------------------
# Convenience aggregates
# NOTE: these should be deprecated
#------------------------------------------------------
# Import after convenience functions are defined to avoid circular imports
from .std import aggregates as aggs  # noqa: E402

rank = aggs.rank
limit = aggs.limit
asc = aggs.asc
desc = aggs.desc
count = aggs.count
sum = aggs.sum
min = aggs.min
max = aggs.max
avg = aggs.avg
string_join = aggs.string_join
per = aggs.per

#------------------------------------------------------
# Exports
#------------------------------------------------------

__all__ = [
    "Alias", "CoreLibrary", "Concept", "Data", "Distinct", "Expression", "Field", "FieldRef", "Fragment",
    "Library", "Literal", "Match", "Model", "New", "Not", "Chain", "Property", "Reading", "Relationship",
    "Table", "Union", "Value", "Variable", "core", "Error",
    "Any", "AnyEntity",
    "Number", "String", "Integer", "Int", "Int64", "Int128", "Float", "Decimal", "Bool", "Boolean", "Date", "DateTime",

    "rank", "limit", "asc", "desc",
    "count", "sum", "min", "max", "avg", "string_join", "per",

    "select", "define", "where", "require", "union", "data", "not_",
]
