# Released under the MIT License. See LICENSE for details.
#
"""Public types for assets-v1 workspaces.

While this module is currently only used server-side, its source code
can be useful as reference when setting workspace config data by hand or
for use in client-side workspace modification tools. There may be
advanced settings that are not accessible through the UI/etc.
"""

# This is the hand-written schema module for the whole assets-v1
# workspace format -- one cohesive set of types that callers import
# together -- so it legitimately runs long. (Not an _implN spill; there
# is nothing to split out to.)
# pylint: disable=too-many-lines

import datetime
from enum import Enum
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Annotated, override, assert_never

from efro.dataclassio import ioprepped, IOAttrs, IOMultiType
from bacommon.locale import Locale
from bacommon.langstr import WrapParams
from bacommon.loctext import StringSelector

if TYPE_CHECKING:
    pass


class WrapperType(Enum):
    """Python wrapper-module flavor for an asset-package version.

    Selects which feature-set's loader API the generated wrapper
    delegates to. Members today correspond 1:1 with feature-sets, but
    the type is deliberately named ``WrapperType`` (not
    ``WrapperFeatureset``) to leave room for non-featureset-shaped
    variants (e.g. tooling-only or future loader APIs) without a
    rename.
    """

    BASCENEV1 = 'bascenev1'
    BAUIV1 = 'bauiv1'

    #: Strings-and-sounds wrapper for the babase layer. String leaves
    #: emit the same native ``babase.LangStr`` accessors as the
    #: featureset forms; sound leaves emit ``SimpleSoundHandle``, whose
    #: ``.get()`` loads a ``babase.SimpleSound`` -- the one classic
    #: asset loader API babase has (there is no babase texture or mesh
    #: equivalent, so those kinds are skipped). Exists so
    #: pre-featureset machinery (e.g. construct-mode's bring-up UI, and
    #: the app/plugin/account paths that play ui feedback sounds before
    #: any feature-set is up) can consume package strings and sounds.
    BABASE = 'babase'


class ConventionsMode(Enum):
    """Conventions-check enforcement level for an assets_v1 workspace.

    ``STRICT`` blocks test/prod publishes while conventions findings
    exist (dev-track resolves are never gated); ``RELAXED`` (the
    default) surfaces findings as informational hints only.
    """

    RELAXED = 'relaxed'
    STRICT = 'strict'


class PackageResolveAccess(Enum):
    """Who may resolve a package's *prod* versions.

    Orthogonal to the version track: a track says how released a
    version is, this says who may have it. ``PRIVATE`` applies the same
    owner-or-dev-team check that dev/test versions always get, so
    resolve access only ever *adds* restriction to prod -- it can never
    make a dev/test version public.

    ``PUBLIC`` is the default and what every package had before this
    existed. Exists for packages nothing fetches at runtime -- content
    the master evaluates itself, or build-time sources embedded into
    other packages -- not as a general publishing control.

    Gates the *resolve*, not the content. A private package's entries
    can still reach clients verbatim -- formatter components are
    embedded by value into each consuming package's language blobs at
    build time -- so this says "nothing fetches this package at
    runtime", never "these strings are secret".
    """

    PUBLIC = 'public'
    PRIVATE = 'private'


class PackageSourceSharing(Enum):
    """Who can start a new workspace from a package's source.

    Governs *source* availability only -- who may copy the exporting
    workspace's snapshot as the starting point for a workspace of their
    own. It says nothing about who can *use* the published assets;
    that's the track plus :class:`PackageResolveAccess`.

    Package-wide (not per-version): sharing intent belongs to the
    package, and a per-version value meant every republish silently
    reset it.
    """

    PRIVATE = 'private'
    DEV_TEAM_ONLY = 'devteam'
    PUBLIC = 'public'

    @property
    def pretty(self) -> str:
        """Human-facing display name (use in UIs; not wire values)."""
        cls = PackageSourceSharing
        if self is cls.PRIVATE:
            return 'Private'
        if self is cls.DEV_TEAM_ONLY:
            return 'Dev Team Only'
        if self is cls.PUBLIC:
            return 'Public'
        assert_never(self)


@ioprepped
@dataclass
class AssetsV1GlobalVals:
    """Global values for an assets_v1 workspace."""

    base_assets: Annotated[
        str | None, IOAttrs('base_assets', store_default=False)
    ] = None

    base_assets_filter: Annotated[
        str, IOAttrs('base_assets_filter', store_default=False)
    ] = ''

    #: Optional free-form workspace documentation, appended to the
    #: generated wrapper module's docstring (after the auto-generated
    #: summary line). Empty string means none.
    docs: Annotated[str, IOAttrs('docs', store_default=False)] = ''

    #: Dev-team id granting resolve access to this workspace's
    #: dev/test asset-package versions. None (unset) means owner-only
    #: access — matching the semantics of the asset-package doc's
    #: ``dev_team_id`` (see ``AssetPackage.account_has_access``).
    dev_team: Annotated[
        str | None, IOAttrs('dev_team', store_default=False)
    ] = None

    #: The asset-package name this workspace publishes under. None
    #: means it is derived from the workspace's display name (see
    #: :func:`derive_asset_package_name`); set explicitly to decouple
    #: the published name from the display name (e.g. to keep a
    #: package lineage across a workspace rename, or to have a new
    #: workspace take over publishing an existing package name).
    asset_package_name: Annotated[
        str | None, IOAttrs('asset_package_name', store_default=False)
    ] = None

    #: Conventions-check enforcement level (see
    #: :class:`ConventionsMode`). First-party workspaces set strict
    #: (see the asset-packages design doc). Set by hand in
    #: ``workspace.json`` -- deliberately not exposed in the UI.
    #: Unknown stored values fall back to relaxed so older servers
    #: never over-enforce.
    conventions: Annotated[
        ConventionsMode,
        IOAttrs(
            'conventions',
            store_default=False,
            enum_fallback=ConventionsMode.RELAXED,
        ),
    ] = ConventionsMode.RELAXED

    #: Who may resolve this package's *prod* versions (see
    #: :class:`PackageResolveAccess`). Set by hand in ``workspace.json``
    #: -- deliberately not exposed in the UI, same as ``conventions``,
    #: since its use is limited to server-side packages. Unknown stored
    #: values fall back to public: failing closed here would break asset
    #: resolves for every client, and private packages are private from
    #: birth and reached by their owner (who short-circuits before this
    #: is ever consulted).
    resolve_access: Annotated[
        PackageResolveAccess,
        IOAttrs(
            'resolve_access',
            store_default=False,
            enum_fallback=PackageResolveAccess.PUBLIC,
        ),
    ] = PackageResolveAccess.PUBLIC

    #: Who may start a workspace of their own from this package's source
    #: (see :class:`PackageSourceSharing`). Package-wide policy, resolved
    #: live -- it moved here from a per-version field in 2026-07-27,
    #: which republishing silently reset each time.
    source_sharing: Annotated[
        PackageSourceSharing,
        IOAttrs(
            'source_sharing',
            store_default=False,
            enum_fallback=PackageSourceSharing.PRIVATE,
        ),
    ] = PackageSourceSharing.PRIVATE


