from __future__ import annotations

import hashlib
import os
import re
import shutil
import tempfile
import time
from collections.abc import Iterable
from contextlib import contextmanager
from pathlib import Path
from zipfile import ZipFile

import yaml
from cognite.client import CogniteClient
from cognite.client.data_classes.iam import ClientCredentials
from cognite.client.data_classes.workflows import (
    FunctionTaskParameters,
    WorkflowDefinitionUpsert,
    WorkflowScheduledTriggerRule,
    WorkflowTask,
    WorkflowTriggerUpsert,
    WorkflowUpsert,
    WorkflowVersionUpsert,
)
from pydantic import BaseModel, Field, field_validator, model_validator

from cognite_data_quality._records import _ensure_records_container
from cognite_data_quality._rules import extract_target_views_from_shacl

_ILLEGAL_FILENAME_CHARS = re.compile(r'[<>:"/\\|?*\s]')


def _sanitize_filename(text: str) -> str:
    """Convert string to valid filename; align with toolkit utils.file.sanitize_filename."""
    cleaned = _ILLEGAL_FILENAME_CHARS.sub("_", text)
    return re.sub(r"_+", "_", cleaned)


def _get_embedded_function_files() -> dict[str, bytes]:
    """Extract embedded function code from package resources.

    Returns:
        dict mapping filename to file content as bytes
    """
    from importlib.resources import files

    function_code_root = files("cognite_data_quality") / "_function_code"
    embedded_files = {}

    # File list matching FunctionSettings.include_files
    file_list = [
        "__init__.py",  # Root package init (required for relative imports)
        "handler.py",
        "handlers/__init__.py",
        "handlers/instance_sync_cursor_validation.py",
        "handlers/instance_validation.py",
        "handlers/partitioned_validation.py",
        "handlers/raw_validation.py",
        "handlers/timeseries_validation.py",
        "handlers/orchestrator.py",
        "handlers/orchestration_status.py",
        "handlers/test_rule.py",
        "common/__init__.py",
        "common/shacl_utils.py",
        "common/records_api.py",
        "common/cursor_state.py",
        "common/time_window.py",
        "common/timestamp_collection_state.py",
        "requirements.txt",
    ]

    for filename in file_list:
        # Navigate to the file using path-like navigation
        parts = filename.split("/")
        resource = function_code_root
        for part in parts:
            resource = resource / part

        if resource.is_file():
            embedded_files[filename] = resource.read_bytes()

    # Include any .whl files in the function code directory
    for item in function_code_root.iterdir():
        if hasattr(item, "name") and item.name.endswith(".whl") and item.is_file():
            embedded_files[item.name] = item.read_bytes()

    return embedded_files


class FunctionSettings(BaseModel):
    """Cognite Function settings."""

    external_id: str = Field(default="data-quality-validation")
    name: str = Field(default="Data Quality Validation")
    description: str = Field(
        default="Unified function for all SHACL validation types (instance, partitioned, timeseries, orchestrator)"
    )
    runtime: str = Field(default="py311")
    source_dir: str = Field(default="function")
    include_files: list[str] = Field(
        default_factory=lambda: [
            "handler.py",
            "handlers/__init__.py",
            "handlers/instance_validation.py",
            "handlers/partitioned_validation.py",
            "handlers/raw_validation.py",
            "handlers/timeseries_validation.py",
            "handlers/orchestrator.py",
            "handlers/orchestration_status.py",
            "handlers/test_rule.py",
            "common/__init__.py",
            "common/shacl_utils.py",
            "common/records_api.py",
            "common/cursor_state.py",
            "common/time_window.py",
            "common/timestamp_collection_state.py",
            "requirements.txt",
        ]
    )


class WorkflowTaskSettings(BaseModel):
    """Workflow task retry/timeout settings."""

    retries: int = Field(default=3, ge=0, le=10, description="CDF workflow task retries (API limit: 0-10)")
    timeout: int = Field(default=600)
    on_failure: str = Field(default="abortWorkflow")


class WorkflowSettings(BaseModel):
    """Workflow settings for instance validation."""

    external_id_prefix: str
    version: str = Field(default="1")
    task: WorkflowTaskSettings = Field(default_factory=WorkflowTaskSettings)


class TriggerSettings(BaseModel):
    """Trigger settings for instance validation."""

    external_id_prefix: str
    batch_size: int = Field(default=10)
    batch_timeout: int = Field(default=60)


class RecordsSettings(BaseModel):
    """Records API defaults."""

    space: str = Field(default="dataQuality")
    container: str = Field(default="DataQualityValidationRecord")
    stream_id: str = Field(default="dq_validation_stream")


class ShaclRulesSettings(BaseModel):
    """SHACL rules location settings."""

    source_dir: str
    mime_type: str = Field(default="text/turtle")
    dataset_external_id: str | None = None


class TimeSeriesSettings(BaseModel):
    """Time series config directory settings."""

    config_dir: str


class DeploymentSettings(BaseModel):
    """Top-level deployment settings."""

    function: FunctionSettings | None = None
    workflow: WorkflowSettings
    timeseries_workflow: WorkflowSettings | None = None
    trigger: TriggerSettings
    records: RecordsSettings = Field(default_factory=RecordsSettings)
    shacl_rules: ShaclRulesSettings
    timeseries: TimeSeriesSettings | None = None
    partition_retries: int = Field(default=3, description="Orchestrator partition retry limit (no CDF API constraint)")
    function_code_dataset_external_id: str | None = Field(
        default=None,
        description="Dataset for function code uploads. Required if CDF enforces dataset-scoped file access.",
    )

    def model_post_init(self, __context) -> None:
        """Ensure function settings are populated with defaults if not provided."""
        if self.function is None:
            self.function = FunctionSettings()


class ViewRef(BaseModel):
    """View reference."""

    space: str
    external_id: str
    version: str


class ShaclRulesRef(BaseModel):
    """SHACL rules reference."""

    file: str
    external_id: str


class ValidationSettings(BaseModel):
    """Validation settings."""

    auto_load_depth: int = Field(default=2)
    verbose: bool = Field(default=True)


class RecordsOverrides(BaseModel):
    """Per-view records overrides."""

    rule_set_id: str | None = None
    rule_set_version: str | None = None
    data_domain_external_id: str | None = None


