"""
SHACL Validation Results Dashboard
===================================
A Streamlit app to visualize validation records from CDF Records API.

Runs in Pyodide (browser) with CDF authentication.
"""

from datetime import datetime, timedelta

import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
import streamlit as st
from cognite.client import CogniteClient

# =============================================================================
# Configuration
# =============================================================================
STREAM_ID = "immutable_test_stream"  # abp-dev (test_stream_1 for cog-ai)
RECORDS_SPACE = "sp_data_quality_demo"  # abp-dev (dataQuality for cog-ai)
RECORDS_CONTAINER = "DataQualityValidationRecord"


# =============================================================================
# API Request Counter
# =============================================================================
def increment_api_counter():
    """Increment the API request counter."""
    if "api_requests" not in st.session_state:
        st.session_state.api_requests = 0
    st.session_state.api_requests += 1


def get_api_count() -> int:
    """Get current API request count."""
    return st.session_state.get("api_requests", 0)


def fetch_instance_details(
    client, instance_space: str, instance_id: str, view_space: str, view_external_id: str, view_version: str
):
    """Fetch full instance details from DMS."""
    from cognite.client.data_classes.data_modeling.ids import ViewId

    try:
        view_id = ViewId(view_space, view_external_id, view_version)
        result = client.data_modeling.instances.retrieve(nodes=[(instance_space, instance_id)], sources=[view_id])
        increment_api_counter()

        if result and result.nodes:
            return result.nodes[0]
        return None
    except Exception as e:
        st.error(f"Failed to fetch instance: {e}")
        return None


@st.dialog("Instance Details", width="large")
def show_instance_dialog(
    client, instance_id: str, validation_record: dict, view_space: str, view_external_id: str, view_version: str
):
    """Show instance details dialog with DMS data and validation info."""

    # Extract instance space from focusNode
    focus_node = validation_record.get("focusNode", "")
    # Format: http://purl.org/cognite/{space}/{external_id}
    if "purl.org/cognite/" in focus_node:
        parts = focus_node.split("purl.org/cognite/")[1].split("/")
        instance_space = parts[0] if len(parts) > 0 else "unknown"
    else:
        instance_space = "sp_valhall"  # fallback for abp-dev

    st.subheader(f"📋 Instance: {instance_id}")

    # Fetch instance from DMS
    with st.spinner("Fetching instance from DMS..."):
        instance = fetch_instance_details(
            client, instance_space, instance_id, view_space, view_external_id, view_version
        )

    # Create tabs for different views
    tab1, tab2, tab3 = st.tabs(["Properties", "Validation Details", "Raw JSON"])

    with tab1:
        st.write("### Instance Properties")
        if instance:
            # Get the full instance as dict
            instance_dict = instance.dump(camel_case=True) if hasattr(instance, "dump") else {}

            # Display properties in a nice format
            if "properties" in instance_dict:
                props_dict = instance_dict["properties"]

                for prop_space, views in props_dict.items():
                    st.write(f"**Space:** `{prop_space}`")

                    # Handle different view structures
                    if isinstance(views, dict):
                        for view_key, props in views.items():
                            with st.expander(f"View: {view_key}", expanded=True):
                                if not isinstance(props, dict):
                                    st.warning(f"Unexpected props type: {type(props)}")
                                    continue

                                # Create a dataframe for better display
                                prop_data = []
                                for key, value in props.items():
                                    # Format the value nicely
                                    if isinstance(value, dict):
                                        if "externalId" in value:
                                            val_str = f"→ {value.get('space', 'unknown')}/{value['externalId']}"
                                        else:
                                            val_str = str(value)
                                    elif isinstance(value, list):
                                        if value and isinstance(value[0], dict) and "externalId" in value[0]:
                                            val_str = f"[{len(value)} relations] " + ", ".join(
                                                [f"{v['externalId']}" for v in value[:3]]
                                            )
                                            if len(value) > 3:
                                                val_str += f" ...+{len(value) - 3} more"
                                        else:
                                            val_str = f"[{len(value)} items]"
                                    else:
                                        val_str = str(value) if value is not None else "null"

                                    prop_data.append({"Property": key, "Value": val_str})

                                if prop_data:
                                    st.dataframe(
                                        pd.DataFrame(prop_data),
                                        use_container_width=True,
                                        hide_index=True,
                                        column_config={
                                            "Property": st.column_config.TextColumn("Property", width="medium"),
                                            "Value": st.column_config.TextColumn("Value", width="large"),
                                        },
                                    )
                    else:
                        st.warning(f"Unexpected views structure: {type(views)}")
            else:
                st.warning("No properties found")

            # Show metadata
            st.write("### Metadata")
            meta_data = {
                "Space": instance.space,
                "External ID": instance.external_id,
                "Created": pd.to_datetime(instance.created_time, unit="ms").strftime("%Y-%m-%d %H:%M:%S")
                if hasattr(instance, "created_time")
                else "N/A",
                "Last Updated": pd.to_datetime(instance.last_updated_time, unit="ms").strftime("%Y-%m-%d %H:%M:%S")
                if hasattr(instance, "last_updated_time")
                else "N/A",
                "Version": instance.version if hasattr(instance, "version") else "N/A",
            }
            st.dataframe(pd.DataFrame([meta_data]).T.rename(columns={0: "Value"}), use_container_width=True)
        else:
            st.error("Could not load instance from DMS")

    with tab2:
        st.write("### Validation Record")

        # Status
        status = validation_record.get("status", "Unknown")
        if status == "Failed":
            st.error(f"Status: **{status}**")
        else:
            st.success(f"Status: **{status}**")

        # Rule set info
        col1, col2, col3 = st.columns(3)
        with col1:
            st.metric("Rule Set", validation_record.get("ruleSetId", "N/A"))
        with col2:
            st.metric("Version", validation_record.get("ruleSetVersion", "N/A"))
        with col3:
            st.metric("Job Run", validation_record.get("jobRunId", "N/A")[:12] + "...")

        # Dimensions summary
        record_dims = validation_record.get("dimensions", [])
        if record_dims:
            dim_colors_css = {
                "Completeness": "#6366f1",
                "Validity": "#3b82f6",
                "Conformity": "#10b981",
                "Consistency": "#f59e0b",
            }
            badges = " ".join(
                f'<span style="background:{dim_colors_css.get(d, "#6b7280")};color:white;padding:3px 10px;border-radius:12px;font-size:13px;font-weight:600;">{d}</span>'
                for d in record_dims
            )
            st.markdown(f"**Dimensions violated:** {badges}", unsafe_allow_html=True)
            st.write("")

        # Failed constraints
        if validation_record.get("failedConstraints"):
            st.write("### Failed Constraints")
            constraints = validation_record["failedConstraints"]
            messages = validation_record.get("messages", [])
            severities = validation_record.get("severities", [])
            viol_dims_dialog = validation_record.get("violation_dimensions", [])

            constraint_data = []
            for i, constraint in enumerate(constraints):
                if isinstance(constraint, dict):
                    raw_type = constraint.get(
                        "sourceConstraintComponent", constraint.get("constraintComponent", "Unknown")
                    )
                else:
                    raw_type = str(constraint)

                # Parse constraint type and result path
                result_path = "-"
                if "::" in raw_type:
                    parts = raw_type.split("::")
                    raw_type = parts[0]
                    result_path = parts[1] if len(parts) > 1 else "-"

                # Clean namespace from constraint type
                if "#" in raw_type:
                    constraint_type = raw_type.split("#")[-1]
                elif "/" in raw_type:
                    constraint_type = raw_type.split("/")[-1]
                else:
                    constraint_type = raw_type

                # Remove "ConstraintComponent" suffix for readability
                constraint_type = constraint_type.replace("ConstraintComponent", "")

                # Clean up result path - extract meaningful part
                if result_path != "-" and "/" in result_path:
                    # Extract the last 2-3 segments (e.g., sp_rmdm_dm/Document/description -> Document/description)
                    path_parts = result_path.split("/")
                    if len(path_parts) >= 2:
                        result_path = "/".join(path_parts[-2:])

                # Clean severity (remove namespace prefix)
                severity = severities[i] if i < len(severities) else "-"
                if severity != "-" and "#" in str(severity):
                    severity = str(severity).split("#")[-1]
                elif severity != "-" and "::" in str(severity):
                    severity = str(severity).split("::")[-1]

                dim = viol_dims_dialog[i] if i < len(viol_dims_dialog) else "-"

                constraint_data.append(
                    {
                        "Constraint": constraint_type,
                        "Property": result_path,
                        "Dimension": dim or "-",
                        "Severity": severity,
                        "Message": messages[i] if i < len(messages) else "-",
                    }
                )

            # Display with custom column configuration for better readability
            st.dataframe(
                pd.DataFrame(constraint_data),
                use_container_width=True,
                hide_index=True,
                column_config={
                    "Constraint": st.column_config.TextColumn("Constraint", width="small"),
                    "Property": st.column_config.TextColumn("Property", width="medium"),
                    "Dimension": st.column_config.TextColumn("Dimension", width="small"),
                    "Severity": st.column_config.TextColumn("Severity", width="small"),
                    "Message": st.column_config.TextColumn("Message", width="large", max_chars=500),
                },
                height=min(400, (len(constraint_data) + 1) * 35 + 3),  # Auto-height based on rows
            )

    with tab3:
        st.write("### Raw Instance JSON")
        if instance:
            st.json(instance.dump(camel_case=True) if hasattr(instance, "dump") else str(instance))

        st.write("### Raw Validation Record JSON")
        st.json(validation_record)