def derive_asset_package_name(workspace_name: str) -> str:
    """Derive a default asset-package name from a workspace name.

    Lowercases and strips spaces ('My Awesome Assets' ->
    'myawesomeassets'). The single source for this rule — publish
    paths, collision checks, and UI previews must all route through
    it. Note the result is not guaranteed to be a *valid*
    asset-package name (the workspace name may contain characters
    with no valid mapping); consumers validate at point of use.
    """
    return workspace_name.lower().replace(' ', '')


class AssetsV1StringFileTypeID(Enum):
    """Type ID for each of our subclasses."""

    V1 = 'v1'


class AssetsV1StringFile(IOMultiType[AssetsV1StringFileTypeID]):
    """Top level class for our multitype."""

    @override
    @classmethod
    def get_type_id_storage_name(cls) -> str:
        return 'string_file_version'

    @override
    @classmethod
    def get_type_id(cls) -> AssetsV1StringFileTypeID:
        # Require child classes to supply this themselves. If we did a
        # full type registry/lookup here it would require us to import
        # everything and would prevent lazy loading.
        raise NotImplementedError()

    @override
    @classmethod
    def get_type(
        cls, type_id: AssetsV1StringFileTypeID
    ) -> type[AssetsV1StringFile]:
        """Return the subclass for each of our type-ids."""
        # pylint: disable=cyclic-import

        t = AssetsV1StringFileTypeID
        if type_id is t.V1:
            return AssetsV1StringFileV1

        # Important to make sure we provide all types.
        assert_never(type_id)


@ioprepped
@dataclass
class AssetsV1StringFileV1(AssetsV1StringFile):
    """Our initial version of string file data."""

    class StylePreset(Enum):
        """Preset for general styling in translated strings."""

        NONE = 'none'
        TITLE = 'title'
        LOUD = 'loud'
        SOFT = 'soft'

    class TranslationEffort(Enum):
        """How much model effort a string's translations warrant.

        Deliberately describes *intent*, not a model or a vendor
        setting: the server maps these onto whatever (model, thinking
        level) pair is current, so retuning that never touches stored
        ``.bstr`` data or restales translations.

        ``AUTO`` is the right answer for nearly every string -- short
        UI labels translate identically at any effort. It runs cheap
        first and escalates on its own when the brief looks structurally
        hard or when a generated attempt fails validation. Reach for
        ``HIGH`` only for *semantic* subtlety no heuristic can see:
        wordplay, brand voice, a line whose tone has to land.
        """

        #: Server decides -- cheap by default, escalating when warranted.
        AUTO = 'auto'

        #: Always translate at maximum effort.
        HIGH = 'high'

    class LayoutPreset(Enum):
        """What kind of slot a string occupies, and how it may size.

        (Named ``FitPreset`` until 2026-07-27; the stored key stays
        ``fit_preset``. Renamed because the values describe the *slot*
        -- a narrow tab, a standard button, a body paragraph -- and only
        some of them are a size constraint at all.)

        Mirrors ``StylePreset``: passed to the translator with UI
        context, so localized output respects both the space available
        and the register the slot implies. The CHARS_* budgets are
        display-width in *Latin* characters -- wide-glyph scripts (CJK)
        target roughly half the character count -- and are aims, not
        hard caps (soft enforcement with generous slack; see
        ``char_budget``).
        """

        #: Unset -- no slot declared and no size constraint. Note this
        #: is the *absence* of a choice, which is why the authoring
        #: check nags on a long English string that is still NONE: use
        #: PROSE to say "unbounded on purpose".
        NONE = 'none'

        #: Aim for ~20 characters - narrow buttons, tabs, column
        #: headings.
        CHARS_20 = 'chars_20'

        #: Aim for ~40 characters - standard buttons and labels.
        CHARS_40 = 'chars_40'

        #: Aim for ~80 characters / one concise line - transient
        #: messages, status lines, and the like.
        CHARS_80 = 'chars_80'

        #: Body prose - paragraphs in a document or web page. No size
        #: constraint, but unlike NONE that is a deliberate statement,
        #: and it tells the translator to write flowing multi-sentence
        #: text rather than terse UI wording.
        PROSE = 'prose'

        @property
        def char_budget(self) -> int | None:
            """The preset's rough character budget (None if unbounded)."""
            cls = type(self)
            return {
                cls.NONE: None,
                cls.CHARS_20: 20,
                cls.CHARS_40: 40,
                cls.CHARS_80: 80,
                cls.PROSE: None,
            }[self]

    @override
    @classmethod
    def get_type_id(cls) -> AssetsV1StringFileTypeID:
        return AssetsV1StringFileTypeID.V1

    @dataclass
    class Output:
        """Represents a single localized output."""

        #: When this output was last changed.
        modtime: Annotated[
            datetime.datetime, IOAttrs('modtime', time_format='float')
        ]

        #: The localized output -- a plain string, or a render-time
        #: :class:`~bacommon.loctext.StringSelector` (plural/select)
        #: whose final form is chosen at display time. (A type-disjoint
        #: dataclassio union; selectors ride the wire as dicts.)
        value: Annotated[
            str | StringSelector, IOAttrs('value', store_default=False)
        ] = ''

    input: Annotated[str, IOAttrs('input')]
    input_modtime: Annotated[
        datetime.datetime, IOAttrs('input_modtime', time_format='float')
    ]
    style_preset: Annotated[
        StylePreset, IOAttrs('style_preset', store_default=False)
    ] = StylePreset.NONE

    #: Optional free-form usage docs describing where this string
    #: appears and how it is used. Feeds both the generated wrapper
    #: accessor's docstring
    #: and the translation prompt (as usage context). Lives in the
    #: ``.bstr`` itself so edits restale translations via the file's
    #: content-id; when an edit doesn't warrant regeneration, use the
    #: UI's mark-translations-clean action.
    docs: Annotated[str, IOAttrs('docs', store_default=False)] = ''

    #: Which kind of slot this string occupies (see
    #: ``LayoutPreset``). The stored key remains ``fit_preset`` from
    #: before the rename -- values on disk must not move. Passed to
    #: the translator so localized output respects the UI space
    #: available.
    layout_preset: Annotated[
        LayoutPreset,
        IOAttrs(
            'fit_preset',
            store_default=False,
            enum_fallback=LayoutPreset.NONE,
        ),
    ] = LayoutPreset.NONE

    #: How much model effort this string's translations warrant (see
    #: ``TranslationEffort``). Unlike the other presets this is folded
    #: into the translation digest only when it is *not* ``AUTO``, so
    #: adding the field left every existing entry's digest byte-identical
    #: rather than restaling the whole corpus.
    translation_effort: Annotated[
        TranslationEffort,
        IOAttrs(
            'translation_effort',
            store_default=False,
            enum_fallback=TranslationEffort.AUTO,
        ),
    ] = TranslationEffort.AUTO

    outputs: Annotated[dict[Locale, Output], IOAttrs('outputs')] = field(
        default_factory=dict
    )


