"""
Comprehensive semantic validation for DAZZLE AppSpec.

Validates entities, surfaces, experiences, services, foreign models, and integrations
for semantic correctness beyond basic reference resolution.
"""

from typing import Any
from urllib.parse import urlparse

from . import ir

# =============================================================================
# Validation Constants
# =============================================================================

# Decimal type limits (based on common database limits)
DECIMAL_PRECISION_MIN = 1
DECIMAL_PRECISION_MAX = 65

# String field limits
STRING_MAX_LENGTH_MIN = 1
STRING_MAX_LENGTH_WARN_THRESHOLD = 10000  # Suggest TEXT type above this

# SQL reserved words (common across SQLite, PostgreSQL, MySQL)
# These can cause issues when used unquoted in SQL statements
# SQL keywords that are genuinely dangerous as identifiers — DML/DDL commands
# and clauses that could cause ambiguity. SQLAlchemy quotes all identifiers,
# so even these are technically safe, but they deserve a warning.
SQL_RESERVED_WORDS = frozenset(
    {
        # DML/DDL commands — these look like SQL statements
        "select",
        "insert",
        "update",
        "delete",
        "create",
        "alter",
        "drop",
        "table",
        "index",
        "trigger",
        "database",
        # Clauses that could genuinely confuse
        "from",
        "where",
        "join",
        "having",
        "union",
        "except",
        "intersect",
        "values",
        "constraint",
        "foreign",
        "primary",
        "references",
        # Uncommonly used as domain names
        "vacuum",
        "pragma",
        "savepoint",
        "rollback",
        "commit",
        "recursive",
    }
)

# Words that are SQL-reserved but commonly used in domain modelling.
# SQLAlchemy safely quotes them — warning about these creates noise.
# Examples: User, Order, Group, Action, Key, Check, View, Column, etc.
# These are intentionally excluded from SQL_RESERVED_WORDS.

# Secret/sensitive field patterns that should not appear in event payloads
# These can leak credentials, API keys, or other sensitive data via event logs
SECRET_FIELD_PATTERNS = frozenset(
    {
        # Credentials
        "password",
        "passwd",
        "pwd",
        "secret",
        "secret_key",
        "secretkey",
        # API keys
        "api_key",
        "apikey",
        "api_secret",
        "apisecret",
        "access_key",
        "accesskey",
        "private_key",
        "privatekey",
        # Tokens
        "token",
        "access_token",
        "accesstoken",
        "refresh_token",
        "refreshtoken",
        "auth_token",
        "authtoken",
        "bearer_token",
        "bearertoken",
        "jwt_token",
        "jwttoken",
        "session_token",
        "sessiontoken",
        # Auth-related
        "credentials",
        "auth",
        "authorization",
        # Encryption
        "encryption_key",
        "encryptionkey",
        "signing_key",
        "signingkey",
        "salt",
        "hash",
        "pin",
        "pin_code",
        "pincode",
        # SSN, credit cards
        "ssn",
        "social_security",
        "credit_card",
        "creditcard",
        "card_number",
        "cardnumber",
        "cvv",
        "cvc",
    }
)


def is_secret_field_name(name: str) -> bool:
    """Check if a field name matches a secret pattern."""
    lower_name = name.lower()
    # Exact match
    if lower_name in SECRET_FIELD_PATTERNS:
        return True
    # Suffix match (e.g., user_password, api_token)
    for pattern in SECRET_FIELD_PATTERNS:
        if lower_name.endswith(f"_{pattern}") or lower_name.endswith(pattern):
            return True
    return False


def _validate_entity_pk(entity: ir.EntitySpec, errors: list[str]) -> None:
    """Check that the entity has a primary key field."""
    if not entity.primary_key:
        errors.append(
            f"Entity '{entity.name}' has no primary key field. Add a field with 'pk' modifier."
        )


def _validate_reserved_names(entity: ir.EntitySpec, errors: list[str], warnings: list[str]) -> None:
    """Check for SQL reserved words in entity and field names."""
    if entity.name.lower() in SQL_RESERVED_WORDS:
        warnings.append(
            f"Entity '{entity.name}' uses SQL reserved word as name. "
            f"Consider renaming (e.g., 'SalesOrder' instead of 'Order')."
        )
    for field in entity.fields:
        if field.name.lower() in SQL_RESERVED_WORDS:
            warnings.append(
                f"Entity '{entity.name}' field '{field.name}' uses SQL reserved word. "
                f"Consider renaming (e.g., 'sales_order' instead of 'order')."
            )


def _validate_field_duplicates(entity: ir.EntitySpec, errors: list[str]) -> None:
    """Check for duplicate field names within an entity."""
    field_names = [f.name for f in entity.fields]
    duplicates = {name for name in field_names if field_names.count(name) > 1}
    if duplicates:
        errors.append(f"Entity '{entity.name}' has duplicate field names: {duplicates}")


def _validate_field_enums(entity: ir.EntitySpec, errors: list[str]) -> None:
    """Check that enum fields have at least one value."""
    for field in entity.fields:
        if field.type.kind == ir.FieldTypeKind.ENUM:
            if not field.type.enum_values or len(field.type.enum_values) == 0:
                errors.append(
                    f"Entity '{entity.name}' field '{field.name}' has enum type but no values"
                )


def _validate_field_decimals(entity: ir.EntitySpec, errors: list[str], warnings: list[str]) -> None:
    """Check decimal precision/scale values are valid."""
    for field in entity.fields:
        if field.type.kind == ir.FieldTypeKind.DECIMAL:
            if field.type.precision is None or field.type.scale is None:
                errors.append(
                    f"Entity '{entity.name}' field '{field.name}' has decimal type "
                    f"but missing precision/scale"
                )
            elif (
                field.type.precision < DECIMAL_PRECISION_MIN
                or field.type.precision > DECIMAL_PRECISION_MAX
            ):
                warnings.append(
                    f"Entity '{entity.name}' field '{field.name}' has unusual "
                    f"decimal precision: {field.type.precision}"
                )
            elif field.type.scale > field.type.precision:
                errors.append(
                    f"Entity '{entity.name}' field '{field.name}' has decimal scale "
                    f"({field.type.scale}) greater than precision ({field.type.precision})"
                )


def _validate_field_strings(entity: ir.EntitySpec, errors: list[str], warnings: list[str]) -> None:
    """Check string field max_length values are valid."""
    for field in entity.fields:
        if field.type.kind == ir.FieldTypeKind.STR:
            if field.type.max_length is None:
                errors.append(
                    f"Entity '{entity.name}' field '{field.name}' has str type but no max_length"
                )
            elif field.type.max_length < STRING_MAX_LENGTH_MIN:
                errors.append(
                    f"Entity '{entity.name}' field '{field.name}' has invalid "
                    f"max_length: {field.type.max_length}"
                )
            elif field.type.max_length > STRING_MAX_LENGTH_WARN_THRESHOLD:
                warnings.append(
                    f"Entity '{entity.name}' field '{field.name}' has very large "
                    f"max_length: {field.type.max_length}. Consider using 'text' type."
                )


def _validate_field_modifiers(
    entity: ir.EntitySpec, errors: list[str], warnings: list[str]
) -> None:
    """Check for conflicting or inappropriate field modifiers."""
    for field in entity.fields:
        if (
            ir.FieldModifier.REQUIRED in field.modifiers
            and ir.FieldModifier.OPTIONAL in field.modifiers
        ):
            errors.append(
                f"Entity '{entity.name}' field '{field.name}' has both "
                f"'required' and 'optional' modifiers"
            )

        if (
            ir.FieldModifier.AUTO_ADD in field.modifiers
            or ir.FieldModifier.AUTO_UPDATE in field.modifiers
        ):
            if field.type.kind != ir.FieldTypeKind.DATETIME:
                warnings.append(
                    f"Entity '{entity.name}' field '{field.name}' has auto_add/auto_update "
                    f"modifier but is not datetime type"
                )


def _validate_constraints(entity: ir.EntitySpec, errors: list[str]) -> None:
    """Check that constraint fields exist in the entity."""
    for constraint in entity.constraints:
        for field_name in constraint.fields:
            if not entity.get_field(field_name):
                errors.append(
                    f"Entity '{entity.name}' {constraint.kind.value} constraint "
                    f"references non-existent field '{field_name}'"
                )


def validate_entities(appspec: ir.AppSpec) -> tuple[list[str], list[str]]:
    """
    Validate all entities for semantic correctness.

    Checks:
    - Every entity has a primary key
    - Field types are valid and consistent
    - Enum values are valid identifiers
    - Decimal precision/scale are reasonable
    - Constraint fields exist
    - No duplicate field names
    - Unique constraints reference valid fields

    Returns:
        Tuple of (errors, warnings)
    """
    errors: list[str] = []
    warnings: list[str] = []

    for entity in appspec.domain.entities:
        _validate_entity_pk(entity, errors)
        _validate_reserved_names(entity, errors, warnings)
        _validate_field_duplicates(entity, errors)
        _validate_field_enums(entity, errors)
        _validate_field_decimals(entity, errors, warnings)
        _validate_field_strings(entity, errors, warnings)
        _validate_field_modifiers(entity, errors, warnings)
        _validate_constraints(entity, errors)

        # v0.34.0: Validate bulk config field references
        if entity.bulk:
            field_names = {f.name for f in entity.fields}
            for imp_f in entity.bulk.import_fields:
                if imp_f not in field_names:
                    errors.append(
                        f"Entity '{entity.name}' bulk import_fields references "
                        f"unknown field '{imp_f}'"
                    )
            for exp_f in entity.bulk.export_fields:
                if exp_f not in field_names:
                    errors.append(
                        f"Entity '{entity.name}' bulk export_fields references "
                        f"unknown field '{exp_f}'"
                    )

        # v0.45.0: Entities with permit: blocks must also have scope: blocks (#595).
        # Without scope: blocks, the API list endpoint default-denies all rows.
        # Use `scope: all for: *` for intentionally public entities.
        # Skip framework-generated entities (e.g. AIJob) — users can't add scope
        # blocks to them, and their access rules are set by the framework.
        is_system = "system" in (getattr(entity, "patterns", None) or [])
        access = getattr(entity, "access", None)
        if access is not None and not is_system:
            has_permits = bool(getattr(access, "permissions", None))
            has_scopes = bool(getattr(access, "scopes", None))
            if has_permits and not has_scopes:
                warnings.append(
                    f"Entity '{entity.name}' has permit: rules but no scope: blocks — "
                    f"API list endpoint will default-deny (return 0 rows). "
                    f"Add scope: blocks or use 'scope: all for: *' for public access."
                )

    return errors, warnings


