abstract type AbstractTerm end
const TermOrTerms = Union{AbstractTerm, Tuple{AbstractTerm, Vararg{AbstractTerm}}}
const TupleTerm = Tuple{TermOrTerms, Vararg{TermOrTerms}}

Base.broadcastable(x::AbstractTerm) = Ref(x)

width(::T) where {T<:AbstractTerm} =
    throw(ArgumentError("terms of type $T have undefined width"))

"""
    Term <: AbstractTerm

A placeholder for a variable in a formula where the type (and necessary data
invariants) is not yet known.  This will be converted to a
[`ContinuousTerm`](@ref) or [`CategoricalTerm`](@ref) by [`apply_schema`](@ref).

# Fields

* `sym::Symbol`: The name of the data column this term refers to.
"""
struct Term <: AbstractTerm
    sym::Symbol
end
width(::Term) =
    throw(ArgumentError("Un-typed Terms have undefined width.  " *
                        "Did you forget to call apply_schema?"))

"""
    ConstantTerm{T<:Number} <: AbstractTerm

Represents a literal number in a formula.  By default will be converted to
[`InterceptTerm`] by [`apply_schema`](@ref).

# Fields

* `n::T`: The number represented by this term.
"""
struct ConstantTerm{T<:Number} <: AbstractTerm
    n::T
end
width(::ConstantTerm) = 1

"""
    FormulaTerm{L,R} <: AbstractTerm

Represents an entire formula, with a left- and right-hand side.  These can be of
any type (captured by the type parameters).

# Fields

* `lhs::L`: The left-hand side (e.g., response)
* `rhs::R`: The right-hand side (e.g., predictors)
"""
struct FormulaTerm{L,R} <: AbstractTerm
    lhs::L
    rhs::R
end

"""
    FunctionTerm{F,Args} <: AbstractTerm

Represents a call to a Julia function.  The first type parameter is the type of
the captured function (e.g., `typeof(log)`), and the second is the type of the
captured arguments (e.g., a `Vector` of `AbstractTerm`s).

Nested function calls are captured as further `FunctionTerm`s.

# Fields

* `f::F`: the captured function (e.g., `log`)
* `args::Args`: the arguments of the call passed to `@formula`, each captured as
  an `AbstractTerm`.  Usually this is a `Vector{<:AbstractTerm}`.
* `exorig::Expr`: the original expression passed to `@formula`

# Type parameters

* `F`: the type of the captured function (e.g., `typeof(log)`)
* `Args`: the type of container of captured arguments.

# Example

```jldoctest
julia> f = @formula(y ~ log(1 + a + b))
FormulaTerm
Response:
  y(unknown)
Predictors:
  (a,b)->log(1 + a + b)

julia> typeof(f.rhs)
FunctionTerm{typeof(log), Vector{FunctionTerm{typeof(+), Vector{AbstractTerm}}}}

julia> typeof(only(f.rhs.args))
FunctionTerm{typeof(+), Vector{AbstractTerm}}

julia> only(f.rhs.args).args
3-element Vector{AbstractTerm}:
 1
 a(unknown)
 b(unknown)

julia> f.rhs.f(1 + 3 + 4)
2.0794415416798357

julia> modelcols(f.rhs, (a=3, b=4))
2.0794415416798357

julia> modelcols(f.rhs, (a=[3, 4], b=[4, 5]))
2-element Vector{Float64}:
 2.0794415416798357
 2.302585092994046
```
"""
struct FunctionTerm{F,Args} <: AbstractTerm
    f::F
    args::Args
    exorig::Expr
end
width(::FunctionTerm) = 1

Base.:(==)(a::FunctionTerm, b::FunctionTerm) = a.f == b.f && a.args == b.args && a.exorig == b.exorig

