"""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) -> 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.

    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)

@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_",
]