# =============================================================================
# Styled Console Logging
# =============================================================================
def log_api(tag: str, message: str, data=None):
    """Log API-related messages with clear formatting for console."""
    # Use Unicode symbols for visual distinction
    symbols = {
        "TargetClasses": ">>> ",
        "Aggregate": ">>> ",
        "Records": ">>> ",
    }
    symbol = symbols.get(tag, ">>> ")

    if data is not None:
        import json

        data_str = json.dumps(data, indent=2, default=str)
        print(f"\n{symbol}[{tag}] {message}")
        print(data_str)
    else:
        print(f"{symbol}[{tag}] {message}")


# Color palette - softer, more balanced colors
COLORS = {
    "passed": "#10b981",  # Emerald green
    "failed": "#f59e0b",  # Amber/orange (less alarming than red)
    "accent": "#6366f1",  # Indigo
    "info": "#3b82f6",  # Blue
    "neutral": "#6b7280",  # Gray
    "background": "#1e293b",  # Slate
}

# =============================================================================
# Page Configuration
# =============================================================================
st.set_page_config(
    page_title="SHACL Validation Dashboard", page_icon="📊", layout="wide", initial_sidebar_state="expanded"
)

# Custom CSS
st.markdown(
    f"""
<style>
    .main-header {{
        font-size: 2.2rem;
        font-weight: 700;
        background: linear-gradient(90deg, {COLORS["passed"]}, {COLORS["accent"]});
        -webkit-background-clip: text;
        -webkit-text-fill-color: transparent;
        margin-bottom: 0.5rem;
    }}
    .sub-header {{
        font-size: 1rem;
        color: {COLORS["neutral"]};
        margin-bottom: 2rem;
    }}
    .metric-passed {{
        border-left: 4px solid {COLORS["passed"]} !important;
    }}
    .metric-failed {{
        border-left: 4px solid {COLORS["failed"]} !important;
    }}
</style>
""",
    unsafe_allow_html=True,
)


# =============================================================================
# CDF Client Setup
# =============================================================================
@st.cache_resource
def get_cdf_client():
    """Initialize CDF client (uses browser auth in Pyodide)."""
    return CogniteClient()


# =============================================================================
# Data Fetching Functions
# =============================================================================
def _build_target_class_filter(target_classes: tuple[str, ...]) -> dict | None:
    """Build containsAny filter for targetClass. Returns None if empty (no filter)."""
    if not target_classes:
        return None
    return {
        "containsAny": {
            "property": [RECORDS_SPACE, RECORDS_CONTAINER, "targetClass"],
            "values": list(target_classes),
        }
    }


@st.cache_data(ttl=60)
def fetch_available_target_classes(_client) -> list[str]:
    """Fetch unique targetClass values from the stream using aggregation."""
    original_headers = _client.config.headers.copy()
    _client.config.headers["cdf-version"] = "alpha"

    try:
        six_days_ago_ms = int((datetime.now() - timedelta(days=6)).timestamp() * 1000)

        log_api("TargetClasses", "Fetching available target classes...")

        payload = {
            "lastUpdatedTime": {"gt": six_days_ago_ms},
            "aggregates": {
                "targetClasses": {"uniqueValues": {"property": [RECORDS_SPACE, RECORDS_CONTAINER, "targetClass"]}}
            },
        }
        log_api("TargetClasses", "Aggregate payload:", payload)

        response = _client.post(
            f"/api/v1/projects/{_client.config.project}/streams/{STREAM_ID}/records/aggregate", json=payload
        )
        increment_api_counter()

        data = response.json()
        log_api("TargetClasses", "Aggregate response:", data)

        buckets = data.get("aggregates", {}).get("targetClasses", {}).get("uniqueValueBuckets", [])
        result = sorted([str(b.get("value")) for b in buckets if b.get("value")])
        log_api("TargetClasses", f"Found {len(result)} target classes: {result}")
        return result
    except Exception as e:
        log_api("TargetClasses", f"Failed to fetch target classes: {e}")
        return []
    finally:
        _client.config.headers = original_headers


def _safe_aggregate_call(_client, endpoint: str, payload: dict, agg_name: str) -> dict:
    """Make a single aggregate call with error handling."""
    try:
        log_api("Aggregate", f"POST {agg_name}", payload)
        response = _client.post(endpoint, json=payload)
        increment_api_counter()
        data = response.json()
        log_api("Aggregate", f"{agg_name} response:", data)
        return data
    except Exception as e:
        log_api("Aggregate", f"ERROR in {agg_name}: {e}")
        return {"error": str(e)}


