"""
Data Structure Builder for Snowplow connector.

Handles processing of individual data structures (schemas) including:
- Fetching full schema definitions
- Parsing schema metadata
- Building dataset objects with all aspects
- Field tagging and structured properties
"""

import logging
from typing import (
    TYPE_CHECKING,
    Any,
    Dict,
    Iterable,
    List,
    NamedTuple,
    Optional,
    Set,
    Tuple,
)

from datahub.emitter.mcp_builder import ContainerKey, gen_containers
from datahub.ingestion.api.workunit import MetadataWorkUnit
from datahub.ingestion.source.snowplow.builders.container_keys import (
    SnowplowOrganizationKey,
    SnowplowVendorKey,
)
from datahub.ingestion.source.snowplow.constants import (
    DatasetSubtype,
    EventFieldType,
    SchemaType,
    get_schema_subtype,
)
from datahub.ingestion.source.snowplow.models.snowplow_models import (
    DataStructure,
    DataStructureDeployment,
    SchemaMetadata as SnowplowSchemaMetadata,
)
from datahub.ingestion.source.snowplow.utils.schema_parser import SnowplowSchemaParser
from datahub.metadata.schema_classes import (
    ContainerClass,
    GlobalTagsClass,
    OwnerClass,
    SchemaFieldClass,
    SchemaMetadataClass,
    StatusClass,
    TagAssociationClass,
)
from datahub.sdk.dataset import Dataset

if TYPE_CHECKING:
    from datahub.ingestion.source.snowplow.dependencies import (
        IngestionState,
        ProcessorDependencies,
    )
    from datahub.ingestion.source.snowplow.services.column_lineage_builder import (
        ColumnLineageBuilder,
    )
    from datahub.ingestion.source.snowplow.services.field_tagging import FieldTagContext

logger = logging.getLogger(__name__)


class DataStructureBasicInfo(NamedTuple):
    """Validated basic information extracted from a DataStructure."""

    vendor: str
    name: str
    schema_meta: SnowplowSchemaMetadata
    schema_type: str
    version: str
    schema_identifier: str