"""
    InteractionTerm{Ts} <: AbstractTerm

Represents an _interaction_ between two or more individual terms.

Generated by combining multiple `AbstractTerm`s with `&` (which is what calls to
`&` in a `@formula` lower to)

# Fields

* `terms::Ts`: the terms that participate in the interaction.

# Example

```jldoctest
julia> using StableRNGs; rng = StableRNG(1);

julia> d = (y = rand(rng, 9), a = 1:9, b = rand(rng, 9), c = repeat(["d","e","f"], 3));

julia> t = InteractionTerm(term.((:a, :b, :c)))
a(unknown) & b(unknown) & c(unknown)

julia> t == term(:a) & term(:b) & term(:c)
true

julia> t = apply_schema(t, schema(d))
a(continuous) & b(continuous) & c(DummyCoding:3→2)

julia> modelcols(t, d)
9×2 Matrix{Float64}:
 0.0       0.0
 1.88748   0.0
 0.0       1.33701
 0.0       0.0
 0.725357  0.0
 0.0       0.126744
 0.0       0.0
 4.93994   0.0
 0.0       4.33378

julia> modelcols(t.terms, d)
([1, 2, 3, 4, 5, 6, 7, 8, 9], [0.236781883208121, 0.9437409715735081, 0.4456708824294644, 0.7636794266904741, 0.14507148958283067, 0.021124039581375875, 0.15254507694061115, 0.617492416565387, 0.48153065407402607], [0.0 0.0; 1.0 0.0; … ; 1.0 0.0; 0.0 1.0])
```
"""
struct InteractionTerm{Ts} <: AbstractTerm
    terms::Ts
end
width(ts::InteractionTerm) = prod(width(t) for t in ts.terms)

"""
    InterceptTerm{HasIntercept} <: AbstractTerm

Represents the presence or (explicit) absence of an "intercept" term in a
regression model.  These terms are generated from [`ConstantTerm`](@ref)s in a
formula by `apply_schema(::ConstantTerm, schema, ::Type{<:StatisticalModel})`.
A `1` yields `InterceptTerm{true}`, and `0` or `-1` yield `InterceptTerm{false}`
(which explicitly omits an intercept for models which implicitly includes one
via the [`implicit_intercept`](@ref) trait).
"""
struct InterceptTerm{HasIntercept} <: AbstractTerm end
width(::InterceptTerm{H}) where {H} = H ? 1 : 0

# Typed terms

"""
    ContinuousTerm <: AbstractTerm

Represents a continuous variable, with a name and summary statistics.

# Fields

* `sym::Symbol`: The name of the variable
* `mean::T`: Mean
* `var::T`: Variance
* `min::T`: Minimum value
* `max::T`: Maximum value
"""
struct ContinuousTerm{T} <: AbstractTerm
    sym::Symbol
    mean::T
    var::T
    min::T
    max::T
end
width(::ContinuousTerm) = 1

"""
    CategoricalTerm{C,T,N} <: AbstractTerm

Represents a categorical term, with a name and [`ContrastsMatrix`](@ref)

# Fields

* `sym::Symbol`: The name of the variable
* `contrasts::ContrastsMatrix`: A contrasts matrix that captures the unique
  values this variable takes on and how they are mapped onto numerical
  predictors.
"""
struct CategoricalTerm{C,T,N} <: AbstractTerm
    sym::Symbol
    contrasts::ContrastsMatrix{C,T}
end
width(::CategoricalTerm{C,T,N}) where {C,T,N} = N

# constructor that computes the width based on the contrasts matrix
CategoricalTerm(sym::Symbol, contrasts::ContrastsMatrix{C,T}) where {C,T} =
    CategoricalTerm{C,T,length(contrasts.coefnames)}(sym, contrasts)

"""
    MatrixTerm{Ts} <: AbstractTerm

A collection of terms that should be combined to produce a single numeric matrix.

A matrix term is created by [`apply_schema`](@ref) from a tuple of terms using
[`collect_matrix_terms`](@ref), which pulls out all the terms that are matrix
terms as determined by the trait function [`is_matrix_term`](@ref), which is
true by default for all `AbstractTerm`s.
"""
struct MatrixTerm{Ts<:TupleTerm} <: AbstractTerm
    terms::Ts