class AssetsV1AprefFileTypeID(Enum):
    """Type ID for each of our subclasses."""

    V1 = 'v1'


class AssetsV1AprefFile(IOMultiType[AssetsV1AprefFileTypeID]):
    """Top level class for our multitype.

    An ``<name>.apref`` source in an assets_v1 workspace is an
    asset-package reference: a pin to a published asset-package
    version. String briefs can then reference the pinned package's
    translations via cross-package term refs
    (``{@<apref-logical-path>:<entry-path>}``).
    """

    @override
    @classmethod
    def get_type_id_storage_name(cls) -> str:
        return 'apref_file_version'

    @override
    @classmethod
    def get_type_id(cls) -> AssetsV1AprefFileTypeID:
        # Require child classes to supply this themselves. If we did a
        # full type registry/lookup here it would require us to import
        # everything and would prevent lazy loading.
        raise NotImplementedError()

    @override
    @classmethod
    def get_type(
        cls, type_id: AssetsV1AprefFileTypeID
    ) -> type[AssetsV1AprefFile]:
        """Return the subclass for each of our type-ids."""
        # pylint: disable=cyclic-import

        t = AssetsV1AprefFileTypeID
        if type_id is t.V1:
            return AssetsV1AprefFileV1

        # Important to make sure we provide all types.
        assert_never(type_id)


@ioprepped
@dataclass
class AssetsV1AprefFileV1(AssetsV1AprefFile):
    """Our initial version of asset-package-ref file data."""

    @override
    @classmethod
    def get_type_id(cls) -> AssetsV1AprefFileTypeID:
        return AssetsV1AprefFileTypeID.V1

    #: The pinned asset-package-version id
    #: (``<account>.<package>.<version-segment>``). Always a concrete
    #: version — including on the dev track (a specific ``devN``
    #: segment, never the bare ``dev`` pseudo-id); pins only move via
    #: the explicit update/switch-track actions in the workspace UI.
    apverid: Annotated[str, IOAttrs('apverid')]


#: Placeholder value for a string with no generated output in its own locale
#: *or* in English. We deliberately do NOT fall back to the brief ``input``
#: here: that's the author's description of what the string should say (a
#: translator prompt), often a long-winded sentence -- not display text -- so
#: rendering it is worse than an obvious "untranslated" marker.
STRING_NOT_TRANSLATED = '<NOT-TRANSLATED>'


def complete_locale_values(
    string_files: dict[str, AssetsV1StringFileV1], locale: Locale
) -> dict[str, str | StringSelector]:
    """English-completed per-locale values for a set of string files.

    Maps each string's logical name to its value for ``locale``: the
    locale's own output, else the English output, else the
    ``STRING_NOT_TRANSLATED`` placeholder. So every locale's map carries the
    **complete key set** with graceful English fallback -- an untranslated
    string still renders (in English where available, else an obvious
    ``<NOT-TRANSLATED>`` marker) rather than failing, and every locale's key
    set is identical. The brief ``input`` is intentionally never used as a
    value: it's the author's prompt/description, not display text.

    The shared value-selection both the asset-build string recipe and the
    `langstr vendor` command route through (paired with
    :func:`~bacommon.langstr.serialize_language_blob`) so the built and
    vendored blobs can't drift.
    """
    out: dict[str, str | StringSelector] = {}
    for name, sfile in string_files.items():
        output = sfile.outputs.get(locale)
        if output is None:
            output = sfile.outputs.get(Locale.ENGLISH)
        out[name] = STRING_NOT_TRANSLATED if output is None else output.value
    return out


def display_param_kinds(
    string_files: dict[str, AssetsV1StringFileV1],
) -> dict[str, dict[str, str]]:
    """Per-string ``{param: kind}`` for params the display side must know.

    A spec'd brief param (``{size|data_size}``) renders through logic the
    translated text cannot describe -- the text holds only a ``{size}``
    token -- so its kind has to travel to the evaluator in the language
    blob. Plain text subs and the plural pivot are omitted: text is the
    default, and the pivot is not a named output token at all (it
    renders as the ICU ``#`` count placeholder inside each form).

    Entries with nothing to declare are absent, so a package using no
    spec'd params serializes byte-identically to before this existed.

    The shared derivation both the asset-build string recipe and the
    `langstr vendor` command route through, alongside
    :func:`complete_locale_values`, so the built and vendored blobs
    can't drift on this either. Briefs that don't parse contribute
    nothing rather than failing the build -- consistent with how broken
    briefs degrade everywhere else.
    """
    out: dict[str, dict[str, str]] = {}
    for name, sfile in string_files.items():
        try:
            kinds = display_param_kinds_for_brief(sfile.input)
        except Exception:  # pylint: disable=broad-except
            continue
        if kinds:
            out[name] = kinds
    return out