@st.cache_data(ttl=30)
def fetch_aggregate_stats(_client, target_classes: tuple[str, ...] = ()) -> dict:
    """
    Fetch aggregate statistics for selected entity types.
    Optimized: combines total, jobs, and status breakdown in ONE API call.
    """
    original_headers = _client.config.headers.copy()
    _client.config.headers["cdf-version"] = "alpha"

    try:
        six_days_ago_ms = int((datetime.now() - timedelta(days=6)).timestamp() * 1000)
        endpoint = f"/api/v1/projects/{_client.config.project}/streams/{STREAM_ID}/records/aggregate"

        total_count = 0
        unique_jobs = 0
        passed_count = 0
        failed_count = 0

        # Build filter conditions
        filter_conditions = []
        tc_filter = _build_target_class_filter(target_classes)
        if tc_filter:
            filter_conditions.append(tc_filter)

        payload = {
            "lastUpdatedTime": {"gt": six_days_ago_ms},
            "aggregates": {
                "totalCount": {"count": {}},
                "jobRuns": {"uniqueValues": {"property": [RECORDS_SPACE, RECORDS_CONTAINER, "jobRunId"]}},
                "passedBreakdown": {
                    "uniqueValues": {"property": [RECORDS_SPACE, RECORDS_CONTAINER, "passedValidation"]}
                },
                "severityBreakdown": {
                    "uniqueValues": {"property": [RECORDS_SPACE, RECORDS_CONTAINER, "resultSeverity"]}
                },
            },
        }

        if filter_conditions:
            payload["filter"] = {"and": filter_conditions} if len(filter_conditions) > 1 else filter_conditions[0]

        data = _safe_aggregate_call(_client, endpoint, payload, "combined-stats")

        severity_counts = {}

        if "error" not in data:
            aggregates = data.get("aggregates", {})

            # Parse total count
            raw_total = aggregates.get("totalCount", {})
            total_count = raw_total.get("count", 0) if isinstance(raw_total, dict) else 0

            # Parse unique job runs
            job_buckets = aggregates.get("jobRuns", {}).get("uniqueValueBuckets", [])
            unique_jobs = len(job_buckets)

            # Parse passed/failed from passedValidation (bool)
            passed_buckets = aggregates.get("passedBreakdown", {}).get("uniqueValueBuckets", [])
            for bucket in passed_buckets:
                if bucket.get("value"):
                    passed_count = bucket.get("count", 0)
                elif not bucket.get("value"):
                    failed_count = bucket.get("count", 0)

            # Parse severity breakdown
            severity_buckets = aggregates.get("severityBreakdown", {}).get("uniqueValueBuckets", [])
            for bucket in severity_buckets:
                sev = bucket.get("value")
                if sev:  # Skip None values (passed records)
                    severity_counts[sev] = bucket.get("count", 0)

        log_api(
            "Aggregate",
            f"Final: total={total_count}, passed={passed_count}, failed={failed_count}, jobs={unique_jobs}, severities={severity_counts}",
        )

        return {
            "total": total_count,
            "passed": passed_count,
            "failed": failed_count,
            "unique_jobs": unique_jobs,
            "severity_counts": severity_counts,
        }
    finally:
        _client.config.headers = original_headers


@st.cache_data(ttl=300)
def get_constraint_types_from_shacl(_client, rule_set_file_id: str) -> list:
    """
    Parse SHACL file to extract all constraint types used in the rules.
    Returns list of constraint type names (e.g., ['MinCountConstraintComponent', 'PatternConstraintComponent'])
    """
    try:
        # Download SHACL file
        file_content = _client.files.download_bytes(external_id=rule_set_file_id)
        shacl_text = file_content.decode("utf-8")

        # Extract constraint types from SHACL syntax
        # SHACL uses sh:minCount, sh:maxCount, sh:pattern, etc.
        constraint_types = set()

        # Common SHACL constraint properties and their component names
        shacl_mapping = {
            "sh:minCount": "MinCountConstraintComponent",
            "sh:maxCount": "MaxCountConstraintComponent",
            "sh:pattern": "PatternConstraintComponent",
            "sh:class": "ClassConstraintComponent",
            "sh:datatype": "DatatypeConstraintComponent",
            "sh:minLength": "MinLengthConstraintComponent",
            "sh:maxLength": "MaxLengthConstraintComponent",
            "sh:minInclusive": "MinInclusiveConstraintComponent",
            "sh:maxInclusive": "MaxInclusiveConstraintComponent",
            "sh:minExclusive": "MinExclusiveConstraintComponent",
            "sh:maxExclusive": "MaxExclusiveConstraintComponent",
            "sh:nodeKind": "NodeKindConstraintComponent",
            "sh:in": "InConstraintComponent",
            "sh:hasValue": "HasValueConstraintComponent",
            "sh:uniqueLang": "UniqueLangConstraintComponent",
        }

        # Find which constraints are used in the SHACL file
        for shacl_prop, component_name in shacl_mapping.items():
            if shacl_prop in shacl_text:
                constraint_types.add(component_name)

        log_api("SHACLParse", f"Found {len(constraint_types)} constraint types in SHACL file")
        return list(constraint_types)

    except Exception as e:
        log_api("SHACLParse", f"Error parsing SHACL: {e}")
        # Fallback to common constraint types
        return [
            "MinCountConstraintComponent",
            "MaxCountConstraintComponent",
            "PatternConstraintComponent",
            "ClassConstraintComponent",
            "DatatypeConstraintComponent",
        ]


@st.cache_data(ttl=30)
def discover_constraint_uris(_client, target_classes: tuple[str, ...] = ()) -> dict:
    """
    First step: Discover all unique constraint URI values from failedConstraints.
    Returns dict mapping full_uri -> parsed_display_name.
    Each unique rule (constraint + property path) gets its own entry.
    """
    original_headers = _client.config.headers.copy()
    _client.config.headers["cdf-version"] = "alpha"

    try:
        six_days_ago_ms = int((datetime.now() - timedelta(days=6)).timestamp() * 1000)
        endpoint = f"/api/v1/projects/{_client.config.project}/streams/{STREAM_ID}/records/aggregate"

        # Build filter: failed records + optional targetClass filter
        filter_conditions = [
            {"equals": {"property": [RECORDS_SPACE, RECORDS_CONTAINER, "passedValidation"], "value": False}},
        ]
        tc_filter = _build_target_class_filter(target_classes)
        if tc_filter:
            filter_conditions.append(tc_filter)

        # Get unique constraint values
        payload = {
            "lastUpdatedTime": {"gt": six_days_ago_ms},
            "filter": {"and": filter_conditions},
            "aggregates": {
                "constraintUris": {
                    "uniqueValues": {"property": [RECORDS_SPACE, RECORDS_CONTAINER, "failedConstraints"]}
                }
            },
        }

        data = _safe_aggregate_call(_client, endpoint, payload, "discover-constraint-uris")

        # Parse each unique URI into a display name
        # Format: MinCountConstraintComponent (areaRel)
        constraint_uri_map = {}
        if "error" not in data:
            buckets = data.get("aggregates", {}).get("constraintUris", {}).get("uniqueValueBuckets", [])
            log_api("DiscoverURIs", f"Got {len(buckets)} unique value buckets")

            for bucket in buckets:
                uri = bucket.get("value", "")
                if not isinstance(uri, str) or "::" not in uri:
                    continue

                # Extract constraint type and property path
                # Format: http://www.w3.org/ns/shacl#MinCountConstraintComponent::http://purl.org/.../areaRel
                parts = uri.split("::")
                constraint_part = parts[0]
                property_path = parts[1] if len(parts) > 1 else ""

                # Extract constraint type name
                if "#" in constraint_part:
                    constraint_type = constraint_part.split("#")[-1]
                elif "/" in constraint_part:
                    constraint_type = constraint_part.split("/")[-1]
                else:
                    constraint_type = constraint_part

                # Extract property name from path
                if property_path:
                    # Extract last segment of property path
                    property_name = property_path.split("/")[-1]
                    display_name = f"{constraint_type} ({property_name})"
                else:
                    display_name = constraint_type

                # Map: full_uri -> display_name
                # Each unique URI gets counted separately
                constraint_uri_map[uri] = display_name

        log_api("DiscoverURIs", f"Found {len(constraint_uri_map)} unique constraint rules")
        return constraint_uri_map

    finally:
        _client.config.headers = original_headers


