# 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-only wrapper for the babase layer (no classic asset
    #: loader APIs exist there); string leaves emit the same native
    #: ``babase.LangStr`` accessors as the featureset forms. Exists so
    #: pre-featureset machinery (e.g. construct-mode's bring-up UI) can
    #: consume package strings.
    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 PackageVisibility(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
    visibility 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 server-side packages (content the master
    evaluates itself and never ships as language-strings), not as a
    general publishing control.
    """

    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:`PackageVisibility`.

    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:`PackageVisibility`). 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).
    visibility: Annotated[
        PackageVisibility,
        IOAttrs(
            'visibility',
            store_default=False,
            enum_fallback=PackageVisibility.PUBLIC,
        ),
    ] = PackageVisibility.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 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

    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


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


@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

    #: 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
    )


@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