end
# wrap single terms in a tuple
MatrixTerm(t::AbstractTerm) = MatrixTerm((t, ))
width(t::MatrixTerm) = sum(width(tt) for tt in t.terms)

"""
    collect_matrix_terms(ts::TupleTerm)
    collect_matrix_terms(t::AbstractTerm) = collect_matrix_term((t, ))

Depending on whether the component terms are matrix terms (meaning they have
[`is_matrix_term(T) == true`](@ref is_matrix_term)), `collect_matrix_terms` will
return

1.  A single `MatrixTerm` (if all components are matrix terms)
2.  A tuple of the components (if none of them are matrix terms)
3.  A tuple of terms, with all matrix terms collected into a single `MatrixTerm`
    in the first element of the tuple, and the remaining non-matrix terms passed
    through unchanged.

By default all terms are matrix terms (that is,
`is_matrix_term(::Type{<:AbstractTerm}) = true`), the first case is by far the
most common.  The others are provided only for convenience when dealing with
specialized terms that can't be concatenated into a single model matrix, like
random effects terms in
[MixedModels.jl](https://github.com/dmbates/MixedModels.jl).

"""
function collect_matrix_terms(ts::TupleTerm)
    ismat = collect(is_matrix_term.(ts))
    if all(ismat)
        MatrixTerm(ts)
    elseif any(ismat)
        matterms = ts[ismat]
        (MatrixTerm(ts[ismat]), ts[.!ismat]...)
    else
        ts
    end
end
collect_matrix_terms(t::T) where {T<:AbstractTerm} =
    is_matrix_term(T) ? MatrixTerm((t, )) : t
collect_matrix_terms(t::MatrixTerm) = t


"""
    is_matrix_term(::Type{<:AbstractTerm})

Does this type of term get concatenated with other matrix terms into a single
model matrix?  This controls the behavior of the [`collect_matrix_terms`](@ref),
which collects all of its arguments for which `is_matrix_term` returns `true`
into a [`MatrixTerm`](@ref), and returns the rest unchanged.

Since all "normal" terms which describe one or more model matrix columns are
matrix terms, this defaults to `true` for any `AbstractTerm`.

An example of a non-matrix term is a random effect term in
[MixedModels.jl](https://github.com/dmbates/MixedModels.jl).
"""
is_matrix_term(::T) where {T} = is_matrix_term(T)
is_matrix_term(::Type{<:AbstractTerm}) = true


################################################################################
# showing terms

function Base.show(io::IO, mime::MIME"text/plain", term::AbstractTerm; prefix="")
    print(io, prefix, term)
end

function Base.show(io::IO, mime::MIME"text/plain", terms::TupleTerm; prefix=nothing)
    for t in terms
        show(io, mime, t; prefix=something(prefix, ""))
        # ensure that there are newlines in between each term after the first
        # if no prefix is specified
        prefix = something(prefix, '\n')
    end
end
Base.show(io::IO, terms::TupleTerm) = join(io, terms, " + ")

Base.show(io::IO, ::MIME"text/plain", t::Term; prefix="") =
    print(io, prefix, t.sym, "(unknown)")
Base.show(io::IO, t::Term) = print(io, t.sym)

Base.show(io::IO, t::ConstantTerm) = print(io, t.n)

Base.show(io::IO, t::FormulaTerm) = print(io, "$(t.lhs) ~ $(t.rhs)")
function Base.show(io::IO, mime::MIME"text/plain", t::FormulaTerm; prefix="")
    println(io, "FormulaTerm")
    print(io, "Response:")
    show(io, mime, t.lhs, prefix="\n  ")
    println(io)
    print(io, "Predictors:")
    show(io, mime, t.rhs, prefix="\n  ")
end

Base.show(io::IO, t::FunctionTerm) = print(io, ":($(t.exorig))")
function Base.show(io::IO, ::MIME"text/plain",
                   t::FunctionTerm;
                   prefix = "")
    print(io, prefix, "(")
    join(io, termvars(t), ",")
    print(io, ")->", t.exorig)
end