@st.cache_data(ttl=30)
def count_total_distinct_instances(_client, target_classes: tuple[str, ...] = ()) -> int:
    """
    Count total number of distinct instances validated (not total records).
    Uses uniqueValues aggregate on focusNode field (contains full instance URI).
    """
    original_headers = _client.config.headers.copy()
    _client.config.headers["cdf-version"] = "alpha"

    try:
        six_days_ago_ms = int((datetime.now() - timedelta(days=6)).timestamp() * 1000)
        endpoint = f"/api/v1/projects/{_client.config.project}/streams/{STREAM_ID}/records/aggregate"

        filter_conditions = []
        tc_filter = _build_target_class_filter(target_classes)
        if tc_filter:
            filter_conditions.append(tc_filter)

        payload = {
            "lastUpdatedTime": {"gt": six_days_ago_ms},
            "aggregates": {
                "distinctInstances": {"uniqueValues": {"property": [RECORDS_SPACE, RECORDS_CONTAINER, "focusNode"]}}
            },
        }

        if filter_conditions:
            payload["filter"] = {"and": filter_conditions} if len(filter_conditions) > 1 else filter_conditions[0]

        data = _safe_aggregate_call(_client, endpoint, payload, "count-distinct-instances")

        if "error" not in data:
            agg_result = data.get("aggregates", {}).get("distinctInstances", {})
            buckets = agg_result.get("uniqueValueBuckets", [])
            distinct_count = len(buckets)
            log_api("DistinctInstances", f"Found {distinct_count} distinct instances validated")
            return distinct_count

        return 0

    finally:
        _client.config.headers = original_headers


@st.cache_data(ttl=30)
def fetch_rule_violation_counts_aggregate(
    _client, target_classes: tuple[str, ...] = (), constraint_uri_map: dict | None = None
) -> dict:
    """
    Use aggregate API with filtered counts to get violation record counts per rule.
    Uses containsAny filter with actual full URIs discovered from the data.
    Returns dict mapping display_name -> count of violation records for that rule.

    Note: Makes separate API calls per rule because filter must be at top level.
    """
    original_headers = _client.config.headers.copy()
    _client.config.headers["cdf-version"] = "alpha"

    try:
        six_days_ago_ms = int((datetime.now() - timedelta(days=6)).timestamp() * 1000)
        endpoint = f"/api/v1/projects/{_client.config.project}/streams/{STREAM_ID}/records/aggregate"

        if not constraint_uri_map:
            log_api("RuleAgg", "No constraint URI map provided")
            return {}

        log_api("RuleAgg", f"Aggregating violation counts for {len(constraint_uri_map)} unique rules (separate calls)")

        # Build base filter: failed records + optional targetClass
        base_filter_conditions = [
            {"equals": {"property": [RECORDS_SPACE, RECORDS_CONTAINER, "passedValidation"], "value": False}},
        ]
        tc_filter = _build_target_class_filter(target_classes)
        if tc_filter:
            base_filter_conditions.append(tc_filter)

        all_results = {}

        # Make separate API call for each unique rule (constraint URI)
        # (Can't batch because filter must be at top level, not per-aggregate)
        for constraint_uri, display_name in constraint_uri_map.items():
            # Build filter for this specific rule
            constraint_filter = {
                "and": [
                    *base_filter_conditions,
                    {
                        "containsAny": {
                            "property": [RECORDS_SPACE, RECORDS_CONTAINER, "failedConstraints"],
                            "values": [constraint_uri],  # Single URI for this specific rule
                        }
                    },
                ]
            }

            # Payload with filter at TOP level - count violation records
            payload = {
                "lastUpdatedTime": {"gt": six_days_ago_ms},
                "filter": constraint_filter,  # Filter at top level!
                "aggregates": {
                    "violationCount": {
                        "count": {}  # Simple count of records matching the filter
                    }
                },
            }

            log_api("RuleAgg", f"Querying {display_name}")

            data = _safe_aggregate_call(_client, endpoint, payload, f"rule-count-{display_name[:30]}")

            if "error" not in data:
                agg_results = data.get("aggregates", {})
                count = agg_results.get("violationCount", {}).get("count", 0)

                log_api("RuleAgg", f"  {display_name}: {count} violation records")

                if count > 0:
                    all_results[display_name] = count
            else:
                log_api("RuleAgg", f"  ERROR for {display_name}: {data.get('error')}")

        log_api("RuleAgg", f"Got violation counts for {len(all_results)} unique rules")
        return all_results

    finally:
        _client.config.headers = original_headers


@st.cache_data(ttl=30)
def fetch_validation_records(_client, target_classes: tuple[str, ...] = ()) -> pd.DataFrame:
    """
    Fetch validation records for selected entity types (limited to 1000 for display).
    """
    original_headers = _client.config.headers.copy()
    _client.config.headers["cdf-version"] = "alpha"

    try:
        six_days_ago_ms = int((datetime.now() - timedelta(days=6)).timestamp() * 1000)

        log_api("Records", f"Fetching records for target_classes={target_classes}")

        # Build filter
        filter_conditions = []
        tc_filter = _build_target_class_filter(target_classes)
        if tc_filter:
            filter_conditions.append(tc_filter)

        request_body: dict = {
            "lastUpdatedTime": {"gt": six_days_ago_ms},
            "limit": 1000,
        }
        if filter_conditions:
            request_body["filter"] = {"and": filter_conditions} if len(filter_conditions) > 1 else filter_conditions[0]

        response = _client.post(
            f"/api/v1/projects/{_client.config.project}/streams/{STREAM_ID}/records/filter",
            json=request_body,
        )
        increment_api_counter()

        data = response.json()
        records = data.get("items", [])
        log_api("Records", f"Fetched {len(records)} records")

        if not records:
            return pd.DataFrame()

        records_data = []
        for record in records:
            space_props = record.get("properties", {}).get(RECORDS_SPACE, {})
            props = space_props.get(RECORDS_CONTAINER, {}) or space_props.get(f"{RECORDS_CONTAINER}/v1", {})

            failed_constraints = props.get("failedConstraints", [])
            validation_report = props.get("validationReport", {})
            violations = validation_report.get("violations", [])

            # Extract severities, messages, and dimensions from violations
            severities = []
            messages = []
            violation_dimensions = []
            for v in violations:
                sev = v.get("resultSeverity", "Unknown")
                # Clean up severity (extract after #)
                if "#" in str(sev):
                    sev = str(sev).split("::")[-1]
                severities.append(sev)
                messages.append(v.get("resultMessage", ""))
                violation_dimensions.append(v.get("dimension") or "")

            records_data.append(
                {
                    "externalId": record.get("externalId"),
                    "ruleSetId": props.get("ruleSetId"),
                    "ruleSetVersion": props.get("ruleSetVersion"),
                    "jobRunId": props.get("jobRunId"),
                    "focusNode": props.get("focusNode"),
                    "targetClass": props.get("targetClass", []),
                    "failedConstraints": failed_constraints,
                    "constraintCount": len(failed_constraints),
                    "status": "Failed" if failed_constraints else "Passed",
                    "severities": severities,
                    "messages": messages,
                    "resultSeverity": severities[0] if severities else "",
                    "resultMessage": messages[0] if messages else "",
                    "dimensions": props.get("dimensions", []),
                    "violation_dimensions": violation_dimensions,
                    "createdTime": record.get("createdTime"),
                    "lastUpdatedTime": record.get("lastUpdatedTime"),
                }
            )

        return pd.DataFrame(records_data)
    finally:
        _client.config.headers = original_headers