def display_param_kinds_for_brief(brief: str) -> dict[str, str]:
    """Per-param display kinds for a single brief.

    The one-entry unit :func:`display_param_kinds` aggregates; see it
    for what qualifies as a display kind. Values are display-kind
    *expressions*: the bare kind for an argless spec, else the kind
    plus its spec args in canonical form (``'bytes(compact=true)'``)
    -- see :attr:`~bacommon.strbrief.BriefTag.display_kind`. Raises on
    a malformed brief -- callers that must degrade softly (listing
    renders, builds) wrap it, matching how broken briefs degrade
    everywhere else.
    """
    from bacommon.strbrief import parse_brief

    sig = parse_brief(brief)
    return {
        tag.name: tag.display_kind
        for tag in sig.token_params
        if tag.param_kind != 'text'
    }


class AssetsV1PathValsTypeID(Enum):
    """Types of vals we can store for paths."""

    TEX_V1 = 'tex_v1'
    STR_V1 = 'str_v1'
    AUDIO_V1 = 'audio_v1'
    MESH_V1 = 'mesh_v1'
    GROUP_V1 = 'group_v1'
    CUBE_MAP_V1 = 'cube_map_v1'
    APREF_V1 = 'apref_v1'


class AssetsV1PathVals(IOMultiType[AssetsV1PathValsTypeID]):
    """Top level class for path vals classes."""

    @override
    @classmethod
    def get_type_id_storage_name(cls) -> str:
        return 'type'

    @override
    @classmethod
    def get_type_id(cls) -> AssetsV1PathValsTypeID:
        # Require child classes to supply this themselves. If we
        # did a full type registry/lookup here it would require us
        # to import everything and would prevent lazy loading.
        raise NotImplementedError()

    @override
    @classmethod
    def get_type(
        cls, type_id: AssetsV1PathValsTypeID
    ) -> type[AssetsV1PathVals]:
        # pylint: disable=cyclic-import,too-many-return-statements
        t = AssetsV1PathValsTypeID

        if type_id is t.TEX_V1:
            return AssetsV1PathValsTexV1

        if type_id is t.STR_V1:
            return AssetsV1PathValsStrV1

        if type_id is t.AUDIO_V1:
            return AssetsV1PathValsAudioV1

        if type_id is t.MESH_V1:
            return AssetsV1PathValsMeshV1

        if type_id is t.GROUP_V1:
            return AssetsV1PathValsGroupV1

        if type_id is t.CUBE_MAP_V1:
            return AssetsV1PathValsCubeMapV1

        if type_id is t.APREF_V1:
            return AssetsV1PathValsAprefV1

        # Important to make sure we provide all types.
        assert_never(type_id)


class TextureQuality(Enum):
    """Per-texture authoring quality knob (decision #19).

    ``DEFAULT`` is the normal case (the vast majority of textures);
    ``LOW`` and ``HIGH`` are deliberate per-texture overrides for
    special cases (e.g. ``HIGH`` for a hero texture that must stay
    crisp, ``LOW`` for one that can afford to be cheap). Named
    ``DEFAULT`` rather than ``MEDIUM`` to communicate that — it's the
    baseline, not a middle setting you'd routinely reach past.

    ``LOW``/``DEFAULT``/``HIGH`` are blanket settings that map to a
    sensible value for whichever encoder a profile uses (ASTC block
    size on mobile, BC7 RDO lambda on desktop). ``CUSTOM`` instead
    defers to the per-format :class:`AstcSettings` / :class:`Bc7Settings`
    so a texture can be tuned independently per encoder (e.g. ASTC
    ``HIGH`` while BC7 ``DEFAULT``). Distinct from the bucket-level
    ``TextureTier``.
    """

    LOW = 'low'
    DEFAULT = 'default'
    HIGH = 'high'
    CUSTOM = 'custom'


class Role(Enum):
    """What a texture is for (its authoring intent).

    Drives mip-filtering math and encoder flags (asset-packages
    initiative decisions #19/#23). Intent-based rather than a bundle
    of low-level mechanical flags — the recipe maps each role to a
    concrete filtering/encoding behavior. ``normal_map`` / ``data``
    are reserved slots for when such content (and the compressed-
    profile recipes) land.
    """

    #: sRGB color with straight opacity alpha. The pipeline
    #: premultiplies it by its alpha for storage (decision #23):
    #: premult-weighted, halo-free mip filtering in the requested
    #: render_space, premult output bytes, ``ALPHA_PREMULTIPLIED``
    #: DFD flag set. The common case for color sprites. Renders
    #: correctly only with premult-blend (the renderer wiring lands
    #: in a later step; until then ``DEFAULT`` output shows darkened
    #: edges under the legacy straight-blend path).
    DEFAULT = 'default'

    #: sRGB color whose SOURCE RGB is already premultiplied by its
    #: alpha (e.g. glow sprites — they carry additive ``RGB > alpha``
    #: values that straight alpha cannot represent). The pipeline does
    #: NOT re-multiply; mips filter the premultiplied RGB directly (in
    #: the requested render_space) and the flag is set. Renders
    #: identically to ``DEFAULT`` (both premult-blend); they differ
    #: only in whether the pipeline applies the multiply.
    SOURCE_PREMULTIPLIED = 'source_premultiplied'

    #: sRGB color with straight alpha whose RGB channels carry
    #: meaningful color even in transparent regions, so they must be
    #: preserved (decision #23). The pipeline does NOT premultiply:
    #: mips filter RGB and alpha INDEPENDENTLY (color still filtered
    #: in the requested render_space, but with no premult round-trip,
    #: which would zero — and fail to recover — the transparent-region
    #: color). Straight output bytes; ``ALPHA_PREMULTIPLIED`` flag
    #: clear. Renders with ordinary straight-alpha blending.
    STRAIGHT_ALPHA = 'straight_alpha'