Base.show(io::IO, it::InteractionTerm) = join(io, it.terms, " & ")
function Base.show(io::IO, mime::MIME"text/plain", it::InteractionTerm; prefix="")
    for t in it.terms
        show(io, mime, t; prefix=prefix)
        prefix = " & "
    end
end

Base.show(io::IO, t::InterceptTerm{H}) where {H} = print(io, H ? "1" : "0")

Base.show(io::IO, t::ContinuousTerm) = print(io, t.sym)
Base.show(io::IO, ::MIME"text/plain", t::ContinuousTerm; prefix="") =
    print(io, prefix, t.sym, "(continuous)")

Base.show(io::IO, t::CategoricalTerm{C,T,N}) where {C,T,N} = print(io, t.sym)
Base.show(io::IO, ::MIME"text/plain", t::CategoricalTerm{C,T,N}; prefix="") where {C,T,N} =
    print(io, prefix, t.sym, "($C:$(length(t.contrasts.levels))→$N)")

Base.show(io::IO, t::MatrixTerm) = show(io, t.terms)
Base.show(io::IO, mime::MIME"text/plain", t::MatrixTerm; prefix="") =
    show(io, mime, t.terms, prefix=prefix)

################################################################################
# operators on Terms that create new terms:

Base.:~(lhs::TermOrTerms, rhs::TermOrTerms) = FormulaTerm(lhs, cleanup(rhs))

Base.:&(term::AbstractTerm) = term
Base.:&(a::AbstractTerm, b::AbstractTerm) = InteractionTerm((a,b))

function validate_interaction(t::ConstantTerm)
    t.n == 1 ||
        throw(ArgumentError("only allowed constants in interaction terms are 1, got $(t.n)"))
    return t
end
Base.:&(a::ConstantTerm, b::AbstractTerm) = (validate_interaction(a); b)
Base.:&(a::AbstractTerm, b::ConstantTerm) = (validate_interaction(b); a)

# Avoid method ambiguities
Base.:&(a::ConstantTerm, b::InteractionTerm) = (validate_interaction(a); b)
Base.:&(a::InteractionTerm, b::ConstantTerm) = (validate_interaction(b); a)
Base.:&(a::ConstantTerm, b::ConstantTerm) = (validate_interaction(a); validate_interaction(b); a)

# associative rule
Base.:&(it::InteractionTerm, term::AbstractTerm) =
    term in it.terms ? it : InteractionTerm((it.terms..., term))
Base.:&(term::AbstractTerm, it::InteractionTerm) =
    term in it.terms ? it : InteractionTerm((term, it.terms...))
Base.:&(a::InteractionTerm, b::InteractionTerm) =
    InteractionTerm((union(a.terms, b.terms)..., ))

# distributive rule
Base.:&(term::AbstractTerm, terms::TupleTerm) = term .& terms
Base.:&(terms::TupleTerm, term::AbstractTerm) = terms .& term
Base.:&(as::TupleTerm, bs::TupleTerm) = ((a & b for a in as for b in bs)..., )

# + concatenates terms
Base.:+(a::AbstractTerm) = a
Base.:+(a::AbstractTerm, b::AbstractTerm) = a==b ? a : (a, b)

# associative rule for +
Base.:+(as::TupleTerm, b::AbstractTerm) = b in as ? as : (as..., b)
Base.:+(a::AbstractTerm, bs::TupleTerm) = a in bs ? bs : (a, bs...)
Base.:+(as::TupleTerm, bs::TupleTerm) = (union(as, bs)..., )

# * expansion
Base.:*(a::TermOrTerms, b::TermOrTerms) = a + b + a&b

cleanup(terms::TupleTerm) = Tuple(sort!(unique!(collect(terms)), by=degree))
cleanup(x) = x

degree(::ConstantTerm) = 0
degree(::InterceptTerm) = 0
degree(::AbstractTerm) = 1
degree(t::InteractionTerm) = mapreduce(degree, +, t.terms)
# dirty hack, move to MixedModels.jl
degree(::FunctionTerm{typeof(|)}) = Inf

################################################################################
# evaluating terms with data to generate model matrix entries