# =============================================================================
# Main App
# =============================================================================
def main():
    # Header - shows immediately
    st.markdown('<p class="main-header">📊 Data Quality Validation Dashboard</p>', unsafe_allow_html=True)

    # Sidebar - shows immediately
    st.sidebar.header("⚙️ Settings")

    # Loading placeholder that shows immediately
    loading_placeholder = st.empty()
    loading_placeholder.info("⏳ Loading data from CDF...")

    # Initialize client
    try:
        client = get_cdf_client()
    except Exception as e:
        loading_placeholder.empty()
        st.error(f"❌ Failed to connect to CDF: {e}")
        return

    # Fetch available target classes (single aggregate query)
    target_classes = fetch_available_target_classes(client)

    if not target_classes:
        loading_placeholder.empty()
        st.warning("No validation records found in the last 6 days. Run some validations first!")
        return

    # Entity Type filter (replaces Rule Set filter)
    st.sidebar.subheader("📋 Entity Type")
    selected_target_classes = st.sidebar.multiselect(
        "Filter by entity type",
        options=target_classes,
        default=target_classes,
        help="Filter by SHACL targetClass — the type of data entity being validated",
    )

    if not selected_target_classes:
        loading_placeholder.empty()
        st.warning("Select at least one entity type to view results.")
        return

    # Always apply targetClass filter — "all selected" != "all records"
    # (old records without targetClass would be counted otherwise)
    all_selected = set(selected_target_classes) == set(target_classes)
    active_classes = tuple(sorted(selected_target_classes))

    # Severity Filter (in sidebar)
    st.sidebar.divider()
    st.sidebar.subheader("🎯 Severity Filter")
    severity_options = ["Violation", "Warning", "Info"]
    selected_severities = st.sidebar.multiselect(
        "Show severities",
        options=severity_options,
        default=severity_options,
        help="Filter data by severity level",
    )

    # Show info if some severities are filtered out
    if len(selected_severities) < len(severity_options):
        filtered_out = [s for s in severity_options if s not in selected_severities]
        st.sidebar.caption(f"🔍 Filtering out: {', '.join(filtered_out)}")

    # Dimension Filter (in sidebar)
    st.sidebar.divider()
    st.sidebar.subheader("🏷️ Dimension Filter")
    dimension_options = ["Completeness", "Validity", "Conformity", "Consistency"]
    selected_dimensions = st.sidebar.multiselect(
        "Show dimensions",
        options=dimension_options,
        default=dimension_options,
        help="Filter violated records by data quality dimension",
    )

    if len(selected_dimensions) < len(dimension_options):
        filtered_dims = [d for d in dimension_options if d not in selected_dimensions]
        st.sidebar.caption(f"🔍 Filtering out: {', '.join(filtered_dims)}")

    st.sidebar.divider()

    # Subtitle
    if all_selected:
        class_text = "All entity types"
    elif len(selected_target_classes) == 1:
        class_text = selected_target_classes[0]
    else:
        class_text = ", ".join(selected_target_classes)

    st.markdown(
        f'<p class="sub-header">Stream: <code>{STREAM_ID}</code> • {class_text} • Last 6 days</p>',
        unsafe_allow_html=True,
    )

    if st.sidebar.button("🔄 Refresh", use_container_width=True):
        st.cache_data.clear()
        st.rerun()

    st.sidebar.divider()
    st.sidebar.caption(f"📋 {len(target_classes)} entity type(s) available")
    st.sidebar.caption(f"🔍 Showing: {', '.join(selected_target_classes)}")

    # Update loading message
    loading_placeholder.info(f"⏳ Loading stats for **{class_text}**...")

    # Fetch aggregate stats (fast - single API call)
    agg_stats = fetch_aggregate_stats(client, active_classes)

    # Check if we have data based on aggregate stats
    if agg_stats.get("total", 0) == 0:
        loading_placeholder.empty()
        st.info(f"No records found for: **{class_text}**")
        return

    # Clear loading message - data is ready
    loading_placeholder.empty()

    # ==========================================================================
    # Summary Metrics - Placeholders (will be updated after filtering)
    # ==========================================================================
    col1, col2, col3, col4 = st.columns(4)

    # Create placeholders for metrics
    total_placeholder = col1.empty()
    passed_placeholder = col2.empty()
    failed_placeholder = col3.empty()
    job_runs_placeholder = col4.empty()

    # Show loading state
    total_placeholder.metric("Total Records", "...")
    passed_placeholder.metric("Passed", "...")
    failed_placeholder.metric("Failed", "...")
    job_runs_placeholder.metric("Job Runs", "...")

    st.divider()

    # Validation Status Chart placeholder
    validation_chart_placeholder = st.empty()

    # ==========================================================================
    # Fetch records for detailed views (deferred - takes longer)
    # ==========================================================================
    records_loading = st.empty()
    records_loading.info("⏳ Loading detailed records (up to 1000)...")
    df = fetch_validation_records(client, active_classes)
    records_loading.empty()

    # Apply severity filter to DataFrame
    if not df.empty:
        if not selected_severities:
            # No severities selected - show only passed records
            df_filtered = df[df["status"] == "Passed"].copy()
        elif len(selected_severities) == len(severity_options):
            # All severities selected - show all records
            df_filtered = df.copy()
        else:
            # Filter: keep records where resultSeverity is in selected_severities OR status is Passed
            df_filtered = df[(df["resultSeverity"].isin(selected_severities)) | (df["status"] == "Passed")].copy()
    else:
        df_filtered = df.copy()

    # Apply dimension filter to DataFrame (pass records always shown; filter failed by violated dimension)
    if not df_filtered.empty and len(selected_dimensions) < len(dimension_options):

        def matches_dimension(row):
            if row["status"] == "Passed":
                return True
            dims = row.get("dimensions") if isinstance(row.get("dimensions"), list) else []
            if not dims:
                return True  # Records without dimension data pass through (pre-feature records)
            return any(d in selected_dimensions for d in dims)

        df_filtered = df_filtered[df_filtered.apply(matches_dimension, axis=1)].copy()

    # Update metrics based on filtered data
    all_filters_default = len(selected_severities) == len(severity_options) and len(selected_dimensions) == len(
        dimension_options
    )
    if not df_filtered.empty:
        filtered_passed = (df_filtered["status"] == "Passed").sum()
        filtered_failed = (df_filtered["status"] == "Failed").sum()
        filtered_total = len(df_filtered)
        actual_job_runs = df_filtered["jobRunId"].nunique()

        # Use aggregate stats for top-level metrics when no filters are active,
        # because fetch_validation_records is capped at 1000 records.
        if all_filters_default:
            display_total = agg_stats.get("total", filtered_total)
            display_passed = agg_stats.get("passed", filtered_passed)
            display_failed = agg_stats.get("failed", filtered_failed)
            display_jobs = agg_stats.get("unique_jobs", actual_job_runs)
        else:
            display_total = filtered_total
            display_passed = filtered_passed
            display_failed = filtered_failed
            display_jobs = actual_job_runs

        # Calculate pass rate
        total_with_status = display_passed + display_failed
        pass_rate = 100 * display_passed / total_with_status if total_with_status > 0 else 0

        # Update metric placeholders
        total_placeholder.metric("Total Records", f"{display_total:,}")
        passed_placeholder.metric("Passed", f"{display_passed:,}", delta=f"{pass_rate:.0f}%")
        failed_placeholder.metric(
            "Failed", f"{display_failed:,}", delta=f"{100 - pass_rate:.0f}%", delta_color="inverse"
        )
        job_runs_placeholder.metric("Job Runs", f"{display_jobs:,}")

        # Show filtered info if not showing all severities
        if len(selected_severities) < len(severity_options):
            st.info(
                f"📊 **Filtered View:** Showing {filtered_total:,} records ({filtered_passed:,} passed, {filtered_failed:,} failed) matching selected severities"
            )
    else:
        # No data after filtering
        total_placeholder.metric("Total Records", "0")
        passed_placeholder.metric("Passed", "0")
        failed_placeholder.metric("Failed", "0")
        job_runs_placeholder.metric("Job Runs", "0")
        filtered_passed = 0
        filtered_failed = 0
        pass_rate = 0

    # ==========================================================================
    # Validation Status Chart (based on filtered data)
    # ==========================================================================
    with validation_chart_placeholder.container():
        st.subheader("Validation Status")

        if filtered_passed + filtered_failed > 0:
            fig_pie = go.Figure(
                data=[
                    go.Pie(
                        labels=["Passed", "Failed"],
                        values=[filtered_passed, filtered_failed],
                        hole=0.5,
                        marker_colors=[COLORS["passed"], COLORS["failed"]],
                        textinfo="label+percent",
                        textfont_size=13,
                        textposition="outside",
                        pull=[0, 0.03],
                        hovertemplate="<b>%{label}</b><br>%{value:,}<br>%{percent}<extra></extra>",
                    )
                ]
            )
            fig_pie.update_layout(
                showlegend=False,
                margin=dict(t=30, b=30, l=30, r=30),
                height=320,
                annotations=[
                    dict(text=f"<b>{pass_rate:.0f}%</b><br>Pass Rate", x=0.5, y=0.5, font_size=16, showarrow=False)
                ],
            )
            st.plotly_chart(fig_pie, use_container_width=True)
        else:
            st.info("No validation data to display")

    st.divider()

    # ==========================================================================
    # Rule Compliance by Type (uses aggregate API ONLY - no record fetching!)
    # ==========================================================================
    st.subheader("Rule Compliance by Type")

    # Get total validation records from aggregate stats
    total_records = agg_stats.get("total", 0)
    total_failed_records = agg_stats.get("failed", 0)

    if total_failed_records > 0:
        # Step 1: Discover all unique constraint URIs from failed records
        with st.spinner("Discovering constraint URIs from validation data..."):
            constraint_uri_map = discover_constraint_uris(client, active_classes)

        # Step 2: Count violation records per constraint type
        with st.spinner("Counting violations per rule type..."):
            violation_counts = fetch_rule_violation_counts_aggregate(client, active_classes, constraint_uri_map)

        # Create compliance stats for each rule
        if violation_counts:
            rule_stats = []
            for rule_type, violation_count in sorted(violation_counts.items(), key=lambda x: x[1], reverse=True):
                # violation_count = number of violation RECORDS for this constraint
                # total_records = total number of validation records (passed + failed)
                violation_pct = (violation_count / total_records) * 100 if total_records > 0 else 0

                # Determine status based on violation percentage (of total records)
                if violation_pct >= 10:
                    status = "Critical"
                    status_color = "#ef4444"  # Red - affecting 10%+ of all records
                elif violation_pct >= 3:
                    status = "High"
                    status_color = "#f59e0b"  # Amber - affecting 3-10% of all records
                else:
                    status = "Moderate"
                    status_color = "#10b981"  # Green - affecting <3% of all records

                rule_stats.append(
                    {
                        "rule": rule_type,
                        "violation_count": violation_count,
                        "violation_pct": violation_pct,
                        "status": status,
                        "status_color": status_color,
                    }
                )

            # Display rules in compact table format
            st.markdown(f"Showing compliance for {len(rule_stats)} rule types")

            # Build table HTML
            table_html = """<style>
.rule-table {
    width: 100%;
    border-collapse: collapse;
    font-size: 14px;
}
.rule-table th {
    text-align: left;
    padding: 8px 12px;
    font-weight: 600;
    color: #6b7280;
    font-size: 12px;
    text-transform: uppercase;
    border-bottom: 2px solid #e5e7eb;
}
.rule-table td {
    padding: 10px 12px;
    border-bottom: 1px solid #f3f4f6;
}
.rule-table tr:hover {
    background-color: #f9fafb;
}
.progress-container {
    background-color: #f3f4f6;
    border-radius: 4px;
    height: 20px;
    overflow: hidden;
    position: relative;
    min-width: 200px;
}
.progress-bar {
    height: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
    color: white;
    font-weight: 600;
    font-size: 11px;
}
.status-badge {
    padding: 4px 12px;
    border-radius: 12px;
    font-size: 12px;
    font-weight: 600;
    color: white;
    display: inline-block;
}
</style>
<table class="rule-table">
<thead>
<tr>
<th>RULE NAME</th>
<th>COMPLIANCE</th>
<th style="text-align: right;">VIOLATIONS</th>
<th>STATUS</th>
</tr>
</thead>
<tbody>"""

            for stat in rule_stats:
                table_html += f"""<tr>
<td><strong>{stat["rule"]}</strong></td>
<td>
<div class="progress-container">
<div class="progress-bar" style="background-color: {stat["status_color"]}; width: {min(stat["violation_pct"], 100):.1f}%;">
{stat["violation_pct"]:.1f}%
</div>
</div>
</td>
<td style="text-align: right; font-weight: 600;">{stat["violation_count"]:,}</td>
<td>
<span class="status-badge" style="background-color: {stat["status_color"]};">
{stat["status"]}
</span>
</td>
</tr>"""

            table_html += """</tbody>
</table>"""

            st.markdown(table_html, unsafe_allow_html=True)

            # Summary stats
            total_violations_counted = sum(violation_counts.values())
            st.info(
                f"📊 **Analyzed:** {len(violation_counts)} rule types across {total_failed_records:,} failed records ({total_violations_counted:,} total violations counted)"
            )
        else:
            st.success("🎉 All instances compliant!")
    else:
        st.info("No validation data available")

    st.divider()

    if df_filtered.empty:
        st.info("No detailed records available for expanded view")
    else:
        # ==========================================================================
        # Violations by Type (requires records)
        # ==========================================================================
        st.subheader("Violations by Type")

        violations_by_type = {}
        for constraints in df_filtered["failedConstraints"]:
            for constraint in constraints:
                if isinstance(constraint, dict):
                    constraint_type = constraint.get("sourceConstraintComponent") or constraint.get(
                        "constraintComponent", "Unknown"
                    )
                else:
                    constraint_type = str(constraint)

                # Format is "constraintComponent::resultPath" - extract constraint part first
                if "::" in constraint_type:
                    constraint_type = constraint_type.split("::")[0]

                # Clean up namespace prefix (take part after # or /)
                if "#" in constraint_type:
                    constraint_type = constraint_type.split("#")[-1]
                elif "/" in constraint_type:
                    constraint_type = constraint_type.split("/")[-1]

                violations_by_type[constraint_type] = violations_by_type.get(constraint_type, 0) + 1

        if violations_by_type:
            viol_df = pd.DataFrame(
                [{"Constraint": k, "Count": v} for k, v in sorted(violations_by_type.items(), key=lambda x: -x[1])]
            )

            fig_bar = px.bar(
                viol_df,
                x="Count",
                y="Constraint",
                orientation="h",
                color="Count",
                color_continuous_scale=[[0, COLORS["info"]], [1, COLORS["accent"]]],
            )
            fig_bar.update_layout(
                showlegend=False,
                coloraxis_showscale=False,
                margin=dict(t=10, b=10, l=10, r=10),
                height=320,
                yaxis=dict(categoryorder="total ascending", title=""),
                xaxis=dict(title="Count"),
            )
            fig_bar.update_traces(hovertemplate="<b>%{y}</b><br>Count: %{x}<extra></extra>")
            st.plotly_chart(fig_bar, use_container_width=True)
        else:
            st.success("🎉 No violations!")

    # ==========================================================================
    # Severity Breakdown (from aggregate API - doesn't need records)
    # ==========================================================================
    st.divider()
    st.subheader("Violations by Severity")

    # Use severity counts from aggregate API (resultSeverity property)
    severity_counts = agg_stats.get("severity_counts", {})

    # Filter severity counts based on selected severities
    if selected_severities:
        severity_counts = {k: v for k, v in severity_counts.items() if k in selected_severities}

    if severity_counts:
        col_sev1, col_sev2 = st.columns([1, 2])

        with col_sev1:
            # Severity pie chart
            sev_colors = {
                "Violation": "#ef4444",  # Red
                "Warning": "#f59e0b",  # Amber
                "Info": "#3b82f6",  # Blue
            }
            colors = [sev_colors.get(s, COLORS["neutral"]) for s in severity_counts.keys()]

            fig_sev = go.Figure(
                data=[
                    go.Pie(
                        labels=list(severity_counts.keys()),
                        values=list(severity_counts.values()),
                        hole=0.4,
                        marker_colors=colors,
                        textinfo="label+value",
                        textfont_size=12,
                    )
                ]
            )
            fig_sev.update_layout(
                showlegend=False,
                margin=dict(t=10, b=10, l=10, r=10),
                height=250,
            )
            st.plotly_chart(fig_sev, use_container_width=True)

        with col_sev2:
            # Severity table
            sev_df = pd.DataFrame(
                [
                    {"Severity": k, "Count": v, "Percentage": f"{100 * v / sum(severity_counts.values()):.1f}%"}
                    for k, v in sorted(severity_counts.items(), key=lambda x: -x[1])
                ]
            )
            st.dataframe(sev_df, use_container_width=True, hide_index=True)
    else:
        st.info("No severity data available")

    # ==========================================================================
    # Violations by Dimension (from fetched records)
    # ==========================================================================
    if not df_filtered.empty:
        st.divider()
        st.subheader("Violations by Dimension")

        dimension_counts: dict[str, int] = {}
        for dims in df_filtered["dimensions"]:
            if isinstance(dims, list):
                for d in dims:
                    if d:
                        dimension_counts[d] = dimension_counts.get(d, 0) + 1

        if dimension_counts:
            dim_colors = {
                "Completeness": "#6366f1",  # Indigo
                "Validity": "#3b82f6",  # Blue
                "Conformity": "#10b981",  # Emerald
                "Consistency": "#f59e0b",  # Amber
            }
            col_dim1, col_dim2 = st.columns([1, 2])

            with col_dim1:
                colors = [dim_colors.get(d, COLORS["neutral"]) for d in dimension_counts]
                fig_dim = go.Figure(
                    data=[
                        go.Pie(
                            labels=list(dimension_counts.keys()),
                            values=list(dimension_counts.values()),
                            hole=0.4,
                            marker_colors=colors,
                            textinfo="label+value",
                            textfont_size=12,
                            hovertemplate="<b>%{label}</b><br>%{value} records<br>%{percent}<extra></extra>",
                        )
                    ]
                )
                fig_dim.update_layout(
                    showlegend=False,
                    margin=dict(t=10, b=10, l=10, r=10),
                    height=250,
                )
                st.plotly_chart(fig_dim, use_container_width=True)

            with col_dim2:
                total_with_dims = sum(dimension_counts.values())
                dim_df = pd.DataFrame(
                    [
                        {
                            "Dimension": k,
                            "Records Affected": v,
                            "Share": f"{100 * v / total_with_dims:.1f}%" if total_with_dims else "0%",
                        }
                        for k, v in sorted(dimension_counts.items(), key=lambda x: -x[1])
                    ]
                )
                st.dataframe(dim_df, use_container_width=True, hide_index=True)
        else:
            st.info(
                "No dimension data available. Ensure SHACL rules have `dq:dimension` annotations and records have been re-generated."
            )

    # ==========================================================================
    # Violation Details (requires records)
    # ==========================================================================
    if not df_filtered.empty and (df_filtered["status"] == "Failed").sum() > 0:
        st.divider()
        st.subheader("Violation Details")

        with st.expander("View all violations", expanded=False):
            violations_list = []
            failed_df = df_filtered[df_filtered["status"] == "Failed"]

            # Store full record data for dialog
            violation_records = {}

            for _idx, row in failed_df.iterrows():
                # Use messages and failedConstraints together (they should be aligned)
                constraints = row["failedConstraints"] if isinstance(row["failedConstraints"], list) else []
                messages = row["messages"] if isinstance(row.get("messages"), list) else []
                severities = row["severities"] if isinstance(row.get("severities"), list) else []
                viol_dims = row["violation_dimensions"] if isinstance(row.get("violation_dimensions"), list) else []

                instance_id = row["focusNode"].split("/")[-1] if row["focusNode"] else "Unknown"

                # Store full record for this instance (avoid duplicates)
                if instance_id not in violation_records:
                    violation_records[instance_id] = {
                        "focusNode": row["focusNode"],
                        "ruleSetId": row["ruleSetId"],
                        "ruleSetVersion": row["ruleSetVersion"],
                        "jobRunId": row["jobRunId"],
                        "status": row["status"],
                        "failedConstraints": constraints,
                        "messages": messages,
                        "severities": severities,
                        "dimensions": list(row.get("dimensions") or []),
                        "violation_dimensions": viol_dims,
                        "externalId": row["externalId"],
                        "targetClass": row.get("targetClass") or [],
                    }

                # Zip constraints with messages (pad with defaults if mismatched)
                for i, constraint in enumerate(constraints):
                    if isinstance(constraint, dict):
                        raw_type = constraint.get("sourceConstraintComponent") or constraint.get(
                            "constraintComponent", "Unknown"
                        )
                    else:
                        raw_type = str(constraint)

                    # Format is "constraintComponent::resultPath" - extract both parts
                    result_path = "-"
                    if "::" in raw_type:
                        parts = raw_type.split("::")
                        raw_type = parts[0]
                        result_path = parts[1] if len(parts) > 1 else "-"

                    # Clean up namespace prefix
                    if "#" in raw_type:
                        constraint_type = raw_type.split("#")[-1]
                    elif "/" in raw_type:
                        constraint_type = raw_type.split("/")[-1]
                    else:
                        constraint_type = raw_type

                    # Remove "ConstraintComponent" suffix for readability
                    constraint_type = constraint_type.replace("ConstraintComponent", "")

                    # Clean up result path - extract meaningful part
                    if result_path != "-" and "/" in result_path:
                        # Extract the last 2-3 segments (e.g., sp_rmdm_dm/Document/description -> Document/description)
                        path_parts = result_path.split("/")
                        if len(path_parts) >= 2:
                            result_path = "/".join(path_parts[-2:])

                    msg = messages[i] if i < len(messages) else "-"
                    sev = severities[i] if i < len(severities) else "-"
                    dim = viol_dims[i] if i < len(viol_dims) else ""

                    # Clean severity (remove namespace prefix)
                    if sev != "-" and "#" in str(sev):
                        sev = str(sev).split("#")[-1]
                    elif sev != "-" and "::" in str(sev):
                        sev = str(sev).split("::")[-1]

                    # Filter by selected severities
                    if selected_severities and (sev == "-" or sev in selected_severities):
                        violations_list.append(
                            {
                                "Instance": instance_id,
                                "Constraint": constraint_type,
                                "Property": result_path,
                                "Dimension": dim or "-",
                                "Severity": sev,
                                "Message": msg or "-",
                            }
                        )

            if violations_list:
                # Display violations table with custom column configuration
                violations_df = pd.DataFrame(violations_list)
                st.dataframe(
                    violations_df,
                    use_container_width=True,
                    height=400,
                    hide_index=True,
                    column_config={
                        "Instance": st.column_config.TextColumn("Instance", width="medium"),
                        "Constraint": st.column_config.TextColumn("Constraint", width="small"),
                        "Property": st.column_config.TextColumn("Property", width="medium"),
                        "Dimension": st.column_config.TextColumn("Dimension", width="small"),
                        "Severity": st.column_config.TextColumn("Severity", width="small"),
                        "Message": st.column_config.TextColumn("Message", width="large", max_chars=500),
                    },
                )

                # Add instance selector below the table
                st.write("---")
                st.write("💡 **Select an instance to view full details:**")

                # Get unique instances with their constraint counts
                unique_instances = []
                for instance_id in violation_records.keys():
                    count = sum(1 for v in violations_list if v["Instance"] == instance_id)
                    unique_instances.append((instance_id, count))

                # Sort by instance ID
                unique_instances.sort(key=lambda x: x[0])

                # Create selectbox with instance IDs
                instance_options = [f"{inst_id} ({count} violations)" for inst_id, count in unique_instances]

                selected_option = st.selectbox(
                    "Instance",
                    options=instance_options,
                    index=None,
                    placeholder="Choose an instance...",
                    label_visibility="collapsed",
                )

                if selected_option:
                    # Extract instance ID from the selection (before the parentheses)
                    instance_id = selected_option.split(" (")[0]

                    if instance_id in violation_records:
                        # Use targetClass to determine the view (cleaner than regex on ruleSetId)
                        target_class_list = violation_records[instance_id].get("targetClass", [])
                        view_external_id = target_class_list[0] if target_class_list else "Unknown"

                        show_instance_dialog(
                            client,
                            instance_id,
                            violation_records[instance_id],
                            "sp_rmdm_dm",  # abp-dev data model space
                            view_external_id,
                            "v8.19",  # abp-dev RMDM version
                        )

    # ==========================================================================
    # Results by Job Run (at the end)
    # ==========================================================================
    if not df_filtered.empty:
        st.divider()
        st.subheader("Results by Job Run")

        job_stats = (
            df_filtered.groupby("jobRunId")
            .agg({"status": lambda x: (x == "Passed").sum(), "constraintCount": "sum"})
            .reset_index()
        )
        job_stats.columns = ["Job Run ID", "Passed", "Violations"]
        job_stats["Failed"] = df_filtered.groupby("jobRunId")["status"].apply(lambda x: (x == "Failed").sum()).values
        job_stats["Total"] = job_stats["Passed"] + job_stats["Failed"]

        job_stats["Job Label"] = job_stats["Job Run ID"].apply(lambda x: x[:20] + "..." if len(str(x)) > 20 else x)

        fig_jobs = go.Figure()
        fig_jobs.add_trace(
            go.Bar(
                name="Passed",
                x=job_stats["Job Label"],
                y=job_stats["Passed"],
                marker_color=COLORS["passed"],
                hovertemplate="<b>Passed</b>: %{y}<extra></extra>",
            )
        )
        fig_jobs.add_trace(
            go.Bar(
                name="Failed",
                x=job_stats["Job Label"],
                y=job_stats["Failed"],
                marker_color=COLORS["failed"],
                hovertemplate="<b>Failed</b>: %{y}<extra></extra>",
            )
        )
        fig_jobs.update_layout(
            barmode="stack",
            xaxis_tickangle=-45,
            margin=dict(t=30, b=80, l=40, r=20),
            height=350,
            legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
            xaxis_title="",
            yaxis_title="Records",
        )
        st.plotly_chart(fig_jobs, use_container_width=True)

    # ==========================================================================
    # API Request Counter (sidebar footer)
    # ==========================================================================
    api_count = get_api_count()
    st.sidebar.divider()
    st.sidebar.caption(f"🔌 **API Requests: {api_count}**")
    st.sidebar.caption("""
**Queries made per load:**
1. Target classes (aggregate uniqueValues)
2. Stats (aggregate: count + jobRuns + passed/failed + severity)
3. Constraint URIs discovery (aggregate uniqueValues)
4. Per-rule violation counts (N separate aggregate count queries, where N = number of unique rules)
5. Records (filter, limit 1000)

*Count includes Streamlit re-runs*
""")


if __name__ == "__main__":
    main()