class AstcBlockSize(Enum):
    """ASTC square block size — the mobile bitrate lever.

    Smaller block = more bits per texel = higher quality + larger
    output. Consulted only when an :class:`AstcSettings` has its
    ``texture_quality`` set to ``CUSTOM``; otherwise the blanket
    ``LOW``/``DEFAULT``/``HIGH`` map to a value in this range
    (``LOW`` = ``TWELVE_BY_TWELVE``, ``HIGH`` = ``FOUR_BY_FOUR``).
    """

    FOUR_BY_FOUR = '4x4'
    FIVE_BY_FIVE = '5x5'
    SIX_BY_SIX = '6x6'
    EIGHT_BY_EIGHT = '8x8'
    TEN_BY_TEN = '10x10'
    TWELVE_BY_TWELVE = '12x12'


class Bc7Rdo(Enum):
    """BC7 RDO (rate-distortion optimization) lambda — the desktop lever.

    BC7 is a fixed 8bpp block format, so its size lever is RDO: higher
    lambda steers the encoder toward more zlib/LZ-compressible output
    (smaller on-disk) at the cost of quality. ``OFF`` disables RDO
    (best quality, largest). Consulted only when a :class:`Bc7Settings`
    has its ``texture_quality`` set to ``CUSTOM``; otherwise the blanket
    ``LOW``/``DEFAULT``/``HIGH`` map to a value in this range
    (``LOW`` = ``FOUR``, ``HIGH`` = ``OFF``).
    """

    OFF = 'off'
    ZERO_POINT_ONE_TWO_FIVE = '0.125'
    ZERO_POINT_TWO_FIVE = '0.25'
    ZERO_POINT_FIVE = '0.5'
    ONE = '1'
    TWO = '2'
    FOUR = '4'


@ioprepped
@dataclass
class AstcSettings:
    """Per-texture ASTC (mobile) encode settings.

    Consulted only when the texture's top-level ``texture_quality`` is
    ``CUSTOM``. Its own ``texture_quality`` may in turn be ``CUSTOM``
    to use the explicit ``block_size``; otherwise ``LOW``/``DEFAULT``/
    ``HIGH`` map to the encoder's block-size range. Fully defaulted so
    a texture never has to store it explicitly.
    """

    texture_quality: Annotated[
        TextureQuality, IOAttrs('tq', store_default=False)
    ] = TextureQuality.DEFAULT

    block_size: Annotated[AstcBlockSize, IOAttrs('bs', store_default=False)] = (
        AstcBlockSize.SIX_BY_SIX
    )


@ioprepped
@dataclass
class Bc7Settings:
    """Per-texture BC7 (desktop) encode settings.

    Consulted only when the texture's top-level ``texture_quality`` is
    ``CUSTOM``. Its own ``texture_quality`` may in turn be ``CUSTOM``
    to use the explicit ``rdo`` lambda; otherwise ``LOW``/``DEFAULT``/
    ``HIGH`` map to the encoder's RDO range. Fully defaulted so a
    texture never has to store it explicitly.
    """

    texture_quality: Annotated[
        TextureQuality, IOAttrs('tq', store_default=False)
    ] = TextureQuality.DEFAULT

    rdo: Annotated[Bc7Rdo, IOAttrs('rdo', store_default=False)] = Bc7Rdo.ONE


class TextureWrapping(Enum):
    """How a texture's sampling behaves past its edge, per axis.

    This is authoring *intent*, not just sampler state: it is a build
    input. Mip levels are filtered with a kernel that wraps (or
    mirrors) at the edge for the repeating modes and clamps for
    ``CLAMP``, so changing it changes the built pixels -- a texture's
    wrapping is part of its identity, not an annotation on top of it.
    It also travels to the engine in the KTX2 key/value data and
    becomes the GL wrap mode for its axis.

    ``CLAMP`` is the default and the only mode that permits
    non-power-of-two dimensions on its axis: the repeating modes halve
    their way down the mip chain, and an odd intermediate size puts
    each level's texel grid out of phase with its parent, which breaks
    edge continuity precisely where a repeating texture needs it.
    """

    #: Sample the edge texel for anything past the edge
    #: (``GL_CLAMP_TO_EDGE``). Mips filter with a clamped kernel.
    CLAMP = 'clamp'

    #: Tile (``GL_REPEAT``). Mips filter with a kernel that wraps
    #: around to the opposite edge, so the tiling seam stays
    #: continuous at every level.
    REPEAT = 'repeat'

    #: Tile with every other copy flipped (``GL_MIRRORED_REPEAT``).
    #: Seamless by construction; mips filter with a mirrored kernel.
    MIRRORED_REPEAT = 'mirrored_repeat'