"""
    modelcols(t::AbstractTerm, data)

Create a numerical "model columns" representation of data based on an
`AbstractTerm`.  `data` can either be a whole table (a property-accessible
collection of iterable columns or iterable collection of property-accessible
rows, as defined by [Tables.jl](https://github.com/JuliaData/Tables.jl) or a
single row (in the form of a `NamedTuple` of scalar values).  Tables will be
converted to a `NamedTuple` of `Vectors` (e.g., a `Tables.ColumnTable`).
"""
function modelcols(t, d::D) where D
    Tables.istable(d) || throw(ArgumentError("Data of type $D is not a table!"))
    ## throw an error for t which don't have a more specific modelcols method defined
    ## TODO: this seems like it ought to be handled by dispatching on something
    ## like modelcols(::Any, ::NamedTuple) or modelcols(::AbstractTerm, ::NamedTuple)
    ## but that causes ambiguity errors or under-constrained modelcols methods for
    ## custom term types...
    d isa NamedTuple && throw(ArgumentError("don't know how to generate modelcols for " *
                                            "term $t. Did you forget to call apply_schema?"))
    modelcols(t, columntable(d))
end

"""
    modelcols(ts::NTuple{N, AbstractTerm}, data) where N

When a tuple of terms is provided, `modelcols` broadcasts over the individual
terms.  To create a single matrix, wrap the tuple in a [`MatrixTerm`](@ref).

# Example

```jldoctest
julia> using StableRNGs; rng = StableRNG(1);

julia> d = (a = [1:9;], b = rand(rng, 9), c = repeat(["d","e","f"], 3));

julia> ts = apply_schema(term.((:a, :b, :c)), schema(d))
a(continuous)
b(continuous)
c(DummyCoding:3→2)

julia> cols = modelcols(ts, d)
([1, 2, 3, 4, 5, 6, 7, 8, 9], [0.5851946422124186, 0.07733793456911231, 0.7166282400543453, 0.3203570514066232, 0.6530930076222579, 0.2366391513734556, 0.7096838914472361, 0.5577872440804086, 0.05079002172175784], [0.0 0.0; 1.0 0.0; … ; 1.0 0.0; 0.0 1.0])

julia> reduce(hcat, cols)
9×4 Matrix{Float64}:
 1.0  0.585195   0.0  0.0
 2.0  0.0773379  1.0  0.0
 3.0  0.716628   0.0  1.0
 4.0  0.320357   0.0  0.0
 5.0  0.653093   1.0  0.0
 6.0  0.236639   0.0  1.0
 7.0  0.709684   0.0  0.0
 8.0  0.557787   1.0  0.0
 9.0  0.05079    0.0  1.0

julia> modelcols(MatrixTerm(ts), d)
9×4 Matrix{Float64}:
 1.0  0.585195   0.0  0.0
 2.0  0.0773379  1.0  0.0
 3.0  0.716628   0.0  1.0
 4.0  0.320357   0.0  0.0
 5.0  0.653093   1.0  0.0
 6.0  0.236639   0.0  1.0
 7.0  0.709684   0.0  0.0
 8.0  0.557787   1.0  0.0
 9.0  0.05079    0.0  1.0
```
"""
modelcols(ts::TupleTerm, d::NamedTuple) = modelcols.(ts, Ref(d))

modelcols(t::Term, d::NamedTuple) = getproperty(d, t.sym)
modelcols(t::ConstantTerm, d::NamedTuple) = t.n

modelcols(ft::FunctionTerm, d::NamedTuple) =
    Base.Broadcast.materialize(lazy_modelcols(ft, d))

lazy_modelcols(ft::FunctionTerm, d::NamedTuple) =
    Base.Broadcast.broadcasted(ft.f, lazy_modelcols.(ft.args, Ref(d))...)
lazy_modelcols(x, d) = modelcols(x, d)


modelcols(t::ContinuousTerm, d::NamedTuple) = copy.(d[t.sym])

modelcols(t::CategoricalTerm, d::NamedTuple) = t.contrasts[d[t.sym], :]