class ViewConfig(BaseModel):
    """Instance validation config."""

    view: ViewRef
    instance_spaces: list[str]
    shacl_rules: ShaclRulesRef
    validation: ValidationSettings = Field(default_factory=ValidationSettings)
    records: RecordsOverrides = Field(default_factory=RecordsOverrides)
    workflow_external_id: str | None = None
    trigger_external_id: str | None = None
    partition_count: int = Field(default=10, description="Number of partitions for batch validation")
    partition_field: str = Field(
        default="lastUpdatedTime", description="Field to use for partitioning (e.g., lastUpdatedTime, createdTime)"
    )
    chunk_size: int = Field(default=200, description="Instances per validation chunk")
    sync_batch_size: int | None = Field(
        default=None, description="Sync trigger batch size override; falls back to trigger.batch_size in settings"
    )
    use_sync_cursor_mode: bool = Field(
        default=False,
        description="Use cursor-based sync instead of payload instances. "
        "Trigger fires as a signal (no properties fetched); function "
        "fetches changes via sync cursor. Requires max_concurrent_executions=1.",
    )
    max_concurrent_executions: int | None = Field(
        default=10, description="Maximum number of concurrent workflow executions (set on the CDF workflow)"
    )
    _filename: str | None = None

    @model_validator(mode="before")
    @classmethod
    def normalise_instance_space(cls, data: dict) -> dict:
        """Accept both `instance_space` (str) and `instance_spaces` (list) from YAML."""
        if isinstance(data, dict) and "instance_space" in data and "instance_spaces" not in data:
            data["instance_spaces"] = data.pop("instance_space")
        return data

    @field_validator("instance_spaces", mode="before")
    @classmethod
    def coerce_to_list(cls, v: object) -> list[str]:
        """Coerce a bare string to a single-element list."""
        if isinstance(v, str):
            return [v]
        return v  # type: ignore[return-value]

    @model_validator(mode="after")
    def check_cursor_mode_concurrency(self) -> ViewConfig:
        if self.use_sync_cursor_mode and self.max_concurrent_executions != 1:
            raise ValueError(
                "'use_sync_cursor_mode' is enabled, so 'max_concurrent_executions' must be 1 "
                "to prevent race conditions on the sync cursor."
            )
        return self


class ScheduleConfig(BaseModel):
    """Scheduled trigger config for time series."""

    cron: str = Field(default="0 6 * * *")


class TimeseriesFilter(BaseModel):
    """Time series filter selection."""

    space: str | None = None
    external_id_prefix: str | None = None


class TimeseriesInstanceId(BaseModel):
    """Explicit time series instance ID."""

    space: str
    external_id: str


class TimeseriesConfig(BaseModel):
    """Time series validation config."""

    name: str
    description: str | None = None
    filter: TimeseriesFilter | None = None
    instance_ids: list[TimeseriesInstanceId] | None = None
    shacl_rules: ShaclRulesRef
    datamodel: ViewRef
    validation: ValidationSettings = Field(default_factory=ValidationSettings)
    records: RecordsOverrides = Field(default_factory=RecordsOverrides)
    schedule: ScheduleConfig = Field(default_factory=ScheduleConfig)
    backfill: dict | None = None
    _filename: str | None = None


class RawTableRef(BaseModel):
    """RAW table reference."""

    database: str
    name: str


class RawTableConfig(BaseModel):
    """RAW table validation config."""

    table: RawTableRef
    shacl_rules: ShaclRulesRef
    validation: ValidationSettings = Field(default_factory=ValidationSettings)
    records: RecordsOverrides = Field(default_factory=RecordsOverrides)
    partition_count: int = Field(default=5, description="Number of partitions for batch validation")
    _filename: str | None = None


def load_settings(path: Path | str) -> DeploymentSettings:
    """Load deployment settings from a YAML file."""
    settings_path = Path(path)
    data = yaml.safe_load(settings_path.read_text(encoding="utf-8")) or {}
    return DeploymentSettings(**data)


def load_view_configs(path: Path | str) -> list[ViewConfig]:
    """Load instance validation view configs from a directory."""
    config_dir = Path(path)
    configs: list[ViewConfig] = []
    for config_file in sorted(config_dir.glob("*.yaml")):
        data = yaml.safe_load(config_file.read_text(encoding="utf-8")) or {}
        data["_filename"] = config_file.name
        configs.append(ViewConfig(**data))
    return configs


DataModelId = tuple[str, str, str]


def load_views_from_cdf(
    *,
    client: CogniteClient,
    data_model_ids: Iterable[DataModelId],
    shacl_rules: ShaclRulesRef,
    instance_spaces: list[str] | None = None,
    shacl_rules_by_view: dict[str, ShaclRulesRef] | None = None,
) -> list[ViewConfig]:
    """Load view configs from CDF data models.

    Retrieves data models with inline views from CDF and builds ViewConfig objects
    for each node view. Views are read from the data model schema, not from YAML.

    Args:
        client: Cognite client for CDF API access.
        data_model_ids: Data model identifiers (space, external_id, version).
        shacl_rules: Default SHACL rules ref for all views.
        instance_spaces: Default instance spaces (uses [view.space] if None).
        shacl_rules_by_view: Per-view SHACL overrides, keyed by view external_id.

    Returns:
        List of ViewConfig objects for deployment.
    """
    ids = list(data_model_ids)
    if not ids:
        return []
    data_models = client.data_modeling.data_models.retrieve(ids=ids, inline_views=True)
    overrides = shacl_rules_by_view or {}
    configs: list[ViewConfig] = []
    for data_model in data_models:
        for view in data_model.views or []:
            if getattr(view, "used_for", None) not in ("node", "all"):
                continue
            inst_spaces = instance_spaces or [view.space]
            rules = overrides.get(view.external_id, shacl_rules)
            configs.append(
                ViewConfig(
                    view=ViewRef(space=view.space, external_id=view.external_id, version=view.version),
                    instance_spaces=inst_spaces,
                    shacl_rules=rules,
                    _filename=f"cdf_{view.space}_{view.external_id}.yaml",
                )
            )
    return configs


def load_timeseries_configs(path: Path | str) -> list[TimeseriesConfig]:
    """Load time series validation configs from a directory."""
    config_dir = Path(path)
    configs: list[TimeseriesConfig] = []
    for config_file in sorted(config_dir.glob("*.yaml")):
        data = yaml.safe_load(config_file.read_text(encoding="utf-8")) or {}
        data["_filename"] = config_file.name
        configs.append(TimeseriesConfig(**data))
    return configs


def load_table_configs(path: Path | str) -> list[RawTableConfig]:
    """Load RAW table validation configs from a directory."""
    config_dir = Path(path)
    if not config_dir.exists():
        return []
    configs: list[RawTableConfig] = []
    for config_file in sorted(config_dir.glob("*.yaml")):
        data = yaml.safe_load(config_file.read_text(encoding="utf-8")) or {}
        data["_filename"] = config_file.name
        configs.append(RawTableConfig(**data))
    return configs