@ioprepped
@dataclass
class AssetsV1PathValsTexV1(AssetsV1PathVals):
    """Path-specific values for an assets_v1 workspace path.

    The per-texture quality knobs (:class:`TextureQuality`,
    :class:`Role`, :class:`AstcSettings`, :class:`Bc7Settings`) are
    module-level types in this module.
    """

    texture_quality: Annotated[
        TextureQuality, IOAttrs('texture_quality', store_default=False)
    ] = TextureQuality.DEFAULT

    texture_role: Annotated[
        Role, IOAttrs('texture_role', store_default=False)
    ] = Role.DEFAULT

    #: Horizontal (u/s axis) wrapping. See :class:`TextureWrapping` --
    #: this feeds mip filtering, the KTX2 key/value data, and the
    #: engine's ``GL_TEXTURE_WRAP_S``.
    texture_wrapping_h: Annotated[
        TextureWrapping, IOAttrs('texture_wrapping_h', store_default=False)
    ] = TextureWrapping.CLAMP

    #: Vertical (v/t axis) wrapping. See :class:`TextureWrapping` --
    #: this feeds mip filtering, the KTX2 key/value data, and the
    #: engine's ``GL_TEXTURE_WRAP_T``.
    texture_wrapping_v: Annotated[
        TextureWrapping, IOAttrs('texture_wrapping_v', store_default=False)
    ] = TextureWrapping.CLAMP

    #: Per-format encode settings, consulted only when
    #: ``texture_quality`` is ``CUSTOM``. Fully defaulted so a texture
    #: never has to store them explicitly.
    astc_settings: Annotated[
        AstcSettings, IOAttrs('astc', store_default=False)
    ] = field(default_factory=AstcSettings)

    bc7_settings: Annotated[
        Bc7Settings, IOAttrs('bc7', store_default=False)
    ] = field(default_factory=Bc7Settings)

    #: Optional free-form documentation for this asset, surfaced as a
    #: comment above the asset in generated wrapper modules (and in the
    #: Sphinx docs). Empty string means no docs.
    docs: Annotated[str, IOAttrs('docs', store_default=False)] = ''

    #: Halve the fallback flavor's level0 downsize divisor (2 instead
    #: of 4) so this asset's fallback carries a higher-res top mip. For
    #: the rare asset whose fallback bytes get consumed directly rather
    #: than just serving as a universal render fallback -- e.g. the
    #: engine cursor texture feeding OS hardware cursors, which wants a
    #: retina-res mip. Deliberately not exposed in the workspace web UI
    #: (it would be noise there); edit workspace.json directly for the
    #: odd asset that needs it.
    fallback_high_res: Annotated[
        bool, IOAttrs('fallback_high_res', store_default=False)
    ] = False

    @override
    @classmethod
    def get_type_id(cls) -> AssetsV1PathValsTypeID:
        return AssetsV1PathValsTypeID.TEX_V1

    def normalize(self) -> None:
        """Reset redundant/unused settings to defaults, in place.

        A pure tidiness pass to run before storing: it never changes the
        resolved result, only drops dead data so ``store_default=False``
        can strip it from workspace.json. Resolution consults the
        per-format settings only when the top-level ``texture_quality``
        is ``CUSTOM``, and a format's explicit ``block_size``/``rdo`` only
        when that format's own ``texture_quality`` is ``CUSTOM`` -- so
        anything outside those paths is unused and gets cleared here.
        """
        astc_defaults = AstcSettings()
        bc7_defaults = Bc7Settings()

        # A non-CUSTOM format quality ignores the explicit value: clear it.
        if self.astc_settings.texture_quality is not TextureQuality.CUSTOM:
            self.astc_settings.block_size = astc_defaults.block_size
        if self.bc7_settings.texture_quality is not TextureQuality.CUSTOM:
            self.bc7_settings.rdo = bc7_defaults.rdo

        # Both formats on the same non-CUSTOM blanket value is identical to
        # just setting the top-level knob: collapse to it.
        if (
            self.texture_quality is TextureQuality.CUSTOM
            and self.astc_settings.texture_quality
            is self.bc7_settings.texture_quality
            and self.astc_settings.texture_quality is not TextureQuality.CUSTOM
        ):
            self.texture_quality = self.astc_settings.texture_quality

        # A non-CUSTOM top-level quality never consults per-format settings:
        # clear them entirely.
        if self.texture_quality is not TextureQuality.CUSTOM:
            self.astc_settings = astc_defaults
            self.bc7_settings = bc7_defaults


@ioprepped
@dataclass
class AssetsV1StrTermDeps:
    """Cached term-ref info for a ``.bstr``, keyed to its content.

    Term refs (``{@term}`` / ``{@pkg:term}`` in the brief) are a pure
    function of the ``.bstr`` file's content, which is pinned by its
    content-addressed ``file_id`` -- so this record stays valid exactly
    as long as ``file_id`` matches the entry's current file. Consumers
    (dep-aware staleness calcs) use it to skip reading the file; on
    mismatch they fall back to reading that one file. Maintained
    automatically by the string save/translate paths; do not hand-edit
    (a wrong ``local``/``cross`` list with a matching ``file_id`` would
    be trusted).
    """

    #: Content-id of the ``.bstr`` file these refs were extracted from.
    file_id: Annotated[str, IOAttrs('file_id')]

    #: Digest of the entry's *translation inputs* (brief, docs, style
    #: and layout presets) -- everything that shapes what the model
    #: produces, and nothing else. Notably NOT the entry's own outputs
    #: or modtimes: staleness folds this in per locale, so including
    #: outputs would mean writing one locale's translation restaled
    #: every other locale. Empty on records predating the field, which
    #: consumers treat as a cache miss.
    inputs_digest: Annotated[str, IOAttrs('idig', store_default=False)] = ''

    #: Retired -- same-package term refs, which no longer exist. Kept
    #: only so stored records carrying it still parse; a non-empty
    #: value means the record predates the removal, and consumers
    #: treat that as a cache miss and re-extract. Never populate it.
    local: Annotated[list[str], IOAttrs('local', store_default=False)] = field(
        default_factory=list
    )

    #: Term-ref targets, whole (``<apref-path>:<entry-path>``, no
    #: extensions) -- consumers key staleness on the individual
    #: referenced term, not just its package. (Historical note: this
    #: briefly held bare apref paths; consumers ignore any entry
    #: lacking the ``:`` half and re-extract from the file.)
    cross: Annotated[list[str], IOAttrs('cross', store_default=False)] = field(
        default_factory=list
    )

    #: Sorted unique display-param kinds this entry's brief uses
    #: (``'bytes'`` etc.; the union over
    #: :func:`display_param_kinds_for_brief`). What the master consults
    #: to decide which formatter components a package build must embed,
    #: without reading the file. ``None`` on records predating the
    #: field, which consumers treat as a cache miss so the record gets
    #: repaired (mirroring ``inputs_digest``); an extracted brief using
    #: no spec'd params stores ``[]``.
    kinds: Annotated[list[str] | None, IOAttrs('k', store_default=False)] = None