"""
    reshape_last_to_i(i::Int, a)

Reshape `a` so that its last dimension moves to dimension `i` (+1 if `a` is an
`AbstractMatrix`).
"""
reshape_last_to_i(i, a) = a
reshape_last_to_i(i, a::AbstractVector) = reshape(a, ones(Int, i-1)..., :)
reshape_last_to_i(i, a::AbstractMatrix) = reshape(a, size(a,1), ones(Int, i-1)..., :)

# an "inside out" kronecker-like product based on broadcasting reshaped arrays
# for a single row, some will be scalars, others possibly vectors.  for a whole
# table, some will be vectors, possibly some matrices
function kron_insideout(op::Function, args...)
    args = (reshape_last_to_i(i,a) for (i,a) in enumerate(args))
    out = broadcast(op, args...)
    # flatten array output to vector
    out isa AbstractArray ? vec(out) : out
end

function row_kron_insideout(op::Function, args...)
    rows = size(args[1], 1)
    args = (reshape_last_to_i(i,reshape(a, size(a,1), :)) for (i,a) in enumerate(args))
    # args = (reshape(a, size(a,1), ones(Int, i-1)..., :) for (i,a) in enumerate(args))
    reshape(broadcast(op, args...), rows, :)
end

# two options here: either special-case ColumnTable (named tuple of vectors)
# vs. vanilla NamedTuple, or reshape and use normal broadcasting
modelcols(t::InteractionTerm, d::NamedTuple) =
    kron_insideout(*, (modelcols(term, d) for term in t.terms)...)

function modelcols(t::InteractionTerm, d::ColumnTable)
    row_kron_insideout(*, (modelcols(term, d) for term in t.terms)...)
end

modelcols(t::InterceptTerm{true}, d::NamedTuple) = ones(size(first(d)))
modelcols(t::InterceptTerm{false}, d) = Matrix{Float64}(undef, size(first(d),1), 0)

modelcols(t::FormulaTerm, d::NamedTuple) = (modelcols(t.lhs,d), modelcols(t.rhs, d))

function modelcols(t::MatrixTerm, d::ColumnTable)
    mat = reduce(hcat, [modelcols(tt, d) for tt in t.terms])
    reshape(mat, size(mat, 1), :)
end

modelcols(t::MatrixTerm, d::NamedTuple) =
    reduce(vcat, [modelcols(tt, d) for tt in t.terms])

vectorize(x::Tuple) = collect(x)
vectorize(x::AbstractVector) = x
vectorize(x) = [x]

"""
    coefnames(term::AbstractTerm)

Return the name(s) of column(s) generated by a term.  Return value is either a
`String` or an iterable of `String`s.

See also [`termnames`](@ref).
"""
StatsAPI.coefnames(t::FormulaTerm) = (coefnames(t.lhs), coefnames(t.rhs))
StatsAPI.coefnames(::InterceptTerm{H}) where {H} = H ? ["(Intercept)"] : String[]
StatsAPI.coefnames(t::ContinuousTerm) = string(t.sym)
StatsAPI.coefnames(t::CategoricalTerm) =
    ["$(t.sym): $name" for name in t.contrasts.coefnames]
StatsAPI.coefnames(t::FunctionTerm) = string(t.exorig)
StatsAPI.coefnames(ts::TupleTerm) = reduce(vcat, coefnames.(ts))
StatsAPI.coefnames(t::MatrixTerm) = mapreduce(coefnames, vcat, t.terms; init = String[])
StatsAPI.coefnames(t::InteractionTerm) =
    kron_insideout((args...) -> join(args, " & "), vectorize.(coefnames.(t.terms))...)

"""
    termnames(model::StatisticalModel)

Return the names of terms used in the formula of `model`.

This is a convenience method for `termnames(formula(model))`, which returns a
two-tuple of `termnames` applied to the left and right hand sides of the formula.

For `RegressionModel`s with only continuous predictors, this is the same as
`(responsename(model), coefnames(model))` and `coefnames(formula(model))`.

For models with categorical predictors, the returned names reflect
the variable name and not the coefficients resulting from
the choice of contrast coding.

See also [`coefnames`](@ref).
"""
termnames(model::StatisticalModel) = termnames(formula(model))