def _resolve_repo_root(settings_path: Path) -> Path:
    for parent in [settings_path.parent, *list(settings_path.parents)]:
        candidate = parent / "validate_instances_shacl"
        if candidate.exists():
            return parent
    return settings_path.parent


def _resolve_path(base_dir: Path, candidate: str) -> Path:
    path = Path(candidate)
    return path if path.is_absolute() else (base_dir / path).resolve()


def _hash_files(paths: Iterable[Path]) -> str:
    hasher = hashlib.sha256()
    for path in sorted(paths, key=lambda p: str(p)):
        hasher.update(path.read_bytes())
    return hasher.hexdigest()[:16]


@contextmanager
def _create_function_deploy_zip(settings: FunctionSettings) -> Iterable[Path]:
    """Create temp zip from embedded package resources.

    Reads function handler code from the embedded _function_code directory
    in the package and creates a deployment zip file.
    """
    embedded_files = _get_embedded_function_files()

    with tempfile.TemporaryDirectory() as tmpdir:
        func_dir = Path(tmpdir) / "function"
        func_dir.mkdir()

        # Write all embedded files to temp directory
        for filename, content in embedded_files.items():
            dest = func_dir / filename
            dest.parent.mkdir(parents=True, exist_ok=True)
            dest.write_bytes(content)

        # Create zip
        zip_path = Path(tmpdir) / "function.zip"
        with ZipFile(zip_path, "w", strict_timestamps=False) as zf:
            for root, _, files in os.walk(func_dir):
                root_path = Path(root)
                for filename in files:
                    file_path = root_path / filename
                    arcname = file_path.relative_to(func_dir)
                    zf.write(file_path, arcname=str(arcname))

        yield zip_path


def _function_hash(include_files: list[str]) -> str:
    """Compute hash for embedded function code.

    Args:
        include_files: List of file names (unused, kept for compatibility)

    Returns:
        Hash string (first 16 hex chars of SHA256)
    """
    embedded_files = _get_embedded_function_files()
    hasher = hashlib.sha256()

    # Hash files in sorted order for consistency
    for filename in sorted(embedded_files.keys()):
        hasher.update(embedded_files[filename])

    return hasher.hexdigest()[:16]


def _wait_for_functions_ready(
    client: CogniteClient,
    external_ids: Iterable[str],
    timeout_seconds: int = 15 * 60,
    poll_seconds: int = 10,
) -> None:
    start_time = time.monotonic()
    remaining = set(external_ids)
    last_status_print = start_time

    print(f"  Waiting for {len(remaining)} function(s) to finish deploying...")

    while remaining:
        still_deploying: dict[str, str] = {}  # external_id -> status
        failed: dict[str, str] = {}  # external_id -> status
        for external_id in remaining:
            func = client.functions.retrieve(external_id=external_id)
            status = getattr(func, "status", "Unknown")
            if status and status.lower() == "ready":
                pass  # done
            elif status and status.lower() == "failed":
                failed[external_id] = status
            else:
                still_deploying[external_id] = status

        if failed:
            failed_list = ", ".join(f"{ext_id} ({status})" for ext_id, status in failed.items())
            raise RuntimeError(f"Function(s) failed to deploy: {failed_list}")

        if not still_deploying:
            elapsed = int(time.monotonic() - start_time)
            print(f"  ✓ All functions ready (took {elapsed}s)")
            return

        elapsed = time.monotonic() - start_time
        if elapsed - last_status_print >= 60:
            # Print status update every minute with current statuses
            elapsed_mins = int(elapsed / 60)
            status_summary = ", ".join(f"{ext_id}: {status}" for ext_id, status in still_deploying.items())
            print(
                f"  Still deploying... ({elapsed_mins}m elapsed, {len(still_deploying)} function(s): {status_summary})"
            )
            last_status_print = elapsed

        if elapsed > timeout_seconds:
            status_list = ", ".join(f"{ext_id} ({status})" for ext_id, status in still_deploying.items())
            raise TimeoutError(f"Timed out waiting for functions to deploy: {status_list}")

        time.sleep(poll_seconds)
        remaining = set(still_deploying.keys())


def _upload_shacl_rules(
    client: CogniteClient,
    settings: DeploymentSettings,
    shacl_dir: Path,
    shacl_file: ShaclRulesRef,
    dry_run: bool,
    rule_set_id: str | None = None,
    rule_set_version: str | None = None,
) -> str:
    file_path = shacl_dir / shacl_file.file
    if not file_path.exists():
        raise FileNotFoundError(f"SHACL rules file not found: {file_path}")
    if dry_run:
        return shacl_file.external_id
    dataset_external_id = settings.shacl_rules.dataset_external_id
    dataset_id = None
    if dataset_external_id:
        dataset = client.data_sets.retrieve(external_id=dataset_external_id)
        if dataset:
            dataset_id = dataset.id

    # Build metadata with rule set information
    metadata = {}
    if rule_set_id:
        metadata["rule_set_id"] = rule_set_id
    if rule_set_version:
        metadata["rule_set_version"] = rule_set_version

    uploaded = client.files.upload(
        path=str(file_path),
        name=shacl_file.file,
        external_id=shacl_file.external_id,
        mime_type=settings.shacl_rules.mime_type,
        data_set_id=dataset_id,
        metadata=metadata if metadata else None,
        overwrite=True,
    )
    return uploaded.external_id


def _upload_view_config(
    client: CogniteClient,
    view_config: ViewConfig,
    dataset_id: int | None,
    dry_run: bool,
) -> str:
    """Upload a view config as YAML to CDF Files (reuses SHACL rules dataset).

    External ID scheme: ``{view_external_id}_view_config``
    e.g. ``Pump`` → ``Pump_view_config``
    """
    external_id = f"{view_config.view.external_id}_view_config"
    if dry_run:
        return external_id
    content = yaml.dump(view_config.model_dump(exclude_none=True), allow_unicode=True, sort_keys=False)
    with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False, encoding="utf-8") as tmp:
        tmp.write(content)
        tmp_path = tmp.name
    try:
        client.files.upload(
            path=tmp_path,
            name=f"{view_config.view.external_id}_view_config.yaml",
            external_id=external_id,
            mime_type="application/x-yaml",
            data_set_id=dataset_id,
            overwrite=True,
        )
    finally:
        Path(tmp_path).unlink()
    return external_id