def validate_surfaces(appspec: ir.AppSpec) -> tuple[list[str], list[str]]:
    """
    Validate all surfaces for semantic correctness.

    Checks:
    - Entity references exist (already done by linker, but check fields)
    - Surface fields match entity fields when entity_ref is set
    - Actions have valid outcomes
    - Modes are appropriate for the surface structure

    Returns:
        Tuple of (errors, warnings)
    """
    errors = []
    warnings = []

    for surface in appspec.surfaces:
        # Validate entity field matching
        if surface.entity_ref:
            entity = appspec.get_entity(surface.entity_ref)
            if entity:
                # Check that fields in surface sections match entity fields
                for section in surface.sections:
                    for element in section.elements:
                        if not entity.get_field(element.field_name):
                            errors.append(
                                f"Surface '{surface.name}' section '{section.name}' "
                                f"references non-existent field '{element.field_name}' "
                                f"from entity '{entity.name}'"
                            )

        # Validate search fields reference valid entity fields
        if surface.search_fields and surface.entity_ref:
            entity = appspec.get_entity(surface.entity_ref)
            if entity:
                for sf in surface.search_fields:
                    if not entity.get_field(sf):
                        warnings.append(
                            f"Surface '{surface.name}' search field '{sf}' "
                            f"does not exist on entity '{entity.name}'"
                        )

        # Warn if no sections
        if not surface.sections:
            warnings.append(f"Surface '{surface.name}' has no sections defined")

        # Check mode consistency
        if surface.mode == ir.SurfaceMode.CREATE:
            if not surface.entity_ref:
                warnings.append(
                    f"Surface '{surface.name}' has mode 'create' but no entity reference"
                )
        elif surface.mode == ir.SurfaceMode.EDIT:
            if not surface.entity_ref:
                warnings.append(f"Surface '{surface.name}' has mode 'edit' but no entity reference")
        elif surface.mode == ir.SurfaceMode.VIEW:
            if not surface.entity_ref:
                warnings.append(f"Surface '{surface.name}' has mode 'view' but no entity reference")

    return errors, warnings


def validate_experiences(appspec: ir.AppSpec) -> tuple[list[str], list[str]]:
    """
    Validate all experiences for semantic correctness.

    Checks:
    - All steps are reachable from start step
    - No infinite loops without exit
    - Step kinds match targets
    - Transitions are valid

    Returns:
        Tuple of (errors, warnings)
    """
    errors = []
    warnings = []

    for experience in appspec.experiences:
        # Check for empty experiences
        if not experience.steps:
            errors.append(f"Experience '{experience.name}' has no steps")
            continue

        # Build reachability graph
        reachable = set()
        to_visit = {experience.start_step}

        while to_visit:
            step_name = to_visit.pop()
            if step_name in reachable:
                continue
            reachable.add(step_name)

            step = experience.get_step(step_name)
            if step:
                for transition in step.transitions:
                    to_visit.add(transition.next_step)

        # Check for unreachable steps
        all_steps = {step.name for step in experience.steps}
        unreachable = all_steps - reachable
        if unreachable:
            warnings.append(f"Experience '{experience.name}' has unreachable steps: {unreachable}")

        # Check step consistency
        for step in experience.steps:
            # Validate step kind matches target
            if step.kind == ir.StepKind.SURFACE:
                if not step.surface and not step.entity_ref:
                    errors.append(
                        f"Experience '{experience.name}' step '{step.name}' "
                        f"has kind 'surface' but no surface or entity target"
                    )
            elif step.kind == ir.StepKind.INTEGRATION:
                if not step.integration or not step.action:
                    errors.append(
                        f"Experience '{experience.name}' step '{step.name}' "
                        f"has kind 'integration' but missing integration or action"
                    )

            # Warn about steps with no transitions — but only if the step
            # is NOT the last defined step (terminal steps at the end of a
            # flow are expected, e.g. "complete", "done", "success").
            if not step.transitions:
                is_last = step == experience.steps[-1] if experience.steps else False
                if not is_last:
                    warnings.append(
                        f"Experience '{experience.name}' step '{step.name}' "
                        f"has no transitions (terminal step)"
                    )

            # Validate saves_to format
            if step.saves_to:
                st_parts = step.saves_to.split(".", 1)
                if len(st_parts) != 2 or st_parts[0] != "context":
                    errors.append(
                        f"Experience '{experience.name}' step '{step.name}' "
                        f"saves_to must be 'context.<varname>', got '{step.saves_to}'"
                    )
                elif experience.context:
                    ctx_names = {cv.name for cv in experience.context}
                    if st_parts[1] not in ctx_names:
                        errors.append(
                            f"Experience '{experience.name}' step '{step.name}' "
                            f"saves_to references unknown context variable '{st_parts[1]}'"
                        )

            # Validate prefill references
            if step.prefills and experience.context:
                ctx_names = {cv.name for cv in experience.context}
                for pf in step.prefills:
                    if not pf.expression.startswith('"'):
                        pf_parts = pf.expression.split(".")
                        if pf_parts and pf_parts[0] == "context" and len(pf_parts) >= 2:
                            if pf_parts[1] not in ctx_names:
                                warnings.append(
                                    f"Experience '{experience.name}' step '{step.name}' "
                                    f"prefill references unknown context variable "
                                    f"'{pf_parts[1]}'"
                                )

            # Warn about when guard on terminal steps
            if step.when and not step.transitions:
                warnings.append(
                    f"Experience '{experience.name}' step '{step.name}' "
                    f"has a 'when' guard but no transitions to skip to"
                )

        # Validate context variable declarations
        if experience.context:
            seen_names: set[str] = set()
            for cv in experience.context:
                if cv.name in seen_names:
                    errors.append(
                        f"Experience '{experience.name}' has duplicate context variable '{cv.name}'"
                    )
                seen_names.add(cv.name)

    return errors, warnings


def validate_services(appspec: ir.AppSpec) -> tuple[list[str], list[str]]:
    """
    Validate all services for semantic correctness.

    Checks:
    - Spec URLs are valid
    - Auth profiles are complete
    - Required fields are present

    Returns:
        Tuple of (errors, warnings)
    """
    errors = []
    warnings = []

    for api in appspec.apis:
        # Check spec is provided
        if not api.spec_url and not api.spec_inline:
            errors.append(f"API '{api.name}' has no spec (url or inline)")

        # Validate URL format if provided
        if api.spec_url:
            try:
                parsed = urlparse(api.spec_url)
                if not parsed.scheme or not parsed.netloc:
                    warnings.append(f"API '{api.name}' has invalid spec URL: {api.spec_url}")
            except Exception:
                warnings.append(f"API '{api.name}' has malformed spec URL: {api.spec_url}")

        # Check auth profile
        if api.auth_profile.kind in (ir.AuthKind.OAUTH2_LEGACY, ir.AuthKind.OAUTH2_PKCE):
            # OAuth2 APIs should specify scopes
            if "scopes" not in api.auth_profile.options:
                warnings.append(f"API '{api.name}' uses OAuth2 but doesn't specify scopes")

    return errors, warnings


def validate_foreign_models(appspec: ir.AppSpec) -> tuple[list[str], list[str]]:
    """
    Validate all foreign models for semantic correctness.

    Checks:
    - Key fields are defined
    - Constraints are valid
    - Fields have appropriate types

    Returns:
        Tuple of (errors, warnings)
    """
    errors: list[str] = []
    warnings: list[str] = []

    for foreign_model in appspec.foreign_models:
        # Check key fields exist
        if not foreign_model.key_fields:
            errors.append(f"Foreign model '{foreign_model.name}' has no key fields")

        for key_field in foreign_model.key_fields:
            if not foreign_model.get_field(key_field):
                errors.append(
                    f"Foreign model '{foreign_model.name}' key field '{key_field}' "
                    f"is not defined in fields"
                )

        # Check for conflicting constraints
        constraint_kinds = [c.kind for c in foreign_model.constraints]
        if ir.ForeignConstraintKind.READ_ONLY in constraint_kinds:
            if ir.ForeignConstraintKind.BATCH_IMPORT in constraint_kinds:
                # This is ok - read-only can still be imported
                pass

    return errors, warnings


def validate_integrations(appspec: ir.AppSpec) -> tuple[list[str], list[str]]:
    """
    Validate all integrations for semantic correctness.

    Checks:
    - Service and foreign model references
    - Action/sync structure (simplified for v0.1)

    Returns:
        Tuple of (errors, warnings)
    """
    errors: list[str] = []
    warnings: list[str] = []

    for integration in appspec.integrations:
        # Check that integration uses at least one service
        if not integration.api_refs:
            warnings.append(f"Integration '{integration.name}' doesn't use any APIs")

        # Check that integration has actions, syncs, or mappings (v0.30.0+)
        if not integration.actions and not integration.syncs and not integration.mappings:
            warnings.append(f"Integration '{integration.name}' has no actions, syncs, or mappings")

    return errors, warnings


