<?php

declare(strict_types=1);

namespace apexl\Io\interfaces;

/**
 * The canonical tenancy seam for the whole Cygnus estate.
 *
 * A vendor package that must know "which tenant is this request?" cannot
 * hard-depend on any one project's tenancy primitive. This interface is the
 * lingua franca every {@code apexl/io-*} package resolves against: the package
 * depends only on {@code apexl/io} (which it already does) and never on
 * {@code apexl/io-tenancy}. A project that adopts {@code io-tenancy} binds its
 * {@code TenantContext} (which implements this interface); a project not yet on
 * {@code io-tenancy} binds a trivial single-tenant stub, or falls back to
 * {@see \apexl\Io\services\NullTenantResolver}.
 *
 * Generalised from io-branding 1.2.0's package-local {@code TenantResolverInterface}
 * (which exposed only {@see self::currentTenantId()}); promoted into core so the
 * N candidate packages share one resolver instead of defining N incompatible
 * copies, each needing its own project binding.
 *
 * ---
 * DUAL ISOLATION — which axis a package reads is a package-level decision.
 *
 * The estate runs two isolation models simultaneously and this one object
 * surfaces both:
 *
 *  - Row-scope: a {@code tenant_id} column on every row in one shared database.
 *    Read via {@see self::currentTenantId()}.
 *  - Connection-swap: one database per tenant, no tenant awareness in the rows.
 *    Read via {@see self::activeConnection()}.
 *
 * A shared package that requires a host → tenant default cascade (branding,
 * seeded engagement-notification defaults, template libraries) MUST be
 * row-scope-by-design: it reads {@see self::currentTenantId()} and ignores
 * {@see self::activeConnection()}, and MUST document that it does so — even in a
 * connection-swap project. The cascade cannot exist inside a swapped per-tenant
 * database, because once you are inside the tenant's own DB there is no host row
 * to cascade from. A pure per-tenant-data package with no cascade need (leads,
 * servers, subscriptions) MAY be deployed either way and reads whichever axis
 * the host project wired.
 *
 * ---
 * HOST-BINDING CONSUMPTION PATTERN.
 *
 * Each consuming package binds this interface through a factory that defers to a
 * host-overridable container key with a {@see \apexl\Io\services\NullTenantResolver}
 * fallback — never a direct {@code TenantResolverInterface::class} binding. Host
 * module {@code services.php} files merge BEFORE vendor ones (last key wins), so
 * a plain interface binding in the vendor would silently shadow any host
 * override. Instead the vendor binds the interface to a factory that reads a
 * package-namespaced key the vendor never itself defines, mirroring io-branding
 * 1.2.0's {@code 'branding.tenant_resolver'}:
 *
 * <code>
 * TenantResolverInterface::class => factory(
 *     static fn (\Psr\Container\ContainerInterface $c): TenantResolverInterface
 *         => $c->has('mypackage.tenant_resolver')          // host binding key
 *             ? $c->get('mypackage.tenant_resolver')
 *             : new \apexl\Io\services\NullTenantResolver() // host/platform scope
 * ),
 * </code>
 *
 * A single-tenant install binds nothing and transparently gets the null
 * resolver, so adoption is fully backwards-compatible; a multi-tenant host
 * defines its own key and every consumer becomes per-tenant with no further
 * change.
 *
 * ---
 * ⚠ PHP-DI DEFAULT-PARAM GOTCHA — read before wiring any consumer.
 *
 * PHP-DI honours a constructor parameter's DEFAULT VALUE over a container
 * binding. A seam parameter declared {@code TenantResolverInterface $resolver = null}
 * will resolve to {@code null} even when a binding exists — a silent failure
 * that already bit Musubi once. EVERY seam consumer MUST declare the parameter
 * with NO default value ({@code TenantResolverInterface $resolver}, full stop),
 * so the container is forced to inject the bound implementation.
 *
 * This interface is deliberately tiny (three methods, no dependencies) and, per
 * the tenancy programme, is additive-only after v1: never break it — extend via
 * new interfaces.
 */
interface TenantResolverInterface
{
    /**
     * The acting tenant id for the current context (row-scope axis), or null.
     *
     * Null is always a valid answer: unresolved requests, cron/webhook/CLI
     * system contexts, single-tenant installs, and platform operators/staff all
     * resolve to the host (platform-global) scope — so, crucially, a platform
     * operator edits the host defaults rather than any one tenant's rows.
     */
    public function currentTenantId(): ?int;

    /**
     * The named database connection for the current tenant (connection-swap
     * axis), or null for the default connection.
     *
     * Null means "use the host's default connection": single-tenant installs,
     * pure row-scope deployments, and system/unresolved contexts all report
     * null. Only a connection-swap deployment returns a non-null named
     * connection to swap onto for the current request.
     */
    public function activeConnection(): ?string;

    /**
     * True when acting as the system principal (cron, webhook, job, CLI) rather
     * than on behalf of a resolved tenant or session user.
     *
     * The system principal is the escape hatch for machine-driven work that is
     * legitimately unscoped; it is distinct from an unresolved request that
     * merely failed to determine a tenant.
     */
    public function isSystem(): bool;
}