def _upload_settings(
    client: CogniteClient,
    settings: DeploymentSettings,
    dataset_id: int | None,
    dry_run: bool,
) -> str:
    """Upload deployment settings as YAML to CDF Files (reuses SHACL rules dataset).

    External ID scheme: ``{function_external_id}_settings``
    e.g. ``data-quality-validation_settings``
    """
    external_id = f"{settings.function.external_id}_settings"
    if dry_run:
        return external_id
    content = yaml.dump(settings.model_dump(exclude_none=True), allow_unicode=True, sort_keys=False)
    with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False, encoding="utf-8") as tmp:
        tmp.write(content)
        tmp_path = tmp.name
    try:
        client.files.upload(
            path=tmp_path,
            name="settings.yaml",
            external_id=external_id,
            mime_type="application/x-yaml",
            data_set_id=dataset_id,
            overwrite=True,
        )
    finally:
        Path(tmp_path).unlink()
    return external_id


def _deploy_function(
    client: CogniteClient,
    settings: FunctionSettings,
    force: bool,
    dry_run: bool,
    function_code_dataset_external_id: str | None = None,
    debug_save_zip: Path | None = None,
    secrets: dict[str, str] | None = None,
) -> dict[str, str]:
    print("  Using embedded function code from package")
    current_hash = _function_hash(settings.include_files)

    # Retrieve existing function (if exists) to check hash
    deployed = None
    deployed_hash = None
    try:
        deployed = client.functions.retrieve(external_id=settings.external_id)
        deployed_hash = deployed.metadata.get("code_hash") if deployed and deployed.metadata else None
    except Exception:
        pass  # Function doesn't exist

    # If hash matches and not forced, skip deployment
    if not force and deployed_hash == current_hash:
        return {"function": settings.external_id, "status": "skipped"}

    if dry_run:
        return {"function": settings.external_id, "status": "dry_run"}

    # Delete existing function before creating new one (CDF allows deleting even if Queued/Deploying)
    if deployed:
        try:
            client.functions.delete(external_id=settings.external_id)
        except Exception as e:
            # If delete fails, log but continue (function might have been deleted already)
            print(f"  Note: Could not delete function {settings.external_id}: {e}")

    dataset_id = None
    if function_code_dataset_external_id:
        dataset = client.data_sets.retrieve(external_id=function_code_dataset_external_id)
        if dataset:
            dataset_id = dataset.id

    with _create_function_deploy_zip(settings) as zip_path:
        if debug_save_zip is not None:
            save_dir = Path(debug_save_zip)
            save_dir.mkdir(parents=True, exist_ok=True)
            dest = save_dir / f"{settings.external_id}.zip"
            shutil.copy2(zip_path, dest)

        uploaded_file = client.files.upload(
            path=str(zip_path),
            name=f"{_sanitize_filename(settings.name)}.zip",
            external_id=settings.external_id,
            overwrite=True,
            data_set_id=dataset_id,
        )

    create_kwargs: dict[str, object] = {
        "name": settings.name,
        "external_id": settings.external_id,
        "file_id": uploaded_file.id,
        "description": settings.description,
        "runtime": settings.runtime,
        "metadata": {"code_hash": current_hash},
    }
    if secrets:
        create_kwargs["secrets"] = secrets
    client.functions.create(**create_kwargs)

    return {"function": settings.external_id, "status": "deployed"}


def deploy_functions(
    *,
    client: CogniteClient,
    settings: DeploymentSettings,
    force: bool = False,
    dry_run: bool = False,
    debug_save_zip: Path | str | None = None,
    secrets: dict[str, str] | None = None,
) -> dict[str, dict[str, str]]:
    """Deploy the unified SHACL validation function using embedded code."""
    # Get OAuth credentials for trigger creation (required by orchestrator)
    # If not provided, try environment variables as fallback
    if secrets is None:
        import os

        client_id = os.getenv("COGNITE_CLIENT_ID") or os.getenv("IDP_CLIENT_ID")
        client_secret = os.getenv("COGNITE_CLIENT_SECRET") or os.getenv("IDP_CLIENT_SECRET")
        if client_id and client_secret:
            secrets = {"client-id": client_id, "client-secret": client_secret}

    save_zip_dir = Path(debug_save_zip) if debug_save_zip else None

    result = _deploy_function(
        client,
        settings.function,
        force=force,
        dry_run=dry_run,
        function_code_dataset_external_id=settings.function_code_dataset_external_id,
        debug_save_zip=save_zip_dir,
        secrets=secrets,
    )

    return {"function": result}


def _compute_view_config_hash(view_config: ViewConfig, shacl_content: str) -> str:
    """Compute hash of view configuration for change detection.

    This hash is used to determine if a workflow needs redeployment.
    It includes the view config, SHACL rules content, and all deployment settings.
    """
    import hashlib
    import json

    config_dict = {
        "view": {
            "space": view_config.view.space,
            "external_id": view_config.view.external_id,
            "version": view_config.view.version,
        },
        "instance_spaces": view_config.instance_spaces,
        "shacl_rules": {
            "file": view_config.shacl_rules.file,
            "external_id": view_config.shacl_rules.external_id,
        },
        "validation": {
            "auto_load_depth": view_config.validation.auto_load_depth,
            "verbose": view_config.validation.verbose,
        },
        "records": {
            "rule_set_id": view_config.records.rule_set_id if view_config.records else None,
            "rule_set_version": view_config.records.rule_set_version if view_config.records else None,
            "data_domain_external_id": view_config.records.data_domain_external_id if view_config.records else None,
        },
        "max_concurrent_executions": view_config.max_concurrent_executions,
        "chunk_size": view_config.chunk_size,
        "sync_batch_size": view_config.sync_batch_size,
        "partition_count": view_config.partition_count,
        "partition_field": view_config.partition_field,
        "use_sync_cursor_mode": view_config.use_sync_cursor_mode,
        "shacl_content": shacl_content,
    }

    config_str = json.dumps(config_dict, sort_keys=True)
    return hashlib.sha256(config_str.encode()).hexdigest()[:12]


def _get_deployed_workflow_hash(client: CogniteClient, workflow_external_id: str) -> str | None:
    """Get hash from deployed workflow description.

    Returns None if workflow doesn't exist or has no hash embedded.
    Hash format in description: "... [hash:abc123def456]"
    """
    import re

    try:
        workflow = client.workflows.retrieve(external_id=workflow_external_id)
        if workflow and workflow.description:
            match = re.search(r"\[hash:([a-f0-9]+)\]", workflow.description)
            return match.group(1) if match else None
    except Exception:
        pass

    return None