def validate_ux_specs(appspec: ir.AppSpec) -> tuple[list[str], list[str]]:
    """
    Validate UX semantic layer specifications.

    Checks:
    - UX show fields exist in referenced entity
    - UX sort fields exist in referenced entity
    - UX filter fields exist in referenced entity
    - UX search fields exist in referenced entity
    - Attention signal conditions reference valid fields
    - Persona variants have valid configurations
    - Workspace regions reference valid sources

    Returns:
        Tuple of (errors, warnings)
    """
    errors = []
    warnings = []

    for surface in appspec.surfaces:
        if not surface.ux:
            continue

        entity = None
        if surface.entity_ref:
            entity = appspec.get_entity(surface.entity_ref)

        ux = surface.ux

        # Validate show fields
        if ux.show and entity:
            entity_field_names = {f.name for f in entity.fields}
            for field_name in ux.show:
                if field_name not in entity_field_names:
                    errors.append(
                        f"Surface '{surface.name}' UX show references non-existent "
                        f"field '{field_name}' from entity '{entity.name}'"
                    )

        # Validate sort fields
        if ux.sort and entity:
            entity_field_names = {f.name for f in entity.fields}
            for sort_spec in ux.sort:
                if sort_spec.field not in entity_field_names:
                    errors.append(
                        f"Surface '{surface.name}' UX sort references non-existent "
                        f"field '{sort_spec.field}' from entity '{entity.name}'"
                    )

        # Validate filter fields
        if ux.filter and entity:
            entity_field_names = {f.name for f in entity.fields}
            for field_name in ux.filter:
                if field_name not in entity_field_names:
                    errors.append(
                        f"Surface '{surface.name}' UX filter references non-existent "
                        f"field '{field_name}' from entity '{entity.name}'"
                    )

        # Validate search fields
        if ux.search and entity:
            entity_field_names = {f.name for f in entity.fields}
            for field_name in ux.search:
                if field_name not in entity_field_names:
                    errors.append(
                        f"Surface '{surface.name}' UX search references non-existent "
                        f"field '{field_name}' from entity '{entity.name}'"
                    )

        # Validate attention signals
        for signal in ux.attention_signals:
            field_errors = _validate_condition_fields(
                signal.condition,
                entity,
                f"Surface '{surface.name}' attention signal",
                appspec=appspec,
            )
            errors.extend(field_errors)

            # Warn if attention signal has no message
            if not signal.message:
                warnings.append(
                    f"Surface '{surface.name}' attention signal at level "
                    f"'{signal.level.value}' has no message"
                )

        # Validate persona variants
        for variant in ux.persona_variants:
            # Validate scope condition fields
            if variant.scope:
                field_errors = _validate_condition_fields(
                    variant.scope,
                    entity,
                    f"Surface '{surface.name}' persona '{variant.persona}' scope",
                    appspec=appspec,
                )
                errors.extend(field_errors)

            # Validate show/hide fields
            if entity:
                entity_field_names = {f.name for f in entity.fields}
                for field_name in variant.show:
                    if field_name not in entity_field_names:
                        errors.append(
                            f"Surface '{surface.name}' persona '{variant.persona}' "
                            f"show references non-existent field '{field_name}'"
                        )
                for field_name in variant.hide:
                    if field_name not in entity_field_names:
                        errors.append(
                            f"Surface '{surface.name}' persona '{variant.persona}' "
                            f"hide references non-existent field '{field_name}'"
                        )

    # Validate workspaces
    for workspace in appspec.workspaces:
        for region in workspace.regions:
            # Skip source validation for aggregate-only regions
            if region.source is None:
                continue

            # Check that source references a valid entity
            source_entity_name = (
                region.source.split(".")[0] if "." in region.source else region.source
            )
            entity = appspec.get_entity(source_entity_name)
            if not entity:
                errors.append(
                    f"Workspace '{workspace.name}' region '{region.name or region.source}' "
                    f"references non-existent entity '{source_entity_name}'"
                )
            else:
                # Validate filter conditions
                if region.filter:
                    region_id = region.name or region.source
                    ctx = f"Workspace '{workspace.name}' region '{region_id}' filter"
                    field_errors = _validate_condition_fields(
                        region.filter,
                        entity,
                        ctx,
                        appspec=appspec,
                    )
                    errors.extend(field_errors)

                # Validate sort fields
                if region.sort:
                    entity_field_names = {f.name for f in entity.fields}
                    for sort_spec in region.sort:
                        if sort_spec.field not in entity_field_names:
                            errors.append(
                                f"Workspace '{workspace.name}' region "
                                f"'{region.name or region.source}' sort references "
                                f"non-existent field '{sort_spec.field}'"
                            )

    return errors, warnings


def _validate_condition_fields(
    condition: ir.ConditionExpr,
    entity: ir.EntitySpec | None,
    context: str,
    appspec: ir.AppSpec | None = None,
) -> list[str]:
    """
    Validate that condition expression references valid entity fields.

    Supports FK traversal: ``assessment_event.department`` resolves
    ``assessment_event`` as a ref field on *entity*, then checks
    ``department`` exists on the referenced entity. Multi-hop paths
    like ``mark_scheme.subject.department`` are also supported.

    Args:
        condition: The condition expression to validate
        entity: The entity to validate fields against (may be None)
        context: Context string for error messages
        appspec: Full app spec, needed to resolve FK traversal paths

    Returns:
        List of error messages
    """
    errors: list[str] = []

    if not entity:
        return errors

    entity_field_names = {f.name for f in entity.fields}
    entity_fields_by_name = {f.name: f for f in entity.fields}

    def _resolve_fk_path(field_path: str) -> str | None:
        """Resolve a dotted FK path. Returns an error message or None if valid."""
        parts = field_path.split(".")
        current_entity = entity
        current_fields = entity_fields_by_name

        for i, part in enumerate(parts):
            if part not in current_fields:
                source = current_entity.name if current_entity else "?"
                return (
                    f"{context} references non-existent field '{field_path}' from entity '{source}'"
                )

            # If there are more segments, this part must be a ref field
            if i < len(parts) - 1:
                field_spec = current_fields[part]
                if field_spec.type.kind != ir.FieldTypeKind.REF or not field_spec.type.ref_entity:
                    return (
                        f"{context} field '{'.'.join(parts[: i + 1])}' is not a "
                        f"reference field on entity '{current_entity.name if current_entity else '?'}', "
                        f"cannot traverse to '{'.'.join(parts[i + 1 :])}'"
                    )
                if not appspec:
                    return None  # Can't resolve further without appspec; assume valid
                ref_entity = appspec.get_entity(field_spec.type.ref_entity)
                if not ref_entity:
                    return (
                        f"{context} field '{'.'.join(parts[: i + 1])}' references "
                        f"entity '{field_spec.type.ref_entity}' which does not exist"
                    )
                current_entity = ref_entity
                current_fields = {f.name: f for f in ref_entity.fields}

        return None  # Valid

    def check_comparison(comparison: ir.Comparison) -> None:
        """Validate field references in a comparison expression."""
        if comparison.field:
            if "." in comparison.field:
                err = _resolve_fk_path(comparison.field)
                if err:
                    errors.append(err)
            elif comparison.field not in entity_field_names:
                errors.append(
                    f"{context} references non-existent field '{comparison.field}' "
                    f"from entity '{entity.name}'"
                )
        if comparison.function and comparison.function.argument not in entity_field_names:
            errors.append(
                f"{context} function '{comparison.function.name}' references "
                f"non-existent field '{comparison.function.argument}' from entity '{entity.name}'"
            )

    def check_condition(cond: ir.ConditionExpr) -> None:
        """Recursively validate a condition expression tree."""
        if cond.comparison:
            check_comparison(cond.comparison)
        elif cond.is_compound:
            if cond.left:
                check_condition(cond.left)
            if cond.right:
                check_condition(cond.right)

    check_condition(condition)
    return errors


def validate_money_fields(appspec: ir.AppSpec) -> tuple[list[str], list[str]]:
    """
    Validate that monetary fields use the correct type in FACT/INTENT streams.

    The Money type (amount_minor: int + currency: str) is REQUIRED for all
    monetary values in event payloads. Using float or decimal for money
    causes precision issues and JSON serialization problems.

    Checks:
    - Fields with money-like names in FACT/INTENT streams must use 'money' type
    - Rejects 'decimal' and warns about 'int' for money-like field names

    Returns:
        Tuple of (errors, warnings)
    """
    errors: list[str] = []
    warnings: list[str] = []

    # Check HLESS streams
    for stream in appspec.streams:
        # Only check FACT and INTENT streams (not OBSERVATION or DERIVATION)
        if stream.record_kind not in (ir.RecordKind.FACT, ir.RecordKind.INTENT):
            continue

        for schema in stream.schemas:
            for field in schema.fields:
                if not ir.is_money_field_name(field.name):
                    continue

                # Check for forbidden types
                if field.type.kind == ir.FieldTypeKind.DECIMAL:
                    errors.append(
                        f"Stream '{stream.name}' schema '{schema.name}' field '{field.name}' "
                        f"uses 'decimal' type for monetary value. "
                        f"Use 'money' type instead (expands to currency + amount_minor:int). "
                        f"Rationale: decimal causes JSON serialization errors and precision issues."
                    )

                # Warn about raw int (might be intentional minor units, but not explicit)
                elif field.type.kind == ir.FieldTypeKind.INT:
                    # Only warn if it looks like a standalone money field without currency
                    # If there's a corresponding currency field, assume it's intentional
                    field_names = {f.name for f in schema.fields}
                    has_currency = any("currency" in name.lower() for name in field_names)
                    if not has_currency:
                        warnings.append(
                            f"Stream '{stream.name}' schema '{schema.name}' field '{field.name}' "
                            f"uses 'int' for monetary value without a currency field. "
                            f"Consider using 'money' type for explicit currency handling."
                        )

    # Also check entity fields (less strict - warning only)
    for entity in appspec.domain.entities:
        for field in entity.fields:
            if not ir.is_money_field_name(field.name):
                continue

            # For entities, using decimal is okay but money is preferred
            if field.type.kind == ir.FieldTypeKind.DECIMAL:
                # Check if there's a corresponding currency field
                field_names = {f.name for f in entity.fields}
                has_currency = any("currency" in name.lower() for name in field_names)
                if not has_currency:
                    warnings.append(
                        f"Entity '{entity.name}' field '{field.name}' uses 'decimal' "
                        f"for monetary value without a currency field. "
                        f"Consider using 'money' type or adding a currency field."
                    )

    return errors, warnings