class DataStructureBuilder:
    """
    Builder for processing Snowplow data structures (schemas).

    Handles the complete lifecycle of processing a data structure from the BDP API,
    including fetching full definitions, parsing schemas, building datasets, and
    emitting all related metadata.
    """

    def __init__(
        self,
        deps: "ProcessorDependencies",
        state: "IngestionState",
        column_lineage_builder: "ColumnLineageBuilder",
    ):
        """
        Initialize data structure builder.

        Args:
            deps: Explicit dependencies (clients, builders, config, etc.)
            state: Shared mutable state
            column_lineage_builder: Builder for column-level lineage
        """
        self.deps = deps
        self.state = state
        self.column_lineage_builder = column_lineage_builder

        # Convenience accessors
        self.config = deps.config
        self.report = deps.report
        self.bdp_client = deps.bdp_client
        self.platform = deps.platform
        self.urn_factory = deps.urn_factory
        self.ownership_builder = deps.ownership_builder
        self.field_tagger = deps.field_tagger

        # Track emitted vendor containers to avoid duplicates
        self._emitted_vendors: Set[str] = set()

    def process_data_structure(
        self, data_structure: DataStructure
    ) -> Iterable[MetadataWorkUnit]:
        """
        Process a single data structure (schema).

        Args:
            data_structure: Data structure from BDP API

        Yields:
            MetadataWorkUnit: Schema metadata work units
        """
        # Fetch full schema definition if missing
        data_structure = self._fetch_full_schema_definition(data_structure)

        # Validate and extract basic info
        basic_info = self._validate_and_extract_basic_info(data_structure)
        if not basic_info:
            return

        vendor, name, schema_meta, schema_type, version, schema_identifier = basic_info

        # Track found schema
        self.report.report_schema_found(schema_type)

        # Apply filtering
        if not self._should_process_schema(schema_identifier, schema_type, schema_meta):
            return

        # Capture first event schema for parsed events dataset naming
        self._capture_first_event_schema(vendor, name, schema_type)

        # Emit vendor container (idempotent - only emits once per vendor)
        yield from self._emit_vendor_container(vendor)

        # Build dataset name
        dataset_name = self._build_dataset_name(vendor, name, version)

        # Build dataset properties
        custom_properties = self._build_custom_properties(
            data_structure, vendor, name, version, schema_type, schema_meta
        )

        # Build owners
        owners_list = self._build_owners_list(data_structure, schema_identifier)

        # Build subtype and extra aspects
        subtype = (
            get_schema_subtype(schema_type) if schema_type else DatasetSubtype.SCHEMA
        )
        extra_aspects = self._build_extra_aspects(name, schema_type)

        # Set container via extra_aspects; browse path is computed by auto_browse_path_v2
        leaf_container_urn = self._get_leaf_container_urn(vendor)
        if leaf_container_urn:
            extra_aspects.append(ContainerClass(container=leaf_container_urn))

        # Parse schema metadata and add tags
        schema_metadata = self._parse_schema_metadata(
            data_structure,
            vendor,
            name,
            version,
            schema_type,
            schema_identifier,
            extra_aspects,
        )

        # Create and yield Dataset
        dataset = self._create_dataset(
            dataset_name,
            name,
            data_structure,
            version,
            custom_properties,
            subtype,
            owners_list,
            extra_aspects,
        )
        yield from dataset.as_workunits()

        # Post-processing: emit structured properties, cache results, emit lineage
        yield from self._post_process_dataset(
            dataset, data_structure, schema_metadata, vendor, name, version, schema_type
        )

        # Track extracted schema
        self.report.report_schema_extracted(schema_type)

    def _validate_and_extract_basic_info(
        self, data_structure: DataStructure
    ) -> Optional[DataStructureBasicInfo]:
        """Validate and extract basic information from data structure."""
        if (
            not data_structure.vendor
            or not data_structure.name
            or not data_structure.meta
        ):
            logger.warning(
                f"Data structure missing basic fields (vendor, name, or meta), skipping: {data_structure.hash}"
            )
            return None

        vendor = data_structure.vendor
        name = data_structure.name
        schema_meta = data_structure.meta
        schema_type = schema_meta.schema_type or "event"

        version = self._get_schema_version(data_structure)
        if not version:
            logger.warning(f"Cannot determine version for {vendor}/{name}, skipping")
            return None

        schema_identifier = f"{vendor}/{name}"
        return DataStructureBasicInfo(
            vendor=vendor,
            name=name,
            schema_meta=schema_meta,
            schema_type=schema_type,
            version=version,
            schema_identifier=schema_identifier,
        )

    def _should_process_schema(
        self,
        schema_identifier: str,
        schema_type: str,
        schema_meta: SnowplowSchemaMetadata,
    ) -> bool:
        """Check if schema should be processed based on filters."""
        if not self.config.schema_pattern.allowed(schema_identifier):
            self.report.report_schema_filtered(schema_type, schema_identifier)
            return False

        if schema_type not in self.config.schema_types_to_extract:
            logger.debug(f"Skipping schema {schema_identifier} with type {schema_type}")
            return False

        if schema_meta.hidden and not self.config.include_hidden_schemas:
            self.report.report_hidden_schema(skipped=True)
            logger.debug(f"Skipping hidden schema: {schema_identifier}")
            return False
        elif schema_meta.hidden:
            self.report.report_hidden_schema(skipped=False)

        return True

    def _capture_first_event_schema(
        self, vendor: str, name: str, schema_type: str
    ) -> None:
        """Capture first event schema for parsed events dataset naming."""
        if (
            schema_type == SchemaType.EVENT.value
            and self.state.first_event_schema_vendor is None
        ):
            self.state.first_event_schema_vendor = vendor
            self.state.first_event_schema_name = name
            logger.debug(
                f"Captured first event schema for Event dataset naming: {vendor}/{name}"
            )

    def _build_dataset_name(self, vendor: str, name: str, version: str) -> str:
        """Build dataset name based on configuration."""
        if self.config.include_version_in_urn:
            return f"{vendor}.{name}.{version}".replace("/", ".")
        return f"{vendor}.{name}".replace("/", ".")

    def _build_custom_properties(
        self,
        data_structure: DataStructure,
        vendor: str,
        name: str,
        version: str,
        schema_type: str,
        schema_meta: SnowplowSchemaMetadata,
    ) -> Dict[str, str]:
        """Build custom properties for dataset."""
        custom_properties = {
            "vendor": vendor,
            "schemaVersion": version,
            "schema_type": schema_type or "unknown",
            "hidden": str(schema_meta.hidden),
            "igluUri": f"iglu:{vendor}/{name}/jsonschema/{version}",
            **schema_meta.custom_data,
        }
        if data_structure.data:
            custom_properties["format"] = data_structure.data.self_descriptor.format
        return custom_properties

    def _get_leaf_container_urn(self, vendor: str) -> Optional[str]:
        """Return the URN of the leaf vendor container for this vendor namespace.

        Only ContainerClass is set on the dataset; browsePathsV2 is computed
        automatically by the framework's auto_browse_path_v2 stream processor
        from the container hierarchy emitted by _emit_vendor_container.
        """
        if not self.config.bdp_connection:
            return None

        org_id = self.config.bdp_connection.organization_id
        vendor_key = SnowplowVendorKey(
            organization_id=org_id,
            vendor=vendor,
            platform=self.platform,
            instance=self.config.platform_instance,
            env=self.config.env,
        )
        return str(vendor_key.as_urn())

    def _emit_vendor_container(self, vendor: str) -> Iterable[MetadataWorkUnit]:
        """Emit nested vendor containers by splitting the vendor namespace on dots.

        For example, 'com.snowplowanalytics.snowplow' emits three containers:
          Organization → com → snowplowanalytics → snowplow
        Shared prefixes (e.g., 'com') are emitted once and reused.
        """
        if not self.config.bdp_connection:
            return

        org_id = self.config.bdp_connection.organization_id
        segments = vendor.split(".")

        for i in range(len(segments)):
            vendor_path = ".".join(segments[: i + 1])

            if vendor_path in self._emitted_vendors:
                continue
            self._emitted_vendors.add(vendor_path)

            if i == 0:
                parent_key: ContainerKey = SnowplowOrganizationKey(
                    organization_id=org_id,
                    platform=self.platform,
                    instance=self.config.platform_instance,
                    env=self.config.env,
                )
            else:
                parent_path = ".".join(segments[:i])
                parent_key = SnowplowVendorKey(
                    organization_id=org_id,
                    vendor=parent_path,
                    platform=self.platform,
                    instance=self.config.platform_instance,
                    env=self.config.env,
                )

            vendor_key = SnowplowVendorKey(
                organization_id=org_id,
                vendor=vendor_path,
                platform=self.platform,
                instance=self.config.platform_instance,
                env=self.config.env,
            )

            yield from gen_containers(
                container_key=vendor_key,
                name=segments[i],
                sub_types=[DatasetSubtype.SCHEMA],
                parent_container_key=parent_key,
                extra_properties={
                    "vendor": vendor_path,
                },
            )

    def _build_owners_list(
        self, data_structure: DataStructure, schema_identifier: str
    ) -> Optional[List[OwnerClass]]:
        """Build owners list from deployments."""
        if data_structure.deployments and self.bdp_client:
            created_by, modified_by = (
                self.ownership_builder.extract_ownership_from_deployments(
                    data_structure.deployments
                )
            )
            return self.ownership_builder.build_ownership_list(
                created_by, modified_by, schema_identifier
            )
        return None

    def _build_extra_aspects(self, name: str, schema_type: str) -> List[Any]:
        """Build extra aspects including status and optional tags."""
        extra_aspects: List[Any] = [StatusClass(removed=False)]

        logger.debug(
            f"Dataset tag check for '{name}': field_tagging.enabled={self.config.field_tagging.enabled}, "
            f"tag_event_type={self.config.field_tagging.tag_event_type}"
        )

        if (
            self.config.field_tagging.enabled
            and self.config.field_tagging.tag_event_type
        ):
            logger.debug(f"Building dataset tags for '{name}'")
            dataset_tags = self._build_dataset_tags(name, schema_type)
            if dataset_tags:
                extra_aspects.append(dataset_tags)
                logger.debug(f"Added dataset tags to '{name}'")
        else:
            logger.debug(
                f"Skipping dataset tags for '{name}' (tag_event_type={self.config.field_tagging.tag_event_type})"
            )

        return extra_aspects

    def _parse_schema_metadata(
        self,
        data_structure: DataStructure,
        vendor: str,
        name: str,
        version: str,
        schema_type: str,
        schema_identifier: str,
        extra_aspects: List[Any],
    ) -> Optional[SchemaMetadataClass]:
        """Parse schema metadata and add field tags if enabled."""
        if not data_structure.data:
            logger.info(
                f"Schema definition not available for {vendor}/{name}/{version}, skipping detailed schema metadata"
            )
            return None

        try:
            schema_metadata = SnowplowSchemaParser.parse_schema(
                schema_data=data_structure.data.model_dump(),
                vendor=vendor,
                name=name,
                version=version,
            )

            if self.config.field_tagging.enabled:
                schema_metadata = self._add_field_tags(
                    schema_metadata=schema_metadata,
                    data_structure=data_structure,
                    version=version,
                    schema_type=schema_type,
                )

            extra_aspects.append(schema_metadata)
            return schema_metadata

        except Exception as e:
            error_msg = f"Failed to parse schema {schema_identifier}: {e}"
            self.report.report_schema_parsing_error(error_msg)
            logger.error(error_msg)
            return None

    def _create_dataset(
        self,
        dataset_name: str,
        name: str,
        data_structure: DataStructure,
        version: str,
        custom_properties: Dict[str, str],
        subtype: str,
        owners_list: Optional[List[OwnerClass]],
        extra_aspects: List[Any],
    ) -> Dataset:
        """Create Dataset object using SDK V2.

        ContainerClass is set via extra_aspects; browsePathsV2 is left unset
        so auto_browse_path_v2 can compute it from the container hierarchy.
        """
        return Dataset(
            platform=self.platform,
            name=dataset_name,
            platform_instance=self.config.platform_instance,
            env=self.config.env,
            description=data_structure.data.description
            if data_structure.data
            else None,
            display_name=name,
            external_url=self._get_schema_url(
                data_structure.vendor or "",
                name,
                version,
                data_structure.hash,
            ),
            custom_properties=custom_properties,
            subtype=subtype,
            owners=owners_list,
            extra_aspects=extra_aspects,
        )

    def _post_process_dataset(
        self,
        dataset: Dataset,
        data_structure: DataStructure,
        schema_metadata: Optional[SchemaMetadataClass],
        vendor: str,
        name: str,
        version: str,
        schema_type: str,
    ) -> Iterable[MetadataWorkUnit]:
        """Post-process dataset: emit structured properties, cache results, emit lineage."""
        # Emit field-level structured properties if enabled
        if (
            self.config.field_tagging.enabled
            and self.config.field_tagging.use_structured_properties
            and schema_metadata
        ):
            yield from self._emit_field_structured_properties(
                dataset_urn=str(dataset.urn),
                schema_metadata=schema_metadata,
                data_structure=data_structure,
                version=version,
                schema_type=schema_type,
            )

        # Cache the schema URN for pipeline collector job
        self.state.extracted_schema_urns.append(str(dataset.urn))

        # Cache schema fields for Event dataset (indexed by URN for O(1) lookups)
        if schema_metadata and schema_metadata.fields:
            self.state.register_schema_fields(str(dataset.urn), schema_metadata.fields)

        # Emit column-level lineage
        if schema_metadata:
            yield from self.column_lineage_builder.emit_column_lineage(
                dataset_urn=str(dataset.urn),
                vendor=vendor,
                name=name,
                version=version,
                schema_metadata=schema_metadata,
            )

    def _fetch_full_schema_definition(
        self, data_structure: DataStructure
    ) -> DataStructure:
        """
        Fetch full schema definition if only minimal info available.

        Args:
            data_structure: Data structure from BDP API

        Returns:
            Updated data structure with full schema definition (if available)
        """
        if (
            data_structure.data is not None
            or not data_structure.hash
            or not self.bdp_client
        ):
            return data_structure

        # Use version from most recent PROD deployment, fall back to any deployment
        latest_deployment = data_structure.get_latest_deployment(prefer_env="PROD")
        if not latest_deployment:
            logger.warning(
                f"No deployments found for {data_structure.vendor}/{data_structure.name}, cannot determine version to fetch"
            )
            return data_structure

        version = latest_deployment.version
        env = latest_deployment.env

        logger.info(
            f"Fetching schema definition for {data_structure.vendor}/{data_structure.name} version {version} from {env}"
        )

        # Fetch using the /versions/{version} endpoint which returns full schema
        try:
            full_structure = self.bdp_client.get_data_structure_version(
                data_structure.hash, version, env
            )
        except Exception as e:
            logger.warning(
                f"Failed to fetch schema definition for {data_structure.vendor}/{data_structure.name} "
                f"version {version}: {e}. Schema will be extracted without field-level metadata.",
                exc_info=True,
            )
            return data_structure

        if full_structure and full_structure.data:
            logger.info(
                f"Successfully fetched schema definition with {len(full_structure.data.properties or {})} properties"
            )
            # Preserve metadata from list response
            full_structure.meta = data_structure.meta
            full_structure.deployments = data_structure.deployments
            return full_structure
        else:
            logger.warning(
                f"Could not fetch schema definition for {data_structure.hash}/{version}, skipping field-level metadata"
            )
            return data_structure

    def _get_schema_version(self, data_structure: DataStructure) -> Optional[str]:
        """
        Extract schema version from data structure.

        Args:
            data_structure: Data structure from BDP API

        Returns:
            Schema version string, or None if cannot be determined
        """
        # Get version from schema definition if available
        if data_structure.data:
            return data_structure.data.self_descriptor.version

        # Otherwise use version from latest deployment
        if data_structure.deployments:
            sorted_deployments = sorted(
                data_structure.deployments, key=lambda d: d.ts or "", reverse=True
            )
            version = sorted_deployments[0].version
            logger.info(
                f"Schema definition missing, using version from latest deployment: {version}"
            )
            return version

        return None

    def _build_dataset_tags(
        self, name: str, schema_type: Optional[str]
    ) -> Optional[GlobalTagsClass]:
        """
        Build dataset-level tags.

        Dataset-level tags apply to the schema/dataset as a whole, not individual fields.
        Currently adds:
        - Event type tag: Category derived from schema name (e.g., "checkout" from "checkout_started")

        Args:
            name: Schema name (e.g., "checkout_started")
            schema_type: Schema type ("event" or "entity")

        Returns:
            GlobalTagsClass with dataset-level tags, or None if no tags to add
        """
        tags = set()

        # Event type tag (category derived from name)
        # Extract first word: checkout_started → checkout
        event_name = name.split("_")[0] if "_" in name else name
        event_type_tag = self.config.field_tagging.event_type_pattern.format(
            name=event_name
        )
        tags.add(event_type_tag)

        # Convert to TagAssociationClass
        tag_associations = [
            TagAssociationClass(tag=f"urn:li:tag:{tag}") for tag in sorted(tags)
        ]

        return GlobalTagsClass(tags=tag_associations)

    def _add_field_tags(
        self,
        schema_metadata: SchemaMetadataClass,
        data_structure: DataStructure,
        version: str,
        schema_type: str,
    ) -> SchemaMetadataClass:
        """
        Add field-level tags to schema metadata.

        Field tags provide semantic meaning about what kind of data each field contains:
        - PII: Personal identifiable information
        - Email, Phone, IP Address, etc.
        - Event metadata (timestamp, ID, etc.)

        Args:
            schema_metadata: Parsed schema metadata with fields
            data_structure: Data structure from BDP API
            version: Schema version
            schema_type: Schema type ("event" or "entity")

        Returns:
            Updated schema metadata with field tags
        """
        if not schema_metadata.fields:
            return schema_metadata

        pii_fields = self._get_pii_fields_if_enabled()
        event_type = self._get_event_type(schema_type)
        schema_key = f"{data_structure.vendor}/{data_structure.name}"
        field_version_map = self._get_field_version_map(schema_key)

        # Update field descriptions with version information
        self._update_field_descriptions_with_versions(
            schema_metadata.fields, schema_key, field_version_map
        )

        # Add tags to each field
        for field in schema_metadata.fields:
            context = self._build_field_context(
                field=field,
                data_structure=data_structure,
                version=version,
                field_version_map=field_version_map,
                pii_fields=pii_fields,
                event_type=event_type,
            )
            field_tags = self.field_tagger.generate_tags(context)
            if field_tags:
                field.globalTags = field_tags

        return schema_metadata

    def _get_pii_fields_if_enabled(self) -> set:
        """Get PII fields if PII enrichment is enabled."""
        if self.config.field_tagging.use_pii_enrichment:
            return self._get_pii_fields()
        return set()

    def _get_event_type(self, schema_type: str) -> str:
        """Map schema_type to event_type for field tagging."""
        return (
            EventFieldType.SELF_DESCRIBING.value
            if schema_type == SchemaType.EVENT.value
            else EventFieldType.CONTEXT.value
        )

    def _get_field_version_map(self, schema_key: str) -> Dict[str, str]:
        """Get field version map if version tracking is enabled."""
        if (
            self.config.field_tagging.track_field_versions
            and self.config.field_tagging.tag_schema_version
        ):
            return self.state.field_version_cache.get(schema_key, {})
        return {}

    def _update_field_descriptions_with_versions(
        self,
        fields: List[SchemaFieldClass],
        schema_key: str,
        field_version_map: Dict[str, str],
    ) -> None:
        """Update field descriptions with version information."""
        if not (
            self.config.field_tagging.track_field_versions
            and self.config.field_tagging.tag_schema_version
        ):
            return

        if not field_version_map:
            logger.warning(
                f"Field version map is empty for {schema_key} - "
                "field version info will not be added"
            )
            return

        logger.debug(
            f"Applying field version info to {len(fields)} fields in {schema_key} "
            f"(field_version_map has {len(field_version_map)} entries)"
        )

        for field in fields:
            field_added_version = field_version_map.get(field.fieldPath)
            if not field_added_version or field_added_version == "1-0-0":
                continue

            version_suffix = f" (Added in version {field_added_version})"
            if field.description and version_suffix not in field.description:
                field.description = f"{field.description}{version_suffix}"
            elif not field.description:
                field.description = f"Added in version {field_added_version}"

    def _get_field_deployment_info(
        self, deployments: Optional[List[DataStructureDeployment]], field_version: str
    ) -> Tuple[Optional[str], Optional[str]]:
        """
        Get deployment info for a specific field version.

        Args:
            deployments: List of deployments from data structure
            field_version: The version when the field was added

        Returns:
            Tuple of (initiator, timestamp), both may be None
        """
        if not deployments:
            return None, None

        matching_deployments = [d for d in deployments if d.version == field_version]
        if not matching_deployments:
            return None, None

        deployment = sorted(
            matching_deployments, key=lambda d: d.ts or "", reverse=True
        )[0]
        return deployment.initiator, deployment.ts

    def _build_field_context(
        self,
        field: SchemaFieldClass,
        data_structure: DataStructure,
        version: str,
        field_version_map: Dict[str, str],
        pii_fields: set,
        event_type: str,
    ) -> "FieldTagContext":
        """
        Build field tag context for a single field.

        Args:
            field: Schema field to build context for
            data_structure: Data structure containing field
            version: Schema version (fallback if field version unknown)
            field_version_map: Map of field paths to introduction versions
            pii_fields: Set of PII field paths
            event_type: Event type (self_describing or context)

        Returns:
            FieldTagContext for the field
        """
        from datahub.ingestion.source.snowplow.services.field_tagging import (
            FieldTagContext,
        )

        field_version = field_version_map.get(field.fieldPath, version)
        initiator, timestamp = self._get_field_deployment_info(
            data_structure.deployments, field_version
        )

        return FieldTagContext(
            schema_version=field_version,
            vendor=data_structure.vendor or "",
            name=data_structure.name or "",
            field_name=field.fieldPath,
            field_type=field.nativeDataType,
            field_description=field.description,
            deployment_initiator=initiator,
            deployment_timestamp=timestamp,
            pii_fields=pii_fields,
            event_type=event_type,
            skip_version_tag=field_version == "1-0-0",
        )

    def _get_pii_fields(self) -> set:
        """
        Get PII fields from cache.

        PII fields are extracted from enrichment configuration by the main source
        and cached in the state. This method simply returns the cached value.

        Returns:
            Set of field paths marked as PII (empty set if not available)
        """
        # Return cached value if available
        if self.state.pii_fields_cache is not None:
            return self.state.pii_fields_cache

        # PII fields haven't been extracted yet - return empty set
        # The main source will extract them from enrichments if enabled
        return set()

    def _emit_field_structured_properties(
        self,
        dataset_urn: str,
        schema_metadata: SchemaMetadataClass,
        data_structure: DataStructure,
        version: str,
        schema_type: str,
    ) -> Iterable[MetadataWorkUnit]:
        """
        Emit field-level structured properties.

        Structured properties provide strongly-typed, searchable metadata on fields.
        Currently supports:
        - PII indicators
        - Semantic types (email, phone, IP, etc.)

        Args:
            dataset_urn: URN of the dataset
            schema_metadata: Parsed schema metadata
            data_structure: Data structure from BDP API
            version: Schema version

        Yields:
            MetadataWorkUnit: Field structured property work units
        """
        if not schema_metadata.fields:
            return

        pii_fields = self._get_pii_fields_if_enabled()
        event_type = self._get_event_type(schema_type)
        schema_key = f"{data_structure.vendor}/{data_structure.name}"
        field_version_map = self._get_field_version_map(schema_key)

        if field_version_map:
            logger.debug(
                f"Using field version map for structured properties in {schema_key}"
            )

        for field in schema_metadata.fields:
            context = self._build_field_context(
                field=field,
                data_structure=data_structure,
                version=version,
                field_version_map=field_version_map,
                pii_fields=pii_fields,
                event_type=event_type,
            )

            yield from self.field_tagger.generate_field_structured_properties(
                dataset_urn=dataset_urn,
                field=field,
                context=context,
                field_properties_cache=self.state.field_structured_properties,
            )

    def _get_schema_url(
        self,
        vendor: str,
        name: str,
        version: str,
        schema_hash: Optional[str],
    ) -> Optional[str]:
        """
        Build external URL for schema in BDP Console.

        Args:
            vendor: Schema vendor
            name: Schema name
            version: Schema version
            schema_hash: Schema hash (if available)

        Returns:
            URL to schema in BDP Console, or None if not using BDP
        """
        if not self.config.bdp_connection or not schema_hash:
            return None

        # BDP Console schema URL format:
        # https://console.snowplowanalytics.com/{{org_id}}/data-structures/{{hash}}?version={{version}}
        org_id = self.config.bdp_connection.organization_id
        return f"https://console.snowplowanalytics.com/{org_id}/data-structures/{schema_hash}?version={version}"