@ioprepped
@dataclass
class AssetsV1StrState:
    """A ``.bstr``'s up-to-date state, resolved per locale.

    A locale's output is a pure function of the entry's own content
    plus, for each ``{@…}`` term it references, that term's translated
    **value for that locale** in the pinned version. So staleness is
    per-locale: fixing one locale of a shared term must cost its
    dependents that one locale, not all ~41.

    Two shapes, because per-locale resolution is only ever needed by
    the minority of entries that reference terms:

    - ``uniform`` -- one state covering every locale. Used when the
      entry has no term refs, so nothing about its translation inputs
      varies by locale.
    - ``per_locale`` -- one state per locale. Used when it does.

    Storing per-locale for everything would be far larger than the
    rest of ``workspace.json`` combined (BaClassicAssets: 32 of 1128
    entries carry refs), hence the split. Read through
    :meth:`for_locale`, which hides it.
    """

    #: State shared by every locale (entries with no term refs).
    uniform: Annotated[str | None, IOAttrs('u', store_default=False)] = None

    #: Per-locale states (entries with term refs).
    per_locale: Annotated[
        dict[Locale, str], IOAttrs('pl', store_default=False)
    ] = field(default_factory=dict)

    def for_locale(self, locale: Locale) -> str | None:
        """This entry's state for one locale, or None if unstamped."""
        if self.uniform is not None:
            return self.uniform
        return self.per_locale.get(locale)

    def locales_stamped(self) -> bool:
        """Whether anything is stamped at all."""
        return self.uniform is not None or bool(self.per_locale)


@ioprepped
@dataclass
class AssetsV1StrConvCache:
    """Cached conventions findings for a ``.bstr``, keyed to its inputs.

    Per-entry conventions findings are a pure function of the ``.bstr``
    file's content plus the workspace's cross-package term environment
    (its ``.apref`` files' content-ids) plus the checks' own version --
    all folded into ``state``. Consumers (the conventions lint) use it
    to skip reading the file; on mismatch they fall back to reading and
    re-checking that one entry. Maintained automatically by the string
    save/translate paths; do not hand-edit (wrong ``findings`` with a
    matching ``state`` would be trusted).
    """

    #: Token pinning the inputs these findings were computed from (see
    #: class docs). Opaque; produced by the conventions module.
    state: Annotated[str, IOAttrs('state')]

    #: The entry's findings (human-readable one-liners), empty if clean.
    findings: Annotated[list[str], IOAttrs('findings', store_default=False)] = (
        field(default_factory=list)
    )


@ioprepped
@dataclass
class AssetsV1PathValsStrV1(AssetsV1PathVals):
    """Path-specific values for an assets_v1 workspace path."""

    #: Retired -- the single whole-entry up-to-date state, superseded by
    #: the per-locale :attr:`state` below. Kept only so stored records
    #: carrying it still parse; never read, and cleared on the next
    #: stamp. Never populate it.
    #:
    #: (Historical note: string author docs briefly lived here as a
    #: ``docs`` path-val to avoid restaling translations; they moved
    #: into the ``.bstr`` itself once docs began feeding the translation
    #: prompt, with the UI's mark-translations-clean action as the
    #: no-regeneration-needed escape hatch.)
    up_to_date_state: Annotated[
        str | None, IOAttrs('up_to_date_state', store_default=False)
    ] = None

    #: Per-locale up-to-date state (see :class:`AssetsV1StrState`).
    #: Stamped by the translate / mark-clean paths for exactly the
    #: locales they brought current; a locale absent here (or whose
    #: stamp no longer matches a fresh calc) needs regenerating.
    state: Annotated[
        AssetsV1StrState | None, IOAttrs('st', store_default=False)
    ] = None

    #: Optional definition-time line-wrapping hints (decision D-t in
    #: the language-string-context initiative): applied automatically
    #: at evaluation everywhere this string displays. Locale-invariant.
    #: Lives HERE (not in the ``.bstr``) deliberately: the ``.bstr`` is
    #: by definition the translation input, so its content hash is the
    #: translation-staleness key, and display-side metadata like this
    #: must not restale translations (the same reasoning as the docs
    #: history above, in reverse -- wrap does not feed the translation
    #: prompt).
    wrap: Annotated[WrapParams | None, IOAttrs('wrap', store_default=False)] = (
        None
    )

    #: Retired -- cached term-ref info, now held in a content-addressed
    #: Valkey group instead (``assetsv1str.term_deps_group``). Kept only
    #: so stored records carrying it still parse; never read or
    #: written. It moved because a cache keyed by immutable content
    #: needs no home in the snapshot -- and living here meant only
    #: write paths could fill it, since a path-vals write mints a
    #: snapshot and races user saves. It was also ~23% of a large
    #: workspace's ``workspace.json``.
    deps: Annotated[
        AssetsV1StrTermDeps | None, IOAttrs('deps', store_default=False)
    ] = None

    #: Retired -- cached conventions findings, now held in a
    #: content-addressed Valkey group instead
    #: (``assetsv1conventions.conv_findings_group``). Same reasoning as
    #: :attr:`deps` above; it was a further ~16%.
    conv: Annotated[
        AssetsV1StrConvCache | None, IOAttrs('conv', store_default=False)
    ] = None

    @override
    @classmethod
    def get_type_id(cls) -> AssetsV1PathValsTypeID:
        return AssetsV1PathValsTypeID.STR_V1


class AudioRole(Enum):
    """A sound's channel/encode contract (asset-packages decision #25).

    Names the *technical* contract, not a content category — "music"
    deliberately does not exist as a build-time concept (volume routing
    stays a runtime play-flag; streaming is a length-derived engine
    policy).

    - ``DEFAULT`` — spatialization-ready: downmixed to mono at encode
      (OpenAL only spatializes mono; a hard requirement, not a size
      optimization). The vast majority of sounds.
    - ``PRE_MIXED`` — an authored mix: channels preserved (≤2) and the
      sound always plays listener-space. The recipe stamps a
      ``BA_ROLE=pre_mixed`` vorbis comment tag so the engine knows at
      load time (channel count alone can't carry the bit — a mono
      pre-mixed source stays mono). Music, plus any intentionally
      stereo (or otherwise authored-mix) sound.
    """

    DEFAULT = 'default'
    PRE_MIXED = 'pre_mixed'


class AudioQuality(Enum):
    """Per-sound authoring quality knob (asset-packages decision #25).

    Mirrors the texture knob's LOW/DEFAULT/HIGH pattern. Defined from
    day one as the escape hatch for content whose default encode budget
    doesn't fit (e.g. a short pre-mixed UI sound sharing music's
    bitrate), but nothing consumes it yet — the recipe carries it in
    its cache key only, so wiring it up later rebuilds correctly.
    """

    LOW = 'low'
    DEFAULT = 'default'
    HIGH = 'high'