def deploy_instance_workflows(
    *,
    client: CogniteClient,
    settings: DeploymentSettings,
    views: list[ViewConfig],
    shacl_dir: Path,
    force: bool = False,
    dry_run: bool = False,
    view_filter: list[str] | None = None,
    skip_unchanged: bool = True,
) -> list[dict[str, object]]:
    """Deploy workflows for instance validation.

    Note: Triggers are managed by the orchestrator function, not created during deployment.

    Args:
        client: CogniteClient instance
        settings: Deployment settings
        views: List of view configurations
        shacl_dir: Directory containing SHACL rules files
        force: Force redeployment even if config unchanged
        dry_run: Preview without making changes
        view_filter: Optional list of view external_ids to deploy (deploy all if None)
        skip_unchanged: Skip deployment if hash matches deployed workflow (default: True)

    Returns:
        List of deployment results with status for each view
    """
    # Filter views if specified
    if view_filter:
        views = [v for v in views if v.view.external_id in view_filter]
        print(f"  Filtered to {len(views)} views")

    # Get dataset ID for workflows (use SHACL rules dataset)
    dataset_id = None
    if settings.shacl_rules.dataset_external_id:
        dataset = client.data_sets.retrieve(external_id=settings.shacl_rules.dataset_external_id)
        if dataset:
            dataset_id = dataset.id

    settings_eid = _upload_settings(client, settings, dataset_id, dry_run=dry_run)
    print(f"  Uploaded settings → CDF Files: {settings_eid}")

    results: list[dict[str, object]] = []
    for view_config in views:
        view_name = view_config.view.external_id.lower().replace("yourorg", "")
        workflow_external_id = view_config.workflow_external_id or f"{settings.workflow.external_id_prefix}-{view_name}"

        # Compute current config hash for change detection
        shacl_file_path = shacl_dir / view_config.shacl_rules.file
        shacl_content = shacl_file_path.read_text() if shacl_file_path.exists() else ""
        current_hash = _compute_view_config_hash(view_config, shacl_content)

        # Check if deployment needed
        if not dry_run:
            deployed_hash = _get_deployed_workflow_hash(client, workflow_external_id)

            if skip_unchanged and not force and current_hash == deployed_hash:
                print(f"  Workflow {workflow_external_id} up to date (hash: {current_hash})")
                results.append(
                    {
                        "view": view_config.view.external_id,
                        "workflow_external_id": workflow_external_id,
                        "status": "skipped",
                        "reason": "unchanged",
                    }
                )
                continue

            if deployed_hash and deployed_hash != current_hash:
                print(f"  Config changed: {deployed_hash} -> {current_hash}")
            elif not deployed_hash:
                print(f"  New workflow (hash: {current_hash})")

        records = settings.records
        overrides = view_config.records
        _upload_shacl_rules(
            client,
            settings,
            shacl_dir,
            view_config.shacl_rules,
            dry_run=dry_run,
            rule_set_id=overrides.rule_set_id if overrides else records.container,
            rule_set_version=overrides.rule_set_version if overrides else None,
        )
        view_config_eid = _upload_view_config(client, view_config, dataset_id, dry_run=dry_run)
        print(f"  Uploaded view config → CDF Files: {view_config_eid}")
        records_config = {
            "stream_id": records.stream_id,
            "rule_set_id": overrides.rule_set_id or f"{view_config.view.external_id}SHACL",
            "rule_set_version": overrides.rule_set_version or "1.0",
            "data_domain_external_id": overrides.data_domain_external_id or view_config.view.external_id,
            "records_space": records.space,
            "records_container": records.container,
        }
        if view_config.use_sync_cursor_mode:
            task_data = {
                "validation_type": "instance_sync_cursor",
                "view_space": view_config.view.space,
                "view_external_id": view_config.view.external_id,
                "view_version": view_config.view.version,
                "instance_spaces": view_config.instance_spaces,
                "shacl_rules_file_external_id": view_config.shacl_rules.external_id,
                "datamodel_space": view_config.view.space,
                "datamodel_external_id": view_config.view.external_id,
                "datamodel_version": view_config.view.version,
                "auto_load_depth": view_config.validation.auto_load_depth,
                "verbose": view_config.validation.verbose,
                "chunk_size": view_config.chunk_size,
                "data_quality_space": records.space,
                "records_config": records_config,
                "initial_sync_cutoff": None,  # Patched by orchestrator at runtime via create_sync_trigger()
                "job_run_id": None,  # Patched by orchestrator at runtime via create_sync_trigger()
            }
        else:
            task_data = {
                "validation_type": "instance",
                "instances": "${workflow.input}",
                "shacl_rules_file_external_id": view_config.shacl_rules.external_id,
                "datamodel_space": view_config.view.space,
                "datamodel_external_id": view_config.view.external_id,
                "datamodel_version": view_config.view.version,
                "auto_load_depth": view_config.validation.auto_load_depth,
                "verbose": view_config.validation.verbose,
                "max_retries": settings.partition_retries,
                "records_config": records_config,
            }

        task = WorkflowTask(
            external_id=f"validate-{view_name}",
            name=f"Validate {view_config.view.external_id} with SHACL",
            description=f"Validates {view_config.view.external_id} instances against SHACL rules",
            parameters=FunctionTaskParameters(external_id=settings.function.external_id, data=task_data),
            retries=settings.workflow.task.retries,
            timeout=settings.workflow.task.timeout,
            on_failure=settings.workflow.task.on_failure,
        )

        if not dry_run:
            print(f"  Creating/updating workflow: {workflow_external_id}")
            wf_upsert = WorkflowUpsert(
                external_id=workflow_external_id,
                description=f"SHACL validation workflow for {view_config.view.external_id} [hash:{current_hash}]",
                data_set_id=dataset_id,
            )
            if view_config.max_concurrent_executions is not None:
                wf_upsert.max_concurrent_executions = view_config.max_concurrent_executions
            client.workflows.upsert(wf_upsert)

            workflow_definition = WorkflowDefinitionUpsert(
                tasks=[task], description=f"Single-task workflow for {view_config.view.external_id}"
            )
            workflow_version = WorkflowVersionUpsert(
                workflow_external_id=workflow_external_id,
                version=settings.workflow.version,
                workflow_definition=workflow_definition,
            )
            client.workflows.versions.upsert(workflow_version)

        results.append(
            {
                "view": view_config.view.external_id,
                "workflow_external_id": workflow_external_id,
                "status": "deployed" if not dry_run else "dry_run",
            }
        )
    return results


def _compute_timeseries_config_hash(config: TimeseriesConfig, shacl_content: str) -> str:
    """Compute hash of timeseries configuration for change detection.

    This hash is used to determine if a workflow needs redeployment.
    """
    import hashlib
    import json

    config_dict = {
        "name": config.name,
        "description": config.description,
        "datamodel": {
            "space": config.datamodel.space,
            "external_id": config.datamodel.external_id,
            "version": config.datamodel.version,
        },
        "shacl_rules": {
            "file": config.shacl_rules.file,
            "external_id": config.shacl_rules.external_id,
        },
        "schedule": {"cron": config.schedule.cron},
        "validation": {"verbose": config.validation.verbose},
        "records": {
            "rule_set_id": config.records.rule_set_id if config.records else None,
            "rule_set_version": config.records.rule_set_version if config.records else None,
            "data_domain_external_id": config.records.data_domain_external_id if config.records else None,
        },
        "filter": config.filter.model_dump(exclude_none=True) if config.filter else None,
        "instance_ids": [item.model_dump() for item in config.instance_ids] if config.instance_ids else None,
        "backfill": config.backfill if config.backfill else None,
        "shacl_content": shacl_content,
    }

    config_str = json.dumps(config_dict, sort_keys=True)
    return hashlib.sha256(config_str.encode()).hexdigest()[:16]