def _validate_account_codes(
    appspec: ir.AppSpec,
    errors: list[str],
    warnings: list[str],
) -> tuple[set[str], dict[str, ir.LedgerSpec]]:
    """Validate ledger names, account codes, currency, sync targets, and intent.

    Returns:
        (ledger_names, ledger_by_name) for use by transaction validation.
    """
    ledger_names: set[str] = set()
    ledger_by_name: dict[str, ir.LedgerSpec] = {}
    account_codes_by_ledger_id: dict[int, set[int]] = {}

    for ledger in appspec.ledgers:
        # Check unique names
        if ledger.name in ledger_names:
            errors.append(f"Duplicate ledger name: '{ledger.name}'")
        ledger_names.add(ledger.name)
        ledger_by_name[ledger.name] = ledger

        # Check account_code uniqueness within ledger_id
        if ledger.ledger_id not in account_codes_by_ledger_id:
            account_codes_by_ledger_id[ledger.ledger_id] = set()
        if ledger.account_code in account_codes_by_ledger_id[ledger.ledger_id]:
            errors.append(
                f"Ledger '{ledger.name}': account_code {ledger.account_code} "
                f"is already used in ledger_id {ledger.ledger_id}"
            )
        account_codes_by_ledger_id[ledger.ledger_id].add(ledger.account_code)

        # Validate currency format
        if len(ledger.currency) != 3 or not ledger.currency.isalpha():
            errors.append(
                f"Ledger '{ledger.name}': currency '{ledger.currency}' "
                f"must be a 3-letter ISO 4217 code (e.g., GBP, USD, EUR)"
            )

        # Check sync target if specified
        if ledger.sync:
            entity_name = ledger.sync.target_entity
            entity = appspec.get_entity(entity_name)
            if not entity:
                errors.append(
                    f"Ledger '{ledger.name}': sync target entity '{entity_name}' not found"
                )
            else:
                field = entity.get_field(ledger.sync.target_field)
                if not field:
                    errors.append(
                        f"Ledger '{ledger.name}': sync target field "
                        f"'{entity_name}.{ledger.sync.target_field}' not found"
                    )

        # Warn about missing intent
        if not ledger.intent:
            warnings.append(
                f"Ledger '{ledger.name}': consider adding an 'intent' field "
                f"to document the business purpose"
            )

    return ledger_names, ledger_by_name


def _validate_transaction_transfers(
    txn: ir.TransactionSpec,
    ledger_names: set[str],
    ledger_by_name: dict[str, ir.LedgerSpec],
    errors: list[str],
    warnings: list[str],
) -> None:
    """Validate transfers within a single transaction."""
    transfer_codes: set[int] = set()
    for transfer in txn.transfers:
        # Check ledger references exist
        if transfer.debit_ledger not in ledger_names:
            errors.append(
                f"Transaction '{txn.name}' transfer '{transfer.name}': "
                f"debit ledger '{transfer.debit_ledger}' not found"
            )
        if transfer.credit_ledger not in ledger_names:
            errors.append(
                f"Transaction '{txn.name}' transfer '{transfer.name}': "
                f"credit ledger '{transfer.credit_ledger}' not found"
            )

        # Validate ledgers are in same ledger_id (TigerBeetle requirement)
        if transfer.debit_ledger in ledger_by_name and transfer.credit_ledger in ledger_by_name:
            debit_ledger = ledger_by_name[transfer.debit_ledger]
            credit_ledger = ledger_by_name[transfer.credit_ledger]
            if debit_ledger.ledger_id != credit_ledger.ledger_id:
                errors.append(
                    f"Transaction '{txn.name}' transfer '{transfer.name}': "
                    f"debit ledger '{transfer.debit_ledger}' "
                    f"(ledger_id={debit_ledger.ledger_id}) "
                    f"and credit ledger '{transfer.credit_ledger}' "
                    f"(ledger_id={credit_ledger.ledger_id}) "
                    f"must be in the same ledger_id"
                )

            # Validate currency match
            if debit_ledger.currency != credit_ledger.currency:
                errors.append(
                    f"Transaction '{txn.name}' transfer '{transfer.name}': "
                    f"currency mismatch between '{transfer.debit_ledger}' "
                    f"({debit_ledger.currency}) and '{transfer.credit_ledger}' "
                    f"({credit_ledger.currency})"
                )

        # Check transfer code uniqueness
        if transfer.code in transfer_codes:
            warnings.append(
                f"Transaction '{txn.name}' transfer '{transfer.name}': "
                f"code {transfer.code} is duplicated (consider unique codes for debugging)"
            )
        transfer_codes.add(transfer.code)

    # Multi-leg transaction validation
    if len(txn.transfers) > 1:
        for transfer in txn.transfers[:-1]:
            if not transfer.is_linked:
                warnings.append(
                    f"Transaction '{txn.name}' transfer '{transfer.name}': "
                    f"multi-leg transactions should use 'linked' flag on all "
                    f"but the last transfer "
                    f"to ensure atomic execution"
                )


def validate_ledgers(appspec: ir.AppSpec) -> tuple[list[str], list[str]]:
    """
    Validate TigerBeetle ledger and transaction specifications (v0.24.0).

    Checks:
    - Ledger names are unique
    - Account codes are unique within a ledger_id
    - Currency is valid ISO 4217 format
    - Transaction transfers reference valid ledgers
    - Transaction idempotency_key is defined
    - Transfer codes are unique within a transaction
    - Multi-leg transactions use 'linked' flag correctly

    Returns:
        Tuple of (errors, warnings)
    """
    errors: list[str] = []
    warnings: list[str] = []

    if not appspec.ledgers:
        return errors, warnings

    ledger_names, ledger_by_name = _validate_account_codes(appspec, errors, warnings)

    for txn in appspec.transactions:
        if not txn.idempotency_key:
            errors.append(
                f"Transaction '{txn.name}': idempotency_key is required "
                f"for TigerBeetle transfer deduplication"
            )

        _validate_transaction_transfers(txn, ledger_names, ledger_by_name, errors, warnings)

        if not txn.intent:
            warnings.append(
                f"Transaction '{txn.name}': consider adding an 'intent' field "
                f"to document the business purpose"
            )

    return errors, warnings


def validate_event_payload_secrets(appspec: ir.AppSpec) -> tuple[list[str], list[str]]:
    """
    Validate that event payloads do not contain secret/sensitive fields.

    Events are typically logged, replayed, and stored long-term. Including
    passwords, API keys, tokens, or other secrets in event payloads creates
    security vulnerabilities.

    Checks:
    - HLESS stream schema fields for secret-like names
    - Event model custom fields for secret-like names
    - Entity fields used as event payloads for secret-like names

    Returns:
        Tuple of (errors, warnings)
        - errors: Fields that are almost certainly secrets (password, api_key, etc.)
        - warnings: Fields that might be secrets (token, hash, auth, etc.)
    """
    errors: list[str] = []
    warnings: list[str] = []

    # High-risk patterns that are almost always actual secrets
    high_risk_patterns = {"password", "passwd", "pwd", "api_key", "apikey", "secret", "secret_key"}

    def check_field(field_name: str, context: str) -> None:
        if not is_secret_field_name(field_name):
            return

        lower_name = field_name.lower()
        # High-risk patterns are errors
        if any(pattern in lower_name for pattern in high_risk_patterns):
            errors.append(
                f"{context} field '{field_name}' appears to contain a secret. "
                f"Secrets MUST NOT be included in event payloads. "
                f"Store secrets securely and use references instead."
            )
        else:
            # Medium-risk patterns are warnings
            warnings.append(
                f"{context} field '{field_name}' may contain sensitive data. "
                f"Ensure this field does not contain secrets, tokens, or credentials. "
                f"If it does, remove it from the event payload."
            )

    # Check HLESS streams
    for stream in appspec.streams:
        for schema in stream.schemas:
            for schema_field in schema.fields:
                check_field(schema_field.name, f"Stream '{stream.name}' schema '{schema.name}'")

    # Check event model (custom event fields)
    if appspec.event_model:
        for event in appspec.event_model.events:
            for event_field in event.custom_fields:
                check_field(event_field.name, f"Event '{event.name}'")

    return errors, warnings