@ioprepped
@dataclass
class AssetsV1PathValsAudioV1(AssetsV1PathVals):
    """Path-specific values for an audio source in an assets_v1 workspace.

    The per-sound authoring knobs (:class:`AudioRole`,
    :class:`AudioQuality`) are module-level types in this module.
    """

    audio_role: Annotated[
        AudioRole, IOAttrs('audio_role', store_default=False)
    ] = AudioRole.DEFAULT

    audio_quality: Annotated[
        AudioQuality, IOAttrs('audio_quality', store_default=False)
    ] = AudioQuality.DEFAULT

    #: Optional free-form documentation for this asset, surfaced as a
    #: comment above the asset in generated wrapper modules (and in the
    #: Sphinx docs). Empty string means no docs.
    docs: Annotated[str, IOAttrs('docs', store_default=False)] = ''

    @override
    @classmethod
    def get_type_id(cls) -> AssetsV1PathValsTypeID:
        return AssetsV1PathValsTypeID.AUDIO_V1


class MeshRole(Enum):
    """What a mesh ``.obj`` source builds (asset-packages decision #26).

    - ``DEFAULT`` — a display mesh: compiled to the engine's binary
      ``.bob`` format (welded/quantized verts, vertex-cache-optimized
      index order) and served from the flavor-varying ``meshes`` bucket
      (headless builds get none).
    - ``COLLISION`` — a collision mesh: compiled to the engine's binary
      ``.cob`` format (positions + indices for the physics trimesh) and
      served from the flavor-invariant ``constant`` bucket — every
      build including headless gets it, and the bytes are identical
      across all flavors (networked sims/replays must agree on
      collision geometry).
    """

    DEFAULT = 'default'
    COLLISION = 'collision'


@ioprepped
@dataclass
class AssetsV1PathValsMeshV1(AssetsV1PathVals):
    """Path-specific values for a mesh source in an assets_v1 workspace.

    The per-mesh authoring knob (:class:`MeshRole`) is a module-level
    type in this module.
    """

    mesh_role: Annotated[
        MeshRole, IOAttrs('mesh_role', store_default=False)
    ] = MeshRole.DEFAULT

    #: Optional free-form documentation for this asset, surfaced as a
    #: comment above the asset in generated wrapper modules (and in the
    #: Sphinx docs). Empty string means no docs.
    docs: Annotated[str, IOAttrs('docs', store_default=False)] = ''

    @override
    @classmethod
    def get_type_id(cls) -> AssetsV1PathValsTypeID:
        return AssetsV1PathValsTypeID.MESH_V1


@ioprepped
@dataclass
class AssetsV1PathValsGroupV1(AssetsV1PathVals):
    """Path-specific values for a group (directory) in a workspace.

    A group builds no asset of its own; this exists purely to carry
    optional ``docs`` (decision #28) that become the generated wrapper
    group class's docstring. Keyed in ``workspace.json``'s ``path`` dict
    by the directory path (e.g. ``textures`` or ``mydir/subdir``).
    """

    #: Optional free-form documentation for this group, used as the
    #: generated wrapper group class's docstring (a trailing "See source
    #: for the full asset list." is always appended). Empty string means
    #: fall back to the auto-generated docstring.
    docs: Annotated[str, IOAttrs('docs', store_default=False)] = ''

    @override
    @classmethod
    def get_type_id(cls) -> AssetsV1PathValsTypeID:
        return AssetsV1PathValsTypeID.GROUP_V1


@ioprepped
@dataclass
class AssetsV1PathValsCubeMapV1(AssetsV1PathVals):
    """Path-specific values for a cube map (``.cubemap`` dir) in a workspace.

    Cube maps are reflection textures with no Python API (decision #24),
    so they aren't wrapper-visible. This currently carries only optional
    ``docs`` -- stored for completeness/consistency with other asset
    kinds, but not yet consumed by anything (it'll have a home if/when
    cube maps gain a Python surface). Keyed in ``workspace.json``'s
    ``path`` dict by the ``.cubemap`` directory path.
    """

    #: Optional free-form documentation for this cube map. Stored but not
    #: yet surfaced anywhere (cube maps have no wrapper entry). Empty
    #: string means no docs.
    docs: Annotated[str, IOAttrs('docs', store_default=False)] = ''

    @override
    @classmethod
    def get_type_id(cls) -> AssetsV1PathValsTypeID:
        return AssetsV1PathValsTypeID.CUBE_MAP_V1


@ioprepped
@dataclass
class AssetsV1PathValsAprefV1(AssetsV1PathVals):
    """Path-specific values for an ``.apref`` asset-package ref.

    Keyed in ``workspace.json``\'s ``path`` dict by the ``.apref``
    file\'s path. Carries per-pin settings that are *not* part of the
    pin itself -- the pinned apverid lives in the ``.apref`` file, since
    that is content the workspace owns and syncs.

    NOTE: adding a member to :class:`AssetsV1PathValsTypeID` is a wire
    change with a cross-repo rollout. Workspace compiles parse this map
    via ``WorkspaceCompileInput.parse_path_config_data``, which raises
    ``PermanentBuildError`` on an entry it cannot decode -- and those
    compiles run on basn nodes carrying their own copy of this file. So
    a node that predates this type hard-fails any build of a workspace
    using it. Rolling one out means: define here -> ``make efrosync``
    -> deploy basn -> bump ``CLOUD_BUILD_MIN_BASN_VERSION`` -> only then
    let bamaster start writing it.
    """

    #: Whether pending updates should include bumping this pin to the
    #: newest version on its own track. See
    #: ``docs/initiatives/pin_keep_up_to_date.md``.
    #:
    #: **On by default**, to encourage modders to keep what they depend
    #: on current. With ``store_default=False`` that means the absence
    #: of this field reads as enabled, so every existing workspace
    #: inherits the behavior with no migration and the common case
    #: costs no bytes. The trade is that "never set" and "explicitly
    #: enabled" are indistinguishable -- fine here, but it does mean an
    #: explicit opt-*out* is the only thing that leaves a trace.
    keep_up_to_date: Annotated[bool, IOAttrs('kutd', store_default=False)] = (
        True
    )

    @override
    @classmethod
    def get_type_id(cls) -> AssetsV1PathValsTypeID:
        return AssetsV1PathValsTypeID.APREF_V1