def deploy_timeseries_workflows(
    *,
    client: CogniteClient,
    settings: DeploymentSettings,
    configs: list[TimeseriesConfig],
    shacl_dir: Path,
    force: bool = False,
    dry_run: bool = False,
    config_filter: list[str] | None = None,
    skip_unchanged: bool = True,
) -> list[dict[str, object]]:
    """Deploy scheduled workflows and triggers for time series validation.

    Args:
        client: CogniteClient instance
        settings: Deployment settings
        configs: List of timeseries configurations
        shacl_dir: Directory containing SHACL rules files
        force: Force redeployment even if config unchanged
        dry_run: Preview without making changes
        config_filter: Optional list of config names to deploy (deploy all if None)
        skip_unchanged: Skip deployment if hash matches deployed workflow (default: True)

    Returns:
        List of deployment results with status for each config
    """
    # Filter configs if specified
    if config_filter:
        configs = [c for c in configs if c.name in config_filter]
        print(f"  Filtered to {len(configs)} timeseries configs")

    # Get dataset ID for workflows (use SHACL rules dataset)
    dataset_id = None
    if settings.shacl_rules.dataset_external_id:
        dataset = client.data_sets.retrieve(external_id=settings.shacl_rules.dataset_external_id)
        if dataset:
            dataset_id = dataset.id

    workflow_settings = settings.timeseries_workflow or settings.workflow
    results: list[dict[str, object]] = []
    for config in configs:
        workflow_external_id = f"{workflow_settings.external_id_prefix}-{config.name}"

        # Compute current config hash for change detection
        shacl_file_path = shacl_dir / config.shacl_rules.file
        shacl_content = shacl_file_path.read_text() if shacl_file_path.exists() else ""
        current_hash = _compute_timeseries_config_hash(config, shacl_content)

        # Check if deployment needed
        if not dry_run:
            deployed_hash = _get_deployed_workflow_hash(client, workflow_external_id)

            if skip_unchanged and not force and current_hash == deployed_hash:
                print(f"  Workflow {workflow_external_id} up to date (hash: {current_hash})")
                results.append(
                    {
                        "name": config.name,
                        "workflow_external_id": workflow_external_id,
                        "trigger_external_id": f"dq-ts-trigger-{config.name}",
                        "status": "skipped",
                        "reason": "unchanged",
                    }
                )
                continue

            if deployed_hash and deployed_hash != current_hash:
                print(f"  Config changed: {deployed_hash} -> {current_hash}")
            elif not deployed_hash:
                print(f"  New workflow (hash: {current_hash})")

        records = settings.records
        overrides = config.records
        _upload_shacl_rules(
            client,
            settings,
            shacl_dir,
            config.shacl_rules,
            dry_run=dry_run,
            rule_set_id=overrides.rule_set_id if overrides else records.container,
            rule_set_version=overrides.rule_set_version if overrides else None,
        )

        task_data: dict[str, object] = {
            "shacl_rules_file_external_id": config.shacl_rules.external_id,
            "datamodel_space": config.datamodel.space,
            "datamodel_external_id": config.datamodel.external_id,
            "datamodel_version": config.datamodel.version,
            "verbose": config.validation.verbose,
            "max_retries": settings.partition_retries,
            "records_config": {
                "stream_id": records.stream_id,
                "rule_set_id": overrides.rule_set_id or f"{config.name}SHACL",
                "rule_set_version": overrides.rule_set_version or "1.0",
                "data_domain_external_id": overrides.data_domain_external_id or "TimeSeries",
                "records_space": records.space,
                "records_container": records.container,
            },
        }
        if config.filter is not None:
            task_data["filter"] = config.filter.model_dump(exclude_none=True)
        if config.instance_ids is not None:
            task_data["instance_ids"] = [item.model_dump() for item in config.instance_ids]
        if config.backfill:
            task_data["backfill"] = config.backfill

        task_data["validation_type"] = "timeseries"

        task = WorkflowTask(
            external_id=f"validate-ts-{config.name}",
            name=f"Validate Time Series: {config.name}",
            description=config.description or "Time series quality validation",
            parameters=FunctionTaskParameters(
                external_id=settings.function.external_id,
                data=task_data,
            ),
            retries=workflow_settings.task.retries,
            timeout=workflow_settings.task.timeout,
            on_failure=workflow_settings.task.on_failure,
        )

        if not dry_run:
            print(f"  Creating/updating workflow: {workflow_external_id}")
            client.workflows.upsert(
                WorkflowUpsert(
                    external_id=workflow_external_id,
                    description=f"Time series quality validation: {config.name} [hash:{current_hash}]",
                    data_set_id=dataset_id,
                )
            )
            workflow_definition = WorkflowDefinitionUpsert(
                tasks=[task], description=f"Time series validation workflow: {config.name}"
            )
            workflow_version = WorkflowVersionUpsert(
                workflow_external_id=workflow_external_id,
                version=workflow_settings.version,
                workflow_definition=workflow_definition,
            )
            client.workflows.versions.upsert(workflow_version)

        trigger_external_id = f"dq-ts-trigger-{config.name}"
        if not dry_run:
            creds = client.config.credentials
            trigger_credentials = ClientCredentials(
                client_id=creds.client_id,
                client_secret=creds.client_secret,
            )
            trigger_rule = WorkflowScheduledTriggerRule(cron_expression=config.schedule.cron)
            trigger = WorkflowTriggerUpsert(
                external_id=trigger_external_id,
                trigger_rule=trigger_rule,
                workflow_external_id=workflow_external_id,
                workflow_version=workflow_settings.version,
                metadata={"description": f"Scheduled trigger for {config.name}", "cron": config.schedule.cron},
            )
            try:
                client.workflows.triggers.delete(external_id=trigger_external_id)
            except Exception:
                pass
            client.workflows.triggers.upsert(trigger, trigger_credentials)

        results.append(
            {
                "name": config.name,
                "workflow_external_id": workflow_external_id,
                "trigger_external_id": trigger_external_id,
                "status": "deployed" if not dry_run else "dry_run",
            }
        )
    return results