def _detect_dead_constructs(appspec: ir.AppSpec) -> list[str]:
    """Detect unreferenced surfaces, orphan entities, and unreachable experiences.

    Builds a reachability graph from entry points (workspaces, experiences,
    processes) through surfaces to entities. Reports constructs that are
    defined but never referenced from any entry point.
    """
    warnings: list[str] = []

    # --- Collect all defined constructs ---
    all_entities = {e.name for e in appspec.domain.entities}
    entity_locs = {e.name: e.source for e in appspec.domain.entities}
    all_surfaces = {s.name for s in appspec.surfaces}
    surface_locs = {s.name: s.source for s in appspec.surfaces}
    # --- Collect all entity references ---
    used_entities: set[str] = set()

    # Entities referenced by other entities (field refs)
    for entity in appspec.domain.entities:
        for field in entity.fields:
            if field.type.kind == ir.FieldTypeKind.REF and field.type.ref_entity:
                used_entities.add(field.type.ref_entity)

    # Entities referenced by surfaces
    for surface in appspec.surfaces:
        if surface.entity_ref:
            used_entities.add(surface.entity_ref)

    # Entities referenced by workspace regions (source field)
    for workspace in appspec.workspaces:
        for region in workspace.regions:
            if region.source and region.source in all_entities:
                used_entities.add(region.source)

    # Entities referenced by process triggers
    for process in appspec.processes:
        if process.trigger and process.trigger.entity_name:
            used_entities.add(process.trigger.entity_name)

    # Entities referenced by integration mappings
    for integration in appspec.integrations:
        for mapping in integration.mappings:
            if mapping.entity_ref:
                used_entities.add(mapping.entity_ref)

    unused_entities = all_entities - used_entities
    if unused_entities:
        for name in sorted(unused_entities):
            loc = entity_locs.get(name)
            loc_suffix = f" (defined at {loc})" if loc else ""
            warnings.append(f"Dead construct: entity '{name}' is never referenced{loc_suffix}")

    # --- Collect all surface references ---
    used_surfaces: set[str] = set()

    # Surfaces referenced by workspace regions (action field)
    for workspace in appspec.workspaces:
        for region in workspace.regions:
            if region.action and region.action in all_surfaces:
                used_surfaces.add(region.action)
            # source can also be a surface name
            if region.source and region.source in all_surfaces:
                used_surfaces.add(region.source)

    # Surfaces referenced by experience steps
    for experience in appspec.experiences:
        for step in experience.steps:
            if step.surface:
                used_surfaces.add(step.surface)

    # Surfaces referenced by process human_task steps
    for process in appspec.processes:
        for proc_step in process.steps:
            if proc_step.human_task and proc_step.human_task.surface:
                used_surfaces.add(proc_step.human_task.surface)

    # Surfaces belonging to entities used in workspace regions are implicitly
    # alive — entity CRUD surfaces are navigable from workspace detail pages
    # even when not explicitly wired to a workspace action.
    # Entities listed in nav_groups are also navigable via entity nav routes
    # (e.g. /app/trust, /app/trust/create) even without a workspace region.
    workspace_entities: set[str] = set()
    for workspace in appspec.workspaces:
        for region in workspace.regions:
            if region.source and region.source in all_entities:
                workspace_entities.add(region.source)
            for src in region.sources:
                if src in all_entities:
                    workspace_entities.add(src)
        for nav_group in workspace.nav_groups:
            for nav_item in nav_group.items:
                if nav_item.entity in all_entities:
                    workspace_entities.add(nav_item.entity)
    for surface in appspec.surfaces:
        if surface.entity_ref and surface.entity_ref in workspace_entities:
            used_surfaces.add(surface.name)

    unused_surfaces = all_surfaces - used_surfaces
    if unused_surfaces:
        for name in sorted(unused_surfaces):
            loc = surface_locs.get(name)
            loc_suffix = f" (defined at {loc})" if loc else ""
            warnings.append(
                f"Dead construct: surface '{name}' is not referenced by any "
                f"workspace, experience, or process{loc_suffix}"
            )

    # --- Collect all experience references ---
    # Experiences are entry points themselves, but check if any are completely
    # disconnected (no workspace or navigation references them).
    # For now, experiences are considered used if they exist — they are
    # top-level entry points like workspaces.

    return warnings


def _lint_naming_conventions(appspec: ir.AppSpec) -> list[str]:
    """Check entity PascalCase and field snake_case naming."""
    warnings: list[str] = []
    for entity in appspec.domain.entities:
        if not entity.name[0].isupper():
            warnings.append(f"Entity '{entity.name}' should use PascalCase naming")
        for field in entity.fields:
            if field.name != field.name.lower():
                if "_" in field.name or not any(c.isupper() for c in field.name[1:]):
                    continue  # It's snake_case or lowercase, ok
                warnings.append(
                    f"Entity '{entity.name}' field '{field.name}' should use snake_case naming"
                )
    return warnings


def _lint_missing_titles(appspec: ir.AppSpec) -> list[str]:
    """Check for entities and surfaces without titles."""
    warnings: list[str] = []
    for entity in appspec.domain.entities:
        if not entity.title:
            warnings.append(f"Entity '{entity.name}' has no title")
    for surface in appspec.surfaces:
        if not surface.title:
            warnings.append(f"Surface '{surface.name}' has no title")
    return warnings


def _lint_workspace_personas(appspec: ir.AppSpec) -> list[str]:
    """Check for workspaces without associated personas."""
    if not appspec.workspaces:
        return []

    warnings: list[str] = []
    persona_ids = {p.id for p in appspec.personas}

    workspaces_with_personas: set[str] = set()
    for workspace in appspec.workspaces:
        if workspace.ux and workspace.ux.persona_variants:
            for variant in workspace.ux.persona_variants:
                if variant.persona in persona_ids:
                    workspaces_with_personas.add(workspace.name)
                    break

    for persona in appspec.personas:
        if persona.default_workspace:
            for ws in appspec.workspaces:
                if ws.name == persona.default_workspace:
                    workspaces_with_personas.add(ws.name)

    for workspace in appspec.workspaces:
        if workspace.name not in workspaces_with_personas:
            warnings.append(
                f"Workspace '{workspace.name}' has no associated persona. "
                f"Consider adding a persona for role-based access control."
            )
    return warnings


def _lint_workspace_routing(appspec: ir.AppSpec) -> list[str]:
    """Check for workspaces with no routable content."""
    if not appspec.workspaces:
        return []

    warnings: list[str] = []
    surface_entities = {s.entity_ref for s in appspec.surfaces if s.entity_ref}
    for workspace in appspec.workspaces:
        region_sources = {r.source for r in workspace.regions if r.source}
        if not region_sources:
            warnings.append(
                f"Workspace '{workspace.name}' has no regions with entity sources. "
                f"It will not generate any routes or render content."
            )
        elif not region_sources & surface_entities:
            warnings.append(
                f"Workspace '{workspace.name}' references entities "
                f"({', '.join(sorted(region_sources))}) that have no surfaces. "
                f"It will not generate any routes."
            )
    return warnings


def _lint_workspace_access_declarations(appspec: ir.AppSpec) -> list[str]:
    """Warn when a workspace has no access: declaration and personas are defined.

    When personas exist but a workspace carries no explicit ``access:`` block,
    all authenticated users can reach that workspace regardless of their role.
    This is almost never intentional, so we surface it as a lint warning.

    A workspace is considered *declared* if it has an explicit ``access:``
    block with at least one ``allow_personas`` entry, OR if at least one
    persona lists it as its ``default_workspace`` (in which case the runtime
    infers the restriction automatically).
    """
    if not appspec.workspaces or not appspec.personas:
        return []

    # Workspaces claimed by at least one persona's default_workspace
    claimed_by_default: set[str] = {
        p.default_workspace for p in appspec.personas if p.default_workspace
    }

    warnings: list[str] = []
    for workspace in appspec.workspaces:
        ws_access = getattr(workspace, "access", None)
        has_explicit_access = bool(ws_access and ws_access.allow_personas)
        if not has_explicit_access and workspace.name not in claimed_by_default:
            warnings.append(
                f"Workspace '{workspace.name}' has no access: declaration and no persona "
                f"lists it as default_workspace. All authenticated users can access it. "
                f"Add 'access: allow_personas [<persona_id>]' or set default_workspace "
                f"on the relevant persona(s)."
            )
    return warnings


def _lint_list_surface_ux(appspec: ir.AppSpec) -> list[str]:
    """Check list surfaces for sort/filter/search/empty completeness."""
    warnings: list[str] = []
    for surface in appspec.surfaces:
        if surface.mode != ir.SurfaceMode.LIST:
            continue
        if not surface.ux:
            hints: list[str] = []
            surf_entity = appspec.get_entity(surface.entity_ref) if surface.entity_ref else None
            if surf_entity:
                filterable = [
                    f.name
                    for f in surf_entity.fields
                    if f.type
                    and f.type.kind in (ir.FieldTypeKind.ENUM, ir.FieldTypeKind.BOOL)
                    and not f.is_primary_key
                ]
                if surf_entity.state_machine:
                    sm_field = surf_entity.state_machine.status_field
                    if sm_field not in filterable:
                        filterable.insert(0, sm_field)
                searchable = [
                    f.name
                    for f in surf_entity.fields
                    if f.type
                    and f.type.kind in (ir.FieldTypeKind.STR, ir.FieldTypeKind.TEXT)
                    and not f.is_primary_key
                ]
                if filterable:
                    hints.append(f"filter: {', '.join(filterable)}")
                if searchable:
                    hints.append(f"search: {', '.join(searchable)}")
            hints.insert(0, "sort: (e.g. created_at desc)")
            hints.append('empty: "No items yet."')
            warnings.append(
                f"Surface '{surface.name}' (mode: list) has no ux block. "
                f"Consider adding: {'; '.join(hints)}"
            )
        else:
            ux = surface.ux
            missing = []
            if not ux.sort:
                missing.append("sort")
            if not ux.filter:
                missing.append("filter")
            if not ux.search:
                missing.append("search")
            if not ux.empty_message:
                missing.append("empty")
            if missing:
                warnings.append(
                    f"Surface '{surface.name}' ux block is missing: "
                    f"{', '.join(missing)}. These enhance DataTable UX."
                )
    return warnings