"""
    termnames(t::FormulaTerm)

Return a two-tuple of `termnames` applied to the left and
right hand sides of the formula.

!!! note
    Until `apply_schema` has been called, literal `1` in formulae
    is interpreted as `ConstantTerm(1)` and will thus appear as `"1"` in the
    returned term names.

```jldoctest
julia> termnames(@formula(y ~ 1 + x * y + (1+x|g)))
("y", ["1", "x", "y", "x & y", "(1 + x) | g"])
```

Similarly, formulae with an implicit intercept will not have a `"1"`
in their variable names, because the implicit intercept does not exist until
`apply_schema` is called (and may not exist for certain model contexts).

```jldoctest
julia> termnames(@formula(y ~ x * y + (1+x|g)))
("y", ["x", "y", "x & y", "(1 + x) | g"])
```
"""
termnames(t::FormulaTerm) = (termnames(t.lhs), termnames(t.rhs))

"""
    termnames(term::AbstractTerm)

Return the name of the statistical variable associated with a term.

Return value is either a `String`, an iterable of `String`s or the empty vector 
if there is no associated variable (e.g. `termnames(InterceptTerm{false}())`).
"""
termnames(::InterceptTerm{H}) where {H} = H ? "(Intercept)" : String[]
termnames(t::ContinuousTerm) = string(t.sym)
termnames(t::CategoricalTerm) = string(t.sym)
termnames(t::Term) = string(t.sym)
termnames(t::ConstantTerm) = string(t.n)
termnames(t::FunctionTerm) = string(t.exorig)
# termnames(TupleTerm)) always returns a vector, even if it's just one element, e.g.,
# termnames((term(:a),))
termnames(ts::TupleTerm) = mapreduce(termnames, vcat, ts; init=String[])
# termnames(MatrixTerm)) always returns a vector, even if it's just one element, e.g.,
# termnames(MatrixTerm(term(:a)))
termnames(t::MatrixTerm) = mapreduce(termnames, vcat, t.terms; init=String[])
termnames(t::InteractionTerm) =
    only(kron_insideout((args...) -> join(args, " & "), vectorize.(termnames.(t.terms))...))

################################################################################
# old Terms features:

hasintercept(f::FormulaTerm) = hasintercept(f.rhs)
hasintercept(t::AbstractTerm) = t == InterceptTerm{true}() || t == ConstantTerm(1)
hasintercept(t::TupleTerm) = any(hasintercept, t)
hasintercept(t::MatrixTerm) = hasintercept(t.terms)

omitsintercept(f::FormulaTerm) = omitsintercept(f.rhs)
omitsintercept(t::AbstractTerm) =
    t == InterceptTerm{false}() ||
    t == ConstantTerm(0) ||
    t == ConstantTerm(-1)
omitsintercept(t::TupleTerm) = any(omitsintercept, t)
omitsintercept(t::MatrixTerm) = omitsintercept(t.terms)

hasresponse(t) = false
hasresponse(t::FormulaTerm) =
    t.lhs !== nothing &&
    t.lhs !== ConstantTerm(0) &&
    t.lhs !== InterceptTerm{false}()

# convenience converters
"""
    term(x)

Wrap argument in an appropriate `AbstractTerm` type: `Symbol`s and `AbstractString`s become `Term`s,
and `Number`s become `ConstantTerm`s.  Any `AbstractTerm`s are unchanged. `AbstractString`s
are converted to symbols before wrapping.

# Example

```jldoctest
julia> ts = term.((1, :a, "b"))
1
a(unknown)
b(unknown)

julia> typeof(ts)
Tuple{ConstantTerm{Int64}, Term, Term}
```
"""
term(n::Number) = ConstantTerm(n)
term(s::Symbol) = Term(s)
term(s::AbstractString) = term(Symbol(s))
term(t::AbstractTerm) = t