def deploy_raw_table_shacl(
    *,
    client: CogniteClient,
    settings: DeploymentSettings,
    tables: list[RawTableConfig],
    shacl_dir: Path,
    dry_run: bool = False,
) -> list[dict[str, object]]:
    """Deploy SHACL rules for RAW table validation.

    For RAW tables, we only upload SHACL files - no workflows or triggers are created.
    RAW validation is triggered explicitly via function calls.

    Args:
        client: CogniteClient instance
        settings: Deployment settings with records configuration
        tables: List of RAW table configs
        shacl_dir: Directory containing SHACL rule files
        dry_run: Preview without uploading

    Returns:
        list of deployment results for each table
    """
    if not tables:
        return []

    results: list[dict[str, object]] = []

    for table_config in tables:
        db_name = table_config.table.database
        table_name = table_config.table.name
        print(f"\n  RAW table: {db_name}.{table_name}")

        # Upload SHACL rules
        records = settings.records
        overrides = table_config.records
        _upload_shacl_rules(
            client,
            settings,
            shacl_dir,
            table_config.shacl_rules,
            dry_run=dry_run,
            rule_set_id=overrides.rule_set_id if overrides else records.container,
            rule_set_version=overrides.rule_set_version if overrides else None,
        )

        results.append(
            {
                "database": db_name,
                "table": table_name,
                "shacl_file": table_config.shacl_rules.external_id,
                "status": "deployed" if not dry_run else "dry_run",
            }
        )

    return results


def deploy_data_models(
    *,
    client: CogniteClient,
    space: str = "dataQuality",
    containers: list[str] | None = None,
    data_model_id: str = "DataQualityMonitoring",
    data_model_version: str = "1",
    dry_run: bool = False,
) -> dict[str, object]:
    """Deploy data models with auto-generated views from containers.

    Creates views from existing DMS containers, making container data
    accessible through the CDF Data Modeling UI.

    Args:
        client: CogniteClient instance
        space: Space containing containers (default: "dataQuality")
        containers: List of container IDs to create views for
                   (default: ["OrchestrationState", "FunctionValidationState"])
        data_model_id: External ID for the data model
        data_model_version: Version string
        dry_run: Preview without deploying

    Returns:
        dict with deployment results (datamodel, views, status)

    Example:
        >>> from cognite_data_quality import deploy_data_models
        >>> from cognite.client import CogniteClient
        >>> client = CogniteClient()
        >>> result = deploy_data_models(client=client, dry_run=False)
        >>> print(f"Created {len(result['views'])} views")
    """
    from cognite.client.data_classes.data_modeling import (
        DataModelApply,
        MappedPropertyApply,
        ViewApply,
    )
    from cognite.client.data_classes.data_modeling.ids import ContainerId, ViewId

    if containers is None:
        containers = ["OrchestrationState", "FunctionValidationState"]

    views = []

    for container_id in containers:
        print(f"  Processing container: {space}/{container_id}")

        # Retrieve container
        try:
            container = client.data_modeling.containers.retrieve((space, container_id))
        except Exception as e:
            print(f"  ERROR: Could not retrieve container: {e}")
            continue

        if not container:
            print(f"  WARNING: Container {space}/{container_id} not found, skipping")
            continue

        # Build view from container
        view_id = f"{container_id}View"
        view_properties = {}

        for prop_name, prop_def in container.properties.items():
            view_properties[prop_name] = MappedPropertyApply(
                container=ContainerId(space=space, external_id=container_id),
                container_property_identifier=prop_name,
                name=prop_def.description.split(".")[0].strip() if prop_def.description else prop_name,
                description=prop_def.description or "",
            )

        view = ViewApply(
            space=space,
            external_id=view_id,
            version=data_model_version,
            name=f"{container_id} View",
            description=f"Auto-generated view for {container_id} container",
            properties=view_properties,
        )

        # Check if view already exists
        try:
            existing_view = client.data_modeling.views.retrieve((space, view_id, data_model_version))
            if existing_view:
                print(f"    ✓ View {view_id} already exists ({len(view_properties)} properties)")
            else:
                print(f"    • Creating view: {view_id} ({len(view_properties)} properties)")
        except Exception:
            print(f"    • Creating view: {view_id} ({len(view_properties)} properties)")

        views.append(view)

    if not views:
        print("  No views to create")
        return {
            "datamodel": None,
            "views": [],
            "status": "skipped",
        }

    if dry_run:
        print("  [DRY RUN] Would create data model and views")
        return {
            "datamodel": {"space": space, "external_id": data_model_id, "version": data_model_version},
            "views": [{"space": v.space, "external_id": v.external_id, "version": v.version} for v in views],
            "status": "dry_run",
        }

    # Apply views
    client.data_modeling.views.apply(views)
    print(f"  Ensured {len(views)} views are deployed")

    # Create data model
    view_ids = [ViewId(space=v.space, external_id=v.external_id, version=v.version) for v in views]
    data_model = DataModelApply(
        space=space,
        external_id=data_model_id,
        version=data_model_version,
        name="Data Quality Monitoring",
        description="Data models for data quality orchestration and state tracking",
        views=view_ids,
    )

    client.data_modeling.data_models.apply(data_model)
    print(f"  Ensured data model: {space}/{data_model_id} v{data_model_version}")

    return {
        "datamodel": {"space": space, "external_id": data_model_id, "version": data_model_version},
        "views": [{"space": v.space, "external_id": v.external_id, "version": v.version} for v in views],
        "status": "deployed",
    }