_INTEGRATION_KEYWORDS = {
    "api",
    "hmrc",
    "xero",
    "sync",
    "webhook",
    "stripe",
    "twilio",
    "sendgrid",
    "mailgun",
    "slack",
    "zapier",
    "salesforce",
}


def _lint_integration_bindings(appspec: ir.AppSpec) -> list[str]:
    """Check if stories reference integrations without matching service declarations."""
    stories = list(appspec.stories) if appspec.stories else []
    if not stories:
        return []

    warnings: list[str] = []
    declared_integrations = {i.name.lower() for i in appspec.integrations}
    declared_services = {s.name.lower() for s in appspec.domain_services}
    declared_bindings = declared_integrations | declared_services

    for story in stories:
        title_words = set(story.title.lower().split())
        integration_hits = title_words & _INTEGRATION_KEYWORDS

        if integration_hits and not declared_bindings:
            warnings.append(
                f"Story '{story.story_id}' ({story.title}) references integration "
                f"keywords ({', '.join(integration_hits)}) but no integrations or "
                f"services are declared in the DSL."
            )
        elif integration_hits:
            scope_entities = {s.lower() for s in story.scope}
            has_matching_binding = (
                any(
                    any(entity in binding for entity in scope_entities)
                    for binding in declared_bindings
                )
                if scope_entities
                else bool(declared_bindings)
            )

            if not has_matching_binding and scope_entities:
                warnings.append(
                    f"Story '{story.story_id}' references integrations but no "
                    f"service/integration binds to scope entities "
                    f"({', '.join(story.scope)})."
                )
    return warnings


def _lint_process_effects(appspec: ir.AppSpec) -> list[str]:
    """Check process step effects reference valid entities and fields."""
    warnings: list[str] = []
    entity_names = {e.name for e in appspec.domain.entities}

    for process in appspec.processes:
        for step in process.steps:
            for effect in step.effects:
                # Check entity reference
                if effect.entity_name not in entity_names:
                    warnings.append(
                        f"Process '{process.name}' step '{step.name}' effect "
                        f"references non-existent entity '{effect.entity_name}'"
                    )
                    continue

                # Check assignment field paths
                entity = appspec.get_entity(effect.entity_name)
                if entity:
                    entity_field_names = {f.name for f in entity.fields}
                    for assignment in effect.assignments:
                        # Strip entity prefix (e.g. "Task.title" -> "title")
                        field_name = assignment.field_path
                        if "." in field_name:
                            field_name = field_name.split(".", 1)[1]
                        if field_name not in entity_field_names:
                            warnings.append(
                                f"Process '{process.name}' step '{step.name}' effect "
                                f"assignment references non-existent field "
                                f"'{field_name}' on entity '{effect.entity_name}'"
                            )

                # Warn about update without where clause
                if effect.action.value == "update" and not effect.where:
                    warnings.append(
                        f"Process '{process.name}' step '{step.name}' has "
                        f"update effect on '{effect.entity_name}' without "
                        f"a 'where' clause — needs entity ID from context"
                    )

    return warnings


def validate_notifications(appspec: ir.AppSpec) -> tuple[list[str], list[str]]:
    """
    Validate all notifications for semantic correctness (v0.34.0).

    Checks:
    - Trigger entity exists
    - Trigger field exists on entity (if specified)
    - No duplicate notification names
    - Recipients reference valid fields (for field-based recipients)

    Returns:
        Tuple of (errors, warnings)
    """
    errors: list[str] = []
    warnings: list[str] = []
    seen_names: set[str] = set()
    entity_names = {e.name for e in appspec.domain.entities}

    for n in appspec.notifications:
        # Duplicate name check
        if n.name in seen_names:
            errors.append(f"Duplicate notification name: '{n.name}'")
        seen_names.add(n.name)

        # Trigger entity must exist
        if n.trigger.entity not in entity_names:
            errors.append(f"Notification '{n.name}' references unknown entity '{n.trigger.entity}'")
        else:
            entity = appspec.get_entity(n.trigger.entity)
            if entity and n.trigger.field:
                if not entity.get_field(n.trigger.field):
                    errors.append(
                        f"Notification '{n.name}' trigger references unknown field "
                        f"'{n.trigger.field}' on entity '{n.trigger.entity}'"
                    )

            # Field-based recipients should reference a valid field
            if entity and n.recipients.kind == "field":
                if n.recipients.value and not entity.get_field(n.recipients.value):
                    warnings.append(
                        f"Notification '{n.name}' recipients reference field "
                        f"'{n.recipients.value}' which does not exist on '{n.trigger.entity}'"
                    )

    return errors, warnings


_GOD_ENTITY_FIELD_THRESHOLD = 15
_SOFT_DELETE_NAMES = frozenset({"is_deleted", "deleted", "deleted_at", "archived_at"})


def _lint_modeling_anti_patterns(appspec: ir.AppSpec) -> list[str]:
    """Detect common modeling anti-patterns and emit warnings."""
    warnings: list[str] = []
    entity_names = {e.name.lower() for e in appspec.domain.entities}
    entity_map = {e.name: e for e in appspec.domain.entities}

    for entity in appspec.domain.entities:
        field_map = {f.name: f for f in entity.fields}

        # 1. Polymorphic key pairs: *_type (enum) + *_id (uuid)
        for field in entity.fields:
            if field.name.endswith("_type") and field.type.kind == ir.FieldTypeKind.ENUM:
                prefix = field.name.removesuffix("_type")
                sibling_name = f"{prefix}_id"
                sibling = field_map.get(sibling_name)
                if sibling and sibling.type.kind == ir.FieldTypeKind.UUID:
                    warnings.append(
                        f"Entity '{entity.name}': fields '{field.name}' + "
                        f"'{sibling_name}' look like a polymorphic key. "
                        f"Prefer separate ref fields for each target entity."
                    )

        # 2. God entities: too many fields
        meaningful_fields = [
            f
            for f in entity.fields
            if f.name not in ("id", "created_at", "updated_at")
            and ir.FieldModifier.PK not in (f.modifiers or [])
        ]
        if len(meaningful_fields) > _GOD_ENTITY_FIELD_THRESHOLD:
            warnings.append(
                f"Entity '{entity.name}' has {len(meaningful_fields)} fields "
                f"— consider decomposing into smaller entities connected by refs."
            )

        # 3. Soft-delete flags without state machine
        if entity.state_machine is None:
            for field in entity.fields:
                if field.name in _SOFT_DELETE_NAMES:
                    warnings.append(
                        f"Entity '{entity.name}': field '{field.name}' is a soft-delete "
                        f"flag. Prefer a state machine with a terminal state "
                        f"(e.g., 'archived')."
                    )

        # 4. Stringly-typed refs: <entity>_name or <entity>_email
        for field in entity.fields:
            if field.type.kind not in (ir.FieldTypeKind.STR, ir.FieldTypeKind.EMAIL):
                continue
            for suffix in ("_name", "_email"):
                if field.name.endswith(suffix):
                    prefix = field.name.removesuffix(suffix)
                    if prefix.lower() in entity_names and prefix.lower() != entity.name.lower():
                        target = next(
                            (
                                e.name
                                for e in appspec.domain.entities
                                if e.name.lower() == prefix.lower()
                            ),
                            prefix,
                        )
                        warnings.append(
                            f"Entity '{entity.name}': field '{field.name}' looks like "
                            f"a string copy of {target}.{suffix.lstrip('_')}. "
                            f"Use 'ref {target}' instead — the runtime auto-includes related data."
                        )

        # 5. Duplicated ref fields: ref X + x_<field> where <field> exists on X
        for field in entity.fields:
            if field.type.kind != ir.FieldTypeKind.REF or not field.type.ref_entity:
                continue
            target_entity = entity_map.get(field.type.ref_entity)
            if target_entity is None:
                continue  # ref target not found — skip silently
            target_field_names = {f.name for f in target_entity.fields if f.name != "id"}
            ref_lower = field.name.lower()
            for sibling in entity.fields:
                if sibling is field:
                    continue
                if sibling.name.startswith(f"{ref_lower}_"):
                    attr = sibling.name[len(ref_lower) + 1 :]
                    if attr in target_field_names:
                        warnings.append(
                            f"Entity '{entity.name}': field '{sibling.name}' may "
                            f"duplicate {field.type.ref_entity}.{attr} — the ref "
                            f"already provides access via auto-include."
                        )

    return warnings


def _lint_graph_edge_suggestions(appspec: ir.AppSpec) -> list[str]:
    """Suggest graph_edge: for entities with 2+ ref fields to the same entity."""
    warnings: list[str] = []
    for entity in appspec.domain.entities:
        if entity.graph_edge is not None:
            continue
        ref_targets: dict[str, int] = {}
        for field in entity.fields:
            if field.type.kind == ir.FieldTypeKind.REF and field.type.ref_entity:
                ref_targets[field.type.ref_entity] = ref_targets.get(field.type.ref_entity, 0) + 1
        for target, count in ref_targets.items():
            if count >= 2:
                warnings.append(
                    f"Entity '{entity.name}' looks like a graph edge — "
                    f"has {count} ref fields to '{target}'. "
                    f"Consider adding graph_edge:"
                )
                break
    return warnings


def _lint_graph_node_suggestions(appspec: ir.AppSpec) -> list[str]:
    """Suggest graph_node: for entities targeted by graph_edge: declarations."""
    warnings: list[str] = []
    entity_map = {e.name: e for e in appspec.domain.entities}
    # Track which entities we've already warned about
    warned: set[str] = set()
    for entity in appspec.domain.entities:
        if entity.graph_edge is None:
            continue
        field_map = {f.name: f for f in entity.fields}
        for field_name in (entity.graph_edge.source, entity.graph_edge.target):
            field = field_map.get(field_name)
            if field and field.type.ref_entity:
                target_ent = entity_map.get(field.type.ref_entity)
                if target_ent and target_ent.graph_node is None and target_ent.name not in warned:
                    warnings.append(
                        f"'{entity.name}' targets '{target_ent.name}' — "
                        f"consider adding graph_node: for discoverability"
                    )
                    warned.add(target_ent.name)
    return warnings


def _lint_fk_targets_missing_display_field(appspec: ir.AppSpec) -> list[str]:
    """Warn when FK-target entities lack display_field (#652).

    Entities referenced by ``ref`` fields on other entities should declare
    ``display_field:`` so that FK references render as human-readable text
    instead of raw UUIDs. Junction entities (2+ required ref fields, no
    str/text fields) are skipped since they rarely have a meaningful name.
    """
    warnings: list[str] = []
    entities_by_name: dict[str, ir.EntitySpec] = {e.name: e for e in appspec.domain.entities}

    # Build map: entity_name → list of "Entity.field" references
    fk_refs: dict[str, list[str]] = {}
    for entity in appspec.domain.entities:
        for field in entity.fields:
            kind = getattr(field.type, "kind", None)
            kind_val = kind.value if hasattr(kind, "value") else str(kind)  # type: ignore[union-attr]
            ref_target = getattr(field.type, "ref_entity", None)
            if kind_val in ("ref", "belongs_to") and ref_target:
                fk_refs.setdefault(ref_target, []).append(f"{entity.name}.{field.name}")

    for target_name, refs in sorted(fk_refs.items()):
        target = entities_by_name.get(target_name)
        if target is None:
            continue
        if target.display_field:
            continue

        # Skip junction entities: 2+ required ref fields, no str/text fields
        ref_count = 0
        has_text_field = False
        for f in target.fields:
            fk = getattr(f.type, "kind", None)
            fk_val = fk.value if hasattr(fk, "value") else str(fk)  # type: ignore[union-attr]
            if fk_val in ("ref", "belongs_to") and f.is_required:
                ref_count += 1
            scalar = getattr(f.type, "scalar_type", None)
            if scalar and str(scalar) in ("str", "text", "ScalarType.STR", "ScalarType.TEXT"):
                has_text_field = True
        if ref_count >= 2 and not has_text_field:
            continue

        # Suggest a candidate field
        candidate = None
        for name in ("name", "title", "label"):
            if any(f.name == name for f in target.fields):
                candidate = name
                break
        if candidate is None:
            for f in target.fields:
                scalar = getattr(f.type, "scalar_type", None)
                if scalar and str(scalar) in ("str", "ScalarType.STR"):
                    candidate = f.name
                    break

        ref_list = ", ".join(refs[:5])
        if len(refs) > 5:
            ref_list += f", ... ({len(refs)} total)"
        msg = (
            f"Entity '{target_name}' is referenced as FK by {len(refs)} field(s) "
            f"but has no display_field."
        )
        if candidate:
            msg += f"\n  Suggested: display_field: {candidate}"
        msg += f"\n  FK references: {ref_list}"
        warnings.append(msg)

    return warnings


def extended_lint(appspec: ir.AppSpec) -> list[str]:
    """Extended lint rules for code quality.

    Dispatches to focused checkers for naming, titles, workspace
    personas, routing, list-surface UX, and integration bindings.
    Dead-construct detection is handled by :func:`_detect_dead_constructs`.
    """
    warnings: list[str] = []
    warnings.extend(_lint_naming_conventions(appspec))
    warnings.extend(_detect_dead_constructs(appspec))
    warnings.extend(_lint_missing_titles(appspec))
    warnings.extend(_lint_workspace_personas(appspec))
    warnings.extend(_lint_workspace_routing(appspec))
    warnings.extend(_lint_workspace_access_declarations(appspec))
    warnings.extend(_lint_list_surface_ux(appspec))
    warnings.extend(_lint_integration_bindings(appspec))
    warnings.extend(_lint_process_effects(appspec))
    warnings.extend(_lint_modeling_anti_patterns(appspec))
    warnings.extend(_lint_graph_edge_suggestions(appspec))
    warnings.extend(_lint_graph_node_suggestions(appspec))
    warnings.extend(_lint_fk_targets_missing_display_field(appspec))
    return warnings


# =============================================================================
# Preview construct validation (parsed but not yet enforced at runtime)
# =============================================================================


def validate_webhooks(appspec: ir.AppSpec) -> tuple[list[str], list[str]]:
    """Validate webhook definitions and warn about runtime status."""
    errors: list[str] = []
    warnings: list[str] = []

    if not appspec.webhooks:
        return errors, warnings

    entity_names = {e.name for e in appspec.domain.entities}

    warnings.append(
        f"[Preview] {len(appspec.webhooks)} webhook(s) defined. "
        "Webhook delivery is not yet enforced at runtime."
    )

    for wh in appspec.webhooks:
        if wh.entity and wh.entity not in entity_names:
            errors.append(f"Webhook '{wh.name}' references unknown entity '{wh.entity}'.")
        if not wh.events:
            warnings.append(f"Webhook '{wh.name}' has no events specified.")
        if not wh.url:
            warnings.append(f"Webhook '{wh.name}' has no URL configured.")

    return errors, warnings


def validate_approvals(appspec: ir.AppSpec) -> tuple[list[str], list[str]]:
    """Validate approval definitions and warn about runtime status."""
    errors: list[str] = []
    warnings: list[str] = []

    if not appspec.approvals:
        return errors, warnings

    entity_names = {e.name for e in appspec.domain.entities}

    warnings.append(
        f"[Preview] {len(appspec.approvals)} approval(s) defined. "
        "Approval gates are not yet enforced at runtime."
    )

    for ap in appspec.approvals:
        if ap.entity and ap.entity not in entity_names:
            errors.append(f"Approval '{ap.name}' references unknown entity '{ap.entity}'.")
        if not ap.approver_role:
            warnings.append(f"Approval '{ap.name}' has no approver_role specified.")
        if not ap.outcomes:
            warnings.append(f"Approval '{ap.name}' has no outcomes defined.")

    return errors, warnings


def validate_slas(appspec: ir.AppSpec) -> tuple[list[str], list[str]]:
    """Validate SLA definitions and warn about runtime status."""
    errors: list[str] = []
    warnings: list[str] = []

    if not appspec.slas:
        return errors, warnings

    entity_names = {e.name for e in appspec.domain.entities}

    warnings.append(
        f"[Preview] {len(appspec.slas)} SLA(s) defined. "
        "SLA monitoring is not yet enforced at runtime."
    )

    for sla in appspec.slas:
        if sla.entity and sla.entity not in entity_names:
            errors.append(f"SLA '{sla.name}' references unknown entity '{sla.entity}'.")
        if not sla.tiers:
            warnings.append(f"SLA '{sla.name}' has no tiers defined.")

    return errors, warnings


def validate_audit_config(appspec: ir.AppSpec) -> tuple[list[str], list[str]]:
    """Validate entity audit configuration and warn about runtime status."""
    errors: list[str] = []
    warnings: list[str] = []

    valid_operations = {kind.value for kind in ir.PermissionKind}
    audited_entities: list[str] = []

    for entity in appspec.domain.entities:
        if entity.audit and entity.audit.enabled:
            audited_entities.append(entity.name)
            # Validate operation names
            for op in entity.audit.operations:
                if op.value not in valid_operations:
                    errors.append(
                        f"Entity '{entity.name}' audit config references "
                        f"unknown operation '{op.value}'."
                    )

    if audited_entities:
        count = len(audited_entities)
        noun = "entity has" if count == 1 else "entities have"
        warnings.append(
            f"[Info] {count} {noun} audit: enabled. "
            "CRUD operations and access decisions will be logged to the audit trail."
        )
        fcs = [e.name for e in appspec.domain.entities if e.audit and e.audit.include_field_changes]
        if fcs:
            warnings.append(
                f"[Info] {len(fcs)} audited entity/entities have "
                "include_field_changes enabled. Field-level diffs will be "
                "captured for update and delete operations."
            )

    return errors, warnings