def deploy_validation_infrastructure(
    *,
    client: CogniteClient,
    settings_path: Path | str,
    views_dir: Path | str | None = None,
    data_models: Iterable[DataModelId] | None = None,
    default_shacl_rules: ShaclRulesRef | None = None,
    filter_views_by_shacl_rules: bool = False,
    timeseries_dir: Path | str | None = None,
    shacl_rules_dir: Path | str | None = None,
    function_external_id: str | None = None,
    function_secrets: dict[str, str] | None = None,
    force: bool = False,
    force_workflows: bool = False,
    dry_run: bool = False,
    debug_save_zip: Path | str | None = None,
) -> dict[str, object]:
    """Deploy functions, workflows, and triggers using embedded function code.

    Views can be loaded from CDF (data_models) or from YAML (views_dir).
    When data_models is provided, views are read from CDF data model tuples;
    default_shacl_rules is required in that case.

    When filter_views_by_shacl_rules is True, only views referenced in the
    SHACL rules file (via sh:targetClass) are deployed.

    When debug_save_zip is set to a directory path, each function zip is
    saved there as {external_id}.zip for inspection (e.g. dist/debug-zips).

    Args:
        function_external_id: Optional override for function external_id.
            Defaults to "data-quality-validation" if not specified.
        function_secrets: Optional secrets to pass to the function (e.g., {"client-id": "...", "client-secret": "..."}).
            If not provided, will attempt to read from environment variables
            COGNITE_CLIENT_ID and COGNITE_CLIENT_SECRET.
    """
    settings_path = Path(settings_path)
    settings = load_settings(settings_path)
    base_dir = settings_path.parent

    # Override external_id if provided
    if function_external_id is not None:
        settings.function.external_id = function_external_id

    # Ensure all data quality infrastructure containers exist
    if not dry_run:
        print("Ensuring data quality infrastructure containers...")

        # Ensure space exists once (all containers use the same space)
        from cognite_data_quality._containers import _ensure_space_exists

        space = settings.records.space
        _ensure_space_exists(client, space)

        # Records container (for validation results)
        print(f"  Records: {settings.records.space}/{settings.records.container}")
        _ensure_records_container(client, settings.records.space, settings.records.container)

        # State containers (for orchestration and function state tracking)
        from cognite_data_quality._containers import (
            _ensure_function_validation_state_container,
            _ensure_orchestration_state_container,
            _ensure_raw_validation_state_container,
        )

        print(f"  Orchestration: {space}/OrchestrationState")
        _ensure_orchestration_state_container(client, space, "OrchestrationState")

        print(f"  Function State: {space}/FunctionValidationState")
        _ensure_function_validation_state_container(client, space, "FunctionValidationState")

        print(f"  RAW Validation State: {space}/RawValidationState")
        _ensure_raw_validation_state_container(client, space, "RawValidationState")

        # Deploy monitoring data models and views (for UI access to state containers)
        print("\nDeploying data quality monitoring data model...")
        deploy_data_models(
            client=client,
            space=space,
            containers=["FunctionValidationState", "OrchestrationState", "RawValidationState"],
            data_model_id="DataQualityMonitoring",
            data_model_version="1",
            dry_run=False,
        )

    # shacl_rules.source_dir is relative to settings file directory
    shacl_dir = (
        _resolve_path(base_dir, shacl_rules_dir)
        if shacl_rules_dir is not None
        else _resolve_path(base_dir, settings.shacl_rules.source_dir)
    )
    if timeseries_dir is None and settings.timeseries is not None:
        timeseries_dir = settings.timeseries.config_dir
    resolved_timeseries_dir = _resolve_path(base_dir, timeseries_dir) if timeseries_dir is not None else None

    if data_models is not None:
        if default_shacl_rules is None:
            raise ValueError("default_shacl_rules is required when data_models is provided")
        view_configs = load_views_from_cdf(
            client=client,
            data_model_ids=list(data_models),
            shacl_rules=default_shacl_rules,
        )
    else:
        resolved_views_dir = _resolve_path(base_dir, views_dir or "views")
        view_configs = load_view_configs(resolved_views_dir)

    if filter_views_by_shacl_rules and view_configs:
        if data_models is not None and default_shacl_rules is not None:
            target_views = extract_target_views_from_shacl(shacl_dir / default_shacl_rules.file)
        else:
            target_views = set()
            for vc in view_configs:
                target_views |= extract_target_views_from_shacl(shacl_dir / vc.shacl_rules.file)
        view_configs = [vc for vc in view_configs if vc.view.external_id in target_views]

    timeseries_configs = (
        load_timeseries_configs(resolved_timeseries_dir)
        if resolved_timeseries_dir is not None and resolved_timeseries_dir.exists()
        else []
    )

    # Load RAW table configs from tables/ directory
    resolved_tables_dir = _resolve_path(base_dir, "tables")
    table_configs = load_table_configs(resolved_tables_dir) if resolved_tables_dir.exists() else []

    function_results = deploy_functions(
        client=client,
        settings=settings,
        force=force,
        dry_run=dry_run,
        debug_save_zip=debug_save_zip,
        secrets=function_secrets,
    )
    if not dry_run:
        _wait_for_functions_ready(client, [settings.function.external_id])
    workflow_results = deploy_instance_workflows(
        client=client,
        settings=settings,
        views=view_configs,
        shacl_dir=shacl_dir,
        force=force or force_workflows,
        dry_run=dry_run,
    )
    timeseries_results = deploy_timeseries_workflows(
        client=client,
        settings=settings,
        configs=timeseries_configs,
        shacl_dir=shacl_dir,
        force=force or force_workflows,
        dry_run=dry_run,
    )

    # Deploy RAW table SHACL rules
    table_results = deploy_raw_table_shacl(
        client=client,
        settings=settings,
        tables=table_configs,
        shacl_dir=shacl_dir,
        dry_run=dry_run,
    )

    return {
        "functions": function_results,
        "workflows": workflow_results,
        "timeseries_workflows": timeseries_results,
        "raw_tables": table_results,
    }


def deploy_incremental(
    *,
    view_config_path: Path | str,
    config_env: str | None = None,
    force: bool = False,
    dry_run: bool = False,
    client: CogniteClient | None = None,
) -> dict[str, object]:
    """Deploy function, workflow, and trigger for a single view config."""
    import importlib.util
    import sys

    def _load_module(module_name: str, path: Path):
        spec = importlib.util.spec_from_file_location(module_name, path)
        if spec is None or spec.loader is None:
            raise ImportError(f"Could not load module {module_name} from {path}")
        module = importlib.util.module_from_spec(spec)
        sys.modules[module_name] = module
        spec.loader.exec_module(module)
        return module

    def _load_view_config(path: Path) -> dict:
        if not path.exists():
            raise FileNotFoundError(f"View config not found: {path}")
        import yaml

        config = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
        config["_filename"] = path.name
        return config

    def _repo_root() -> Path:
        return Path(__file__).resolve().parents[1]

    repo_root = _repo_root()
    scripts_dir = repo_root / "scripts"

    shared = _load_module("dq_shared", scripts_dir / "shared.py")
    deploy_function = _load_module("dq_deploy_function", scripts_dir / "deploy_function.py")
    deploy_workflows = _load_module("dq_deploy_workflows", scripts_dir / "deploy_workflows.py")

    settings = shared.load_settings(repo_root, config_env)
    view_config = _load_view_config(Path(view_config_path))

    if client is None and not dry_run:
        client = shared.create_client()

    function_result = deploy_function.deploy_function_by_settings_key(
        client, repo_root, settings, "function", force=force, dry_run=dry_run
    )
    workflow_result = deploy_workflows.deploy_view(
        client, repo_root, settings, view_config, force=force, dry_run=dry_run
    )

    return {
        "function": function_result,
        "workflow": workflow_result,
        "dry_run": dry_run,
    }