def validate_governance_policies(appspec: ir.AppSpec) -> tuple[list[str], list[str]]:
    """Validate governance policy definitions and warn about runtime status."""
    errors: list[str] = []
    warnings: list[str] = []

    if not appspec.policies:
        return errors, warnings

    entity_names = {e.name for e in appspec.domain.entities}
    entity_fields: dict[str, set[str]] = {
        e.name: {f.name for f in e.fields} for e in appspec.domain.entities
    }

    # Validate classifications
    if appspec.policies.classifications:
        for cls in appspec.policies.classifications:
            if cls.entity not in entity_names:
                errors.append(f"Classification references unknown entity '{cls.entity}'.")
            elif cls.field not in entity_fields.get(cls.entity, set()):
                errors.append(
                    f"Classification references unknown field '{cls.entity}.{cls.field}'."
                )

        count = len(appspec.policies.classifications)
        noun = "classification" if count == 1 else "classifications"
        warnings.append(
            f"[Preview] {count} data {noun} defined. "
            "Classification-based access filtering is not yet enforced at runtime."
        )

    # Validate erasures
    if appspec.policies.erasures:
        for erasure in appspec.policies.erasures:
            if erasure.entity not in entity_names:
                errors.append(f"Erasure policy references unknown entity '{erasure.entity}'.")
            elif erasure.field and erasure.field not in entity_fields.get(erasure.entity, set()):
                errors.append(
                    f"Erasure policy references unknown field '{erasure.entity}.{erasure.field}'."
                )

        count = len(appspec.policies.erasures)
        noun = "erasure policy" if count == 1 else "erasure policies"
        warnings.append(
            f"[Preview] {count} {noun} defined. "
            "Data erasure workflows are not yet enforced at runtime."
        )

    return errors, warnings


def validate_sensitive_fields(appspec: ir.AppSpec) -> tuple[list[str], list[str]]:
    """Validate sensitive field markers and warn about runtime status."""
    errors: list[str] = []
    warnings: list[str] = []

    sensitive_fields: list[str] = []
    for entity in appspec.domain.entities:
        for field in entity.fields:
            if field.is_sensitive:
                sensitive_fields.append(f"{entity.name}.{field.name}")

    if sensitive_fields:
        count = len(sensitive_fields)
        noun = "field" if count == 1 else "fields"
        warnings.append(
            f"[Info] {count} {noun} marked 'sensitive'. "
            "Response masking is not yet enforced at runtime."
        )

    return errors, warnings


def validate_scope_predicates(appspec: ir.AppSpec) -> tuple[list[str], list[str]]:
    """
    Validate scope predicates against the FK graph (belt-and-suspenders).

    The linker already compiles scope predicates and catches many errors during
    ``build_scope_predicate``.  This validator provides a second layer that
    walks the *compiled* predicate trees and verifies:

    - ColumnCheck.field exists on the entity
    - PathCheck.path resolves through the FK graph
    - ExistsCheck.target_entity exists
    - No role/grant checks leak into scope rules (already blocked by the
      predicate builder, but verified here for defense-in-depth)

    Returns:
        Tuple of (errors, warnings)
    """
    errors: list[str] = []
    warnings: list[str] = []

    fk_graph = appspec.fk_graph
    if fk_graph is None:
        return errors, warnings

    for entity in appspec.domain.entities:
        if entity.access is None or not entity.access.scopes:
            continue

        entity_field_names = {f.name for f in entity.fields}

        for rule in entity.access.scopes:
            predicate = rule.predicate
            if predicate is None:
                continue

            ctx = f"Entity '{entity.name}' scope rule"
            _validate_predicate_node(
                predicate,
                entity.name,
                entity_field_names,
                fk_graph,
                appspec,
                ctx,
                errors,
                warnings,
            )

    return errors, warnings


def _validate_predicate_node(
    node: object,
    entity_name: str,
    entity_field_names: set[str],
    fk_graph: Any,
    appspec: ir.AppSpec,
    ctx: str,
    errors: list[str],
    warnings: list[str],
) -> None:
    """Recursively validate a single predicate node against the FK graph."""
    # Import here to avoid circular imports at module level
    from .ir.predicates import (
        BoolComposite,
        ColumnCheck,
        Contradiction,
        ExistsCheck,
        PathCheck,
        Tautology,
        UserAttrCheck,
    )

    if isinstance(node, (Tautology, Contradiction)):
        return

    if isinstance(node, ColumnCheck):
        if node.field not in entity_field_names:
            errors.append(
                f"{ctx}: ColumnCheck references non-existent field "
                f"'{node.field}' on entity '{entity_name}'"
            )
        return

    if isinstance(node, UserAttrCheck):
        if node.field not in entity_field_names:
            errors.append(
                f"{ctx}: UserAttrCheck references non-existent field "
                f"'{node.field}' on entity '{entity_name}'"
            )
        return

    if isinstance(node, PathCheck):
        if not node.path:
            errors.append(f"{ctx}: PathCheck has empty path")
            return
        # Validate FK hops for all segments except the last, then check
        # the terminal field exists on the final entity.
        path_str = ".".join(node.path)
        current = entity_name
        for i, segment in enumerate(node.path):
            is_last = i == len(node.path) - 1
            if is_last:
                # Terminal segment: must be a plain field on current entity
                if not fk_graph.field_exists(current, segment):
                    errors.append(
                        f"{ctx}: PathCheck path '{path_str}' — terminal field "
                        f"'{segment}' does not exist on entity '{current}'"
                    )
            else:
                # Intermediate segment: must be an FK hop
                try:
                    _, target = fk_graph.resolve_segment(current, segment)
                    current = target
                except (ValueError, AttributeError) as exc:
                    errors.append(f"{ctx}: PathCheck path '{path_str}' — {exc}")
                    break  # Cannot continue resolving after a broken hop
        return

    if isinstance(node, ExistsCheck):
        if not appspec.get_entity(node.target_entity):
            errors.append(
                f"{ctx}: ExistsCheck references non-existent entity '{node.target_entity}'"
            )
        return

    if isinstance(node, BoolComposite):
        for child in node.children:
            _validate_predicate_node(
                child,
                entity_name,
                entity_field_names,
                fk_graph,
                appspec,
                ctx,
                errors,
                warnings,
            )
        return


# =============================================================================
# Graph semantics validation (v0.46.0 — #619)
# =============================================================================

# Numeric field types for graph weight validation
_NUMERIC_FIELD_TYPES = frozenset(
    {
        ir.FieldTypeKind.INT,
        ir.FieldTypeKind.DECIMAL,
        ir.FieldTypeKind.FLOAT,
    }
)


def validate_graph_declarations(appspec: ir.AppSpec) -> tuple[list[str], list[str]]:
    """Validate graph_edge: and graph_node: declarations.

    Checks field references, types, and cross-entity consistency.

    Returns:
        Tuple of (errors, warnings)
    """
    errors: list[str] = []
    warnings: list[str] = []
    entity_map = {e.name: e for e in appspec.domain.entities}

    for entity in appspec.domain.entities:
        if entity.graph_edge is not None:
            _validate_graph_edge(entity, entity_map, errors, warnings)
        if entity.graph_node is not None:
            _validate_graph_node(entity, entity_map, errors, warnings)

    return errors, warnings


def _validate_graph_edge(
    entity: ir.EntitySpec,
    entity_map: dict[str, ir.EntitySpec],
    errors: list[str],
    warnings: list[str],
) -> None:
    """Validate a single entity's graph_edge: block."""
    ge = entity.graph_edge
    assert ge is not None
    field_map = {f.name: f for f in entity.fields}

    # source field
    if ge.source not in field_map:
        errors.append(f"graph_edge source '{ge.source}' is not a field on {entity.name}")
    else:
        src_field = field_map[ge.source]
        if src_field.type.kind != ir.FieldTypeKind.REF:
            errors.append(
                f"graph_edge source must be a ref field, got '{src_field.type.kind.value}'"
            )

    # target field
    if ge.target not in field_map:
        errors.append(f"graph_edge target '{ge.target}' is not a field on {entity.name}")
    else:
        tgt_field = field_map[ge.target]
        if tgt_field.type.kind != ir.FieldTypeKind.REF:
            errors.append(
                f"graph_edge target must be a ref field, got '{tgt_field.type.kind.value}'"
            )

    # type_field (optional)
    if ge.type_field is not None:
        if ge.type_field not in field_map:
            errors.append(f"graph_edge type '{ge.type_field}' is not a field on {entity.name}")

    # weight_field (optional)
    if ge.weight_field is not None:
        if ge.weight_field not in field_map:
            errors.append(f"graph_edge weight '{ge.weight_field}' is not a field on {entity.name}")
        else:
            wf = field_map[ge.weight_field]
            if wf.type.kind not in _NUMERIC_FIELD_TYPES:
                errors.append("graph_edge weight must be int, decimal, or float")

    # Warnings: heterogeneous graph
    if ge.source in field_map and ge.target in field_map:
        src_ref = field_map[ge.source].type.ref_entity
        tgt_ref = field_map[ge.target].type.ref_entity
        if src_ref and tgt_ref and src_ref != tgt_ref:
            warnings.append(f"Heterogeneous graph: source refs {src_ref}, target refs {tgt_ref}")

    # Warning: no access control on edge entity
    if entity.access is None or not entity.access.permissions:
        warnings.append(f"Edge entity '{entity.name}' has no access control")

    # Warning: acyclic only detectable in seed data
    if ge.acyclic:
        warnings.append("acyclic declared but cycles only detected in seed data")


def _validate_graph_node(
    entity: ir.EntitySpec,
    entity_map: dict[str, ir.EntitySpec],
    errors: list[str],
    warnings: list[str],
) -> None:
    """Validate a single entity's graph_node: block."""
    gn = entity.graph_node
    assert gn is not None

    if gn.edge_entity not in entity_map:
        errors.append(f"graph_node edges '{gn.edge_entity}' is not a defined entity")
    else:
        edge_ent = entity_map[gn.edge_entity]
        if edge_ent.graph_edge is None:
            errors.append(f"graph_node edges '{gn.edge_entity}' does not declare graph_edge:")

    if gn.display is not None:
        field_map = {f.name: f for f in entity.fields}
        if gn.display not in field_map:
            errors.append(f"graph_node display '{gn.display}' is not a field on {entity.name}")
    else:
        warnings.append("graph_node has no display field — labels use default fallback")
