from __future__ import annotations
from collections import defaultdict
from datetime import datetime, timezone
import atexit
import re
from urllib.parse import urlparse

from pandas import DataFrame
from typing import Any, Optional, Literal, TYPE_CHECKING
from dataclasses import replace
from snowflake.snowpark import Session

from relationalai import debugging
from relationalai.errors import NonDefaultLQPSemanticsVersionWarning
from relationalai.semantics.lqp import result_helpers, export_rewriter, utils
from relationalai.semantics.lqp.utils import ExportInfo, extract_export_fqn
from relationalai.semantics.metamodel import ir, factory as f, executor as e
from relationalai.semantics.metamodel.visitor import collect_by_type

if TYPE_CHECKING:
    from relationalai.semantics.internal.internal import Model as InternalModel
from relationalai.semantics.lqp.compiler import Compiler
from relationalai.semantics.lqp.intrinsics import mk_intrinsic_datetime_now
from relationalai.semantics.lqp.constructors import mk_transaction
from relationalai.semantics.lqp.types import lqp_type_to_sql
from lqp.proto_validator import validate_proto
from relationalai.semantics.lqp import lqp_bundle
from relationalai.clients.config import Config
from relationalai.clients.resources.snowflake import APP_NAME, create_resources_instance
from relationalai.clients.types import TransactionAsyncResponse
from relationalai.clients.util import IdentityParser, escape_for_f_string
from relationalai.tools.constants import QUERY_ATTRIBUTES_HEADER
from relationalai.tools.query_utils import prepare_metadata_for_headers

if TYPE_CHECKING:
    from relationalai.semantics.snowflake import Table

# Whenever the logic engine introduces a breaking change in behaviour, we bump this version
# once the client is ready to handle it.
#
# [2026-01-09] bumping to 1 to opt-into hard validation errors from the engine
DEFAULT_LQP_SEMANTICS_VERSION = "1"

class LQPExecutor(e.Executor):
    """Executes LQP using the RAI client."""

    def __init__(
        self,
        database: str,
        dry_run: bool = False,
        keep_model: bool = True,
        wide_outputs: bool = False,
        connection: Session | None = None,
        config: Config | None = None,
        # In order to facilitate snapshot testing, we allow overriding intrinsic definitions
        # like the current time, which would otherwise change between runs.
        intrinsic_overrides: dict = {},
    ) -> None:
        super().__init__()
        self.database = database
        self.dry_run = dry_run
        self.keep_model = keep_model
        self.wide_outputs = wide_outputs
        self.compiler = Compiler()
        self.connection = connection
        self.config = config or Config()
        self.intrinsic_overrides = intrinsic_overrides
        self._resources = None
        self._last_model = None
        self._last_sources_version = (-1, None)

    @property
    def resources(self):
        if not self._resources:
            with debugging.span("create_session"):
                self.dry_run |= bool(self.config.get("compiler.dry_run", False))
                # NOTE: language="lqp" is not strictly required for LQP execution, but it
                # will significantly improve performance.
                self._resources = create_resources_instance(
                    config=self.config,
                    dry_run=self.dry_run,
                    connection=self.connection,
                    language="lqp",
                )
                if not self.dry_run:
                    self.engine = self._resources.get_default_engine_name()
                    if not self.keep_model:
                        atexit.register(self._resources.delete_graph, self.database, True, "lqp")
        return self._resources

    # Checks the graph index and updates it if necessary
    def prepare_data(self):
        # Has to happen first, so self.dry_run is populated.
        resources = self.resources

        if self.dry_run:
            return

        from relationalai.semantics.snowflake import Table
        table_sources = Table._used_sources
        if not table_sources.has_changed(self._last_sources_version):
            return

        model = self.database
        app_name = resources.get_app_name()
        engine_name = self.engine
        engine_size = self.resources.config.get_default_engine_size()

        program_span_id = debugging.get_program_span_id()
        sources = [t._fqn for t in Table._used_sources]
        self._last_sources_version = Table._used_sources.version()

        assert self.engine is not None

        with debugging.span("poll_use_index", sources=sources, model=model, engine=engine_name):
            resources.maybe_poll_use_index(
                app_name=app_name,
                sources=sources,
                model=model,
                engine_name=self.engine,
                engine_size=engine_size,
                program_span_id=program_span_id,
            )

    def report_errors(self, problems: list[dict[str, Any]], abort_on_error=True):
        from relationalai import errors
        all_errors = []
        undefineds = []
        pyrel_errors = defaultdict(list)
        pyrel_warnings = defaultdict(list)

        for problem in problems:
            message = problem.get("message", "")
            report = problem.get("report", "")
            # TODO: we need to build source maps
            # path = problem.get("path", "")
            # source_task = self._install_batch.line_to_task(path, problem["start_line"]) or task
            # source = debugging.get_source(source_task) or debugging.SourceInfo()
            source = debugging.SourceInfo()
            severity = problem.get("severity", "warning")
            code = problem.get("code")

            if severity in ["error", "exception"]:
                if code == "UNDEFINED_IDENTIFIER":
                    match = re.search(r'`(.+?)` is undefined', message)
                    if match:
                        undefineds.append((match.group(1), source))
                    else:
                        all_errors.append(errors.RelQueryError(problem, source))
                elif "overflowed" in report:
                    all_errors.append(errors.NumericOverflow(problem, source))
                elif code == "PYREL_ERROR":
                    pyrel_errors[problem["props"]["pyrel_id"]].append(problem)
                elif abort_on_error:
                    all_errors.append(errors.RelQueryError(problem, source))
            else:
                if code == "ARITY_MISMATCH":
                    errors.ArityMismatch(problem, source)
                elif code == "IC_VIOLATION":
                    all_errors.append(errors.IntegrityConstraintViolation(problem, source))
                elif code == "PYREL_ERROR":
                    pyrel_warnings[problem["props"]["pyrel_id"]].append(problem)
                else:
                    errors.RelQueryWarning(problem, source)

        if abort_on_error and len(undefineds):
            all_errors.append(errors.UninitializedPropertyException(undefineds))

        if abort_on_error:
            for pyrel_id, pyrel_problems in pyrel_errors.items():
                all_errors.append(errors.ModelError(pyrel_problems))

        for pyrel_id, pyrel_problems in pyrel_warnings.items():
            errors.ModelWarning(pyrel_problems)


        if len(all_errors) == 1:
            raise all_errors[0]
        elif len(all_errors) > 1:
            raise errors.RAIExceptionSet(all_errors)

    def _export(self, txn_id: str, export_info: tuple, dest: Table, actual_cols: list[str], declared_cols: list[str], update: bool):
        # At this point of the export, we assume that a CSV file has already been written
        # to the Snowflake Native App stage area. Thus, the purpose of this method is to
        # copy the data from the CSV file to the destination table.
        _exec = self.resources._exec
        dest_database, dest_schema, dest_table, _ = IdentityParser(dest._fqn, require_all_parts=True).to_list()
        filename = export_info[0]
        result_table_name = filename + "_table"

        with debugging.span("export", txn_id=txn_id, export_info=export_info, dest_table=dest_table):
            with debugging.span("export_to_result_schema"):
                # First, we need to persist from the CSV file to the results schema by calling the
                # `persist_from_stage` stored procedure. This step also cleans up the CSV file in
                # the stage area.
                column_fields = []
                for (col_name, col_type) in export_info[1]:
                    column_fields.append([col_name, lqp_type_to_sql(col_type)])

                # NOTE: the `str(column_fields)` depends on python formatting which surrounds
                # strings with single quotes. If this changes, or if we ever get a single quote in
                # the actual string, then we need to do something more sophisticated.
                exec_str = f"call {APP_NAME}.api.persist_from_stage('{txn_id}', '{filename}', '{result_table_name}', {str(column_fields)})"
                _exec(exec_str)

            with debugging.span("write_table"):
                # The result of the first step above is a table in the results schema,
                # {app_name}.results.{result_table_name}.
                # Second, we need to copy the data from the results schema to the
                # destination table. This step also cleans up the result table.
                out_sample = _exec(f"select * from {APP_NAME}.results.{result_table_name} limit 1;")
                names = self._build_projection(declared_cols, actual_cols, column_fields, out_sample)
                dest_fqn = dest._fqn
                try:
                    if not update:
                        createTableLogic = f"""
                                        CREATE TABLE {dest_fqn} AS
                                        SELECT {names}
                                        FROM {APP_NAME}.results.{result_table_name};
                        """
                        if dest._is_iceberg:
                            assert dest._iceberg_config is not None
                            external_volume_clause = ""
                            if dest._iceberg_config.external_volume:
                                external_volume_clause = f"EXTERNAL_VOLUME = '{dest._iceberg_config.external_volume}'"
                            createTableLogic = f"""
                                        CREATE ICEBERG TABLE {dest_fqn}
                                        CATALOG = "SNOWFLAKE"
                                        {external_volume_clause}
                                        AS
                                        SELECT {names}
                                        FROM {APP_NAME}.results.{result_table_name};
                            """

                        _exec(f"""
                            BEGIN
                                -- Check if table exists
                                IF (EXISTS (
                                    SELECT 1
                                    FROM {dest_database}.INFORMATION_SCHEMA.TABLES
                                    WHERE table_schema = '{dest_schema}'
                                    AND table_name = '{dest_table}'
                                )) THEN
                                    -- Insert into existing table
                                    EXECUTE IMMEDIATE '
                                        BEGIN
                                            TRUNCATE TABLE {dest_fqn};
                                            INSERT INTO {dest_fqn}
                                            SELECT {names}
                                            FROM {APP_NAME}.results.{result_table_name}
                                            {'' if out_sample else 'WHERE 1=0'};
                                        END;
                                    ';
                                ELSE
                                    -- Create table based on the SELECT
                                    EXECUTE IMMEDIATE '
                                        {escape_for_f_string(createTableLogic)}
                                    ';
                                END IF;
                            END;
                        """)
                    else:
                        if out_sample:
                            _exec(f"""
                                BEGIN
                                    INSERT INTO {dest_fqn}
                                    SELECT {names}
                                    FROM {APP_NAME}.results.{result_table_name};
                                END;
                            """)
                finally:
                    # Always try to drop the result table, even if the insert/create failed.
                    _exec(f"call {APP_NAME}.api.drop_result_table('{result_table_name}');")

    def _build_projection(self, declared_cols, actual_cols, column_fields, out_sample=None):
        # map physical col -> type
        col_type_map = {col.lower(): dtype for col, dtype in column_fields}
        sample_keys = {k.lower() for k in out_sample[0].as_dict()} if out_sample else set()

        fields = []
        ix = 0

        for name in declared_cols:
            if name not in actual_cols:
                # Declared but not present in results
                fields.append(f"NULL as \"{name}\"")
                continue

            # Get the actual physical column name from column_fields
            colname = column_fields[ix][0]
            ix += 1

            if colname.lower() in sample_keys:
                # Actual column exists in sample
                fields.append(f"{colname} as \"{name}\"")
            else:
                # No sample or missing key → fall back to type
                dtype = col_type_map.get(colname.lower(), "VARCHAR")
                fields.append(f"CAST(NULL AS {dtype}) as \"{name}\"")

        return ", ".join(fields)

    def _construct_configure(self):
        config_dict = {}
        # Only set the IVM flag if there is a value in `config`. Otherwise, let
        # `construct_configure` set the default value.
        ivm_flag = self.config.get('reasoner.rule.incremental_maintenance', None)
        if ivm_flag:
            config_dict['ivm.maintenance_level'] = lqp_bundle.ir.Value(value=ivm_flag, meta=None)

        # Set semantics_version from config, defaulting to 0
        semantics_version: str | Any = self.config.get('reasoner.rule.lqp.semantics_version', DEFAULT_LQP_SEMANTICS_VERSION)
        config_dict['semantics_version'] = lqp_bundle.ir.Value(value=int(semantics_version), meta=None)

        # Warn if a non-default semantics version is used. Most likely, this is due to a
        # user manually reverting to an older version. We want them to not get stuck on that
        # version for longer than necessary.
        if semantics_version != DEFAULT_LQP_SEMANTICS_VERSION:
            debugging.warn(NonDefaultLQPSemanticsVersionWarning(semantics_version, DEFAULT_LQP_SEMANTICS_VERSION))

        return lqp_bundle.construct_configure(config_dict, None)

    def _should_sync(self, model) :
        if self._last_model != model:
            return lqp_bundle.ir.Sync(fragments=[], meta=None)
        else :
            return None

    def _compile_intrinsics(self) -> lqp_bundle.ir.Epoch:
        """Construct an epoch that defines a number of built-in definitions used by the
        emitter."""
        with debugging.span("compile_intrinsics") as span:
            span["compile_type"] = "intrinsics"

            now = self.intrinsic_overrides.get('datetime_now', datetime.now(timezone.utc))

            debug_info = lqp_bundle.ir.DebugInfo(id_to_orig_name={}, meta=None)
            intrinsics_fragment = lqp_bundle.ir.Fragment(
                id = lqp_bundle.ir.FragmentId(id=b"__pyrel_lqp_intrinsics", meta=None),
                declarations = [mk_intrinsic_datetime_now(now)],
                debug_info = debug_info,
                meta = None,
            )

            return lqp_bundle.ir.Epoch(
                writes=[
                    lqp_bundle.ir.Write(write_type=lqp_bundle.ir.Define(fragment=intrinsics_fragment, meta=None), meta=None)
                ],
                meta=None,
            )

    def _add_nomaintain(self, epoch: lqp_bundle.ir.Epoch) -> lqp_bundle.ir.Epoch:
        """Add the nomaintain attribute to all Def nodes in an LQP epoch."""
        _nomaintain = lqp_bundle.ir.Attribute(name="nomaintain", args=[], meta=None)
        new_writes = []
        for write in epoch.writes:
            if not isinstance(write.write_type, lqp_bundle.ir.Define):
                new_writes.append(write)
                continue
            fragment = write.write_type.fragment
            new_decls = [
                replace(decl, attrs=list(decl.attrs) + [_nomaintain])
                if isinstance(decl, lqp_bundle.ir.Def) and not any(a.name == "nomaintain" for a in decl.attrs)
                else decl
                for decl in fragment.declarations
            ]
            new_fragment = replace(fragment, declarations=new_decls)
            new_define = replace(write.write_type, fragment=new_fragment)
            new_writes.append(replace(write, write_type=new_define))
        return replace(epoch, writes=new_writes)

    # [RAI-40997] We eagerly undefine query fragments so they are not committed to storage
    def _compile_undefine_query(self, query_epoch: lqp_bundle.ir.Epoch) -> lqp_bundle.ir.Epoch:
        fragment_ids = []

        for write in query_epoch.writes:
            if isinstance(write.write_type, lqp_bundle.ir.Define):
                fragment_ids.append(write.write_type.fragment.id)

        # Construct new Epoch with Undefine operations for all collected fragment IDs
        undefine_writes = [
            lqp_bundle.ir.Write(
                write_type=lqp_bundle.ir.Undefine(fragment_id=frag_id, meta=None),
                meta=None
            )
            for frag_id in fragment_ids
        ]

        return lqp_bundle.ir.Epoch(
            writes=undefine_writes,
            meta=None,
        )

    def _extract_export_uri_short_name(self, config_path: str) -> str:
        # config.path is a full Snowflake stage URI of the form:
        #   snowflake://<stage>/export/<short_name>/data
        # export_info stores the short_name (e.g. "export_<uuid>").
        # We parse the URI to extract the short_name for comparison
        try:
            parsed = urlparse(config_path)
            path_parts = parsed.path.strip("/").split("/")
        except Exception as e:
            raise ValueError(f"Failed to parse export URI {config_path!r}: {e}")
        # Expected structure: ["export", "<short_name>", "data"]
        if not (len(path_parts) == 3 and path_parts[0] == "export" and path_parts[2] == "data"):
            raise ValueError(
                f"Unexpected export URI path structure: {config_path!r}. "
                f"Expected 'snowflake://<stage>/export/<name>/data'."
            )
        return path_parts[1]

    def _filter_csv_exports(self, task: ir.Task, query_epoch: lqp_bundle.ir.Epoch, export_info: Optional[ExportInfo]) -> lqp_bundle.ir.Epoch:
        """
        Filter CSV export columns based on the original column names from Output nodes.

        Maps export reads to their corresponding outputs by FQN and filters out extra columns
        that were added during processing but weren't in the original output specification.
        """

        if not export_info:
            return query_epoch

        # Count user-specified columns per export from Output nodes
        outputs = collect_by_type(ir.Output, task)
        assert outputs, "No Output found in the task"
        n_user_cols: dict[str, int] = {}
        for output in outputs:
            export_fqn = extract_export_fqn(output.annotations)
            if export_fqn:
                n_user_cols[export_fqn] = sum(1 for alias, _ in output.aliases if alias)

        # Validate that exports in outputs and export_info are consistent
        export_fqns_from_info = {fqn for fqn, _, _ in export_info}
        missing_in_info = set(n_user_cols.keys()) - export_fqns_from_info
        if missing_in_info:
            raise ValueError(f"Export FQNs found in outputs but missing in export_info: {missing_in_info}")
        extra_in_info = export_fqns_from_info - set(n_user_cols.keys())
        if extra_in_info:
            raise ValueError(f"Export FQNs found in export_info but missing in outputs: {extra_in_info}")

        # Build mapping: export_fqn -> list of column names from export_info.
        # We use export_info names (not Output.aliases) because the compiler may
        # rename Hash-typed columns (e.g. NODE -> NODE_0_uuid).
        exports_cols: dict[str, list[str]] = {}
        for fqn, _, cols in export_info:
            n = n_user_cols[fqn]
            # The compiler preserves user-specified column order and places them first in export_info
            # cols entries are either (col_name, col_type) tuples or plain column name strings
            exports_cols[fqn] = [c[0] if isinstance(c, tuple) else c for c in cols[:n]]

        # Apply filter to each export read by matching FQN
        if query_epoch.reads:
            new_reads = []
            for read in query_epoch.reads:
                if isinstance(read.read_type, lqp_bundle.ir.Export):
                    # For each Export read, extract the short name from its config path
                    # and find the matching export FQN
                    config_path = read.read_type.config.path
                    short_name = self._extract_export_uri_short_name(config_path)

                    matching_fqn = None
                    if export_info:
                        for fqn, filename, _ in export_info:
                            if filename == short_name:
                                matching_fqn = fqn
                                break

                    if matching_fqn and matching_fqn in exports_cols:
                        # Apply filter for this export using its FQN
                        cols = exports_cols[matching_fqn]
                        column_filter = export_rewriter.ExtraColumnsFilter(cols)
                        read = column_filter.filter_read(read)
                    # Disable partitioning for all Export reads in CSV format
                    read = export_rewriter.PartitioningDisabler().rewrite_read(read)
                    new_reads.append(read)
                else:
                    new_reads.append(read)

            return replace(query_epoch, reads=new_reads)

        return query_epoch

    def compile_lqp(self, model: ir.Model, task: ir.Task, format: Optional[Literal["pandas", "snowpark", "csv"]] = "pandas"):
        configure = self._construct_configure()
        # Merge the epochs into a single transaction. Long term the query bits should all
        # go into a WhatIf action and the intrinsics could be fused with either of them. But
        # for now we just use separate epochs.
        epochs = []
        epochs.append(self._compile_intrinsics())

        sync = self._should_sync(model)

        if sync is not None:
            with debugging.span("compile", metamodel=model) as install_span:
                install_span["compile_type"] = "model"
                _, model_epoch = self.compiler.compile(model, {"fragment_id": b"model", "_v0_config": self.config})
                epochs.append(model_epoch)
                self._last_model = model

        with debugging.span("compile", metamodel=task) as txn_span:
            query = f.compute_model(f.logical([task]))
            options = {
                "wide_outputs": self.wide_outputs,
                "fragment_id": b"query",
                "_v0_config": self.config,
            }
            result, final_model = self.compiler.compile_inner(query, options)
            export_info, query_epoch = result

            query_epoch = self._add_nomaintain(query_epoch)

            if format == "csv":
                # Filter CSV exports to only include original columns
                query_epoch = self._filter_csv_exports(task, query_epoch, export_info)

            # Adds constraints declared in previous epochs to the query epoch. A temporary
            # workaround for limited FD Salsa storage in the backend See: https://github.com/RelationalAI/relationalai-python/pull/3262
            _add_previous_constraint_declarations(query_epoch, epochs)

            epochs.append(query_epoch)
            epochs.append(self._compile_undefine_query(query_epoch))

            txn_span["compile_type"] = "query"
            txn = mk_transaction(epochs=epochs, configure=configure, sync=sync)
            txn_span["lqp"] = lqp_bundle.to_safe_string(txn)

        txn_proto = lqp_bundle.ir_to_proto(txn)
        validate_proto(txn_proto)
        return final_model, export_info, txn_proto

    def execute(
        self,
        model: ir.Model,
        task: ir.Task,
        format: Literal["pandas", "snowpark", "csv"] = "pandas",
        export_to: Optional[Table] = None,
        update: bool = False,
        meta: dict[str, Any] | None = None,
        source_info: dict[str, Any] | None = None,
    ) -> DataFrame:
        self.prepare_data()
        previous_model = self._last_model
        final_model, export_info, txn_proto = self.compile_lqp(model, task, format=format)

        if self.dry_run:
            return DataFrame()

        if format == "snowpark":
            raise ValueError(f"Unsupported format: {format}")

        # Format meta as headers
        json_meta = prepare_metadata_for_headers(meta)
        headers = {QUERY_ATTRIBUTES_HEADER: json_meta} if json_meta else {}
        raw_results = self.resources.exec_lqp(
            self.database,
            self.engine,
            txn_proto.SerializeToString(),
            # Current strategy is to run all queries as write transactions, in order to
            # benefit from view caching. This will have to be revisited, because write
            # transactions are serialized.
            readonly=False,
            nowait_durable=True,
            headers=headers,
            source_info=source_info,
        )
        assert isinstance(raw_results, TransactionAsyncResponse), "Expected TransactionAsyncResponse from LQP execution"
        assert raw_results.transaction is not None, "Transaction result is missing"
        txid = raw_results.transaction['id']

        try:
            # For CSV format, return paths directly without computing columns
            if format == "csv":
                if export_info and isinstance(export_info, list):
                    # export_info is a list of (fqn, filename, col_types)
                    paths = []
                    for export_entry in export_info:
                        filename = export_entry[1]  # (fqn, filename, col_types) -> filename
                        full_path = filename + f"/data_{txid}.gz"
                        paths.append(full_path)
                    return DataFrame(paths, columns=["path"])
                else:
                    raise ValueError("The CSV export was not successful!")

            cols, extra_cols, key_locs = self._compute_cols(task, final_model)
            df, errs = result_helpers.format_results(raw_results, cols, key_locs)
            self.report_errors(errs)

            if export_to:
                # Single export with explicit destination table. Multiple table exports are not supported yet.
                assert cols, "No columns found in the output"
                assert export_info, "Export info should be populated if we are exporting results"
                assert isinstance(export_info, list), "export_info must be a list"
                assert len(export_info) == 1, "export_to only supports single export"
                result_cols = export_to._col_names
                if result_cols is not None:
                    assert all(col in result_cols or col in extra_cols for col in cols)
                else:
                    result_cols = [col for col in cols if col not in extra_cols]
                assert result_cols
                # Extract single export info: (fqn, filename, col_types)
                single_export_info = (export_info[0][1], export_info[0][2])
                self._export(txid, single_export_info, export_to, cols, result_cols, update)

            return self._postprocess_df(self.config, df, extra_cols)

        except Exception as e:
            # If processing the results failed, revert to the previous model.
            self._last_model = previous_model
            raise e

    def export_to_csv(self, model: "InternalModel", query) -> str:
        ### Exports the result of the given query fragment to a CSV file in
        ### the Snowflake stage area and returns the path to the CSV file.

        from relationalai.semantics.internal.internal import Fragment, with_source
        from relationalai.environments import runtime_env

        if not query._select:
            raise ValueError("Cannot export empty selection to CSV")

        clone = Fragment(parent=query)
        clone._is_export = True
        clone._source = runtime_env.get_source_pos()
        ir_model = model._to_ir()
        source_info = with_source(clone)
        with debugging.span("query", dsl=str(clone), **source_info, meta=clone._meta):
            query_task = model._compiler.fragment(clone)
            csv_info = self.execute(ir_model, query_task, format="csv", meta=clone._meta, source_info=source_info)
            if csv_info.empty or "path" not in csv_info.columns:
                return ""
            else:
                path = csv_info.at[0, "path"]
                assert isinstance(path, str)
            return path

    def batch_export_to_csv(self, model: "InternalModel", queries: list) -> list[str]:
        """Export multiple query fragments to CSV in a single LQP transaction.

        This is an internal method used by the optimization library where batching
        exports into a single transaction is needed for adequate performance. It is
        not meant to be relied on by anyone else and may change without notice.

        All fragments are compiled together as a single combined IR task. Each
        fragment gets a unique export FQN annotation so the compiler treats them
        as independent exports within the same transaction. This reduces round
        trips from N to 1.

        Parameters
        ----------
        queries : list[Fragment]
            List of query fragments to export. Each must contain a SELECT.

        Returns
        -------
        list[str]
            List of CSV file paths, one per input query, in the same order.
        """
        from relationalai.semantics.internal.internal import Fragment
        from relationalai.semantics.metamodel import builtins, types as mm_types
        from relationalai.environments import runtime_env

        if not queries:
            return []

        # Clone each query, mark as export with a unique FQN annotation, compile to IR task.
        # The unique FQN ensures the UnifyDefinitions rewrite pass does not merge
        # different exports, and the LQP compiler creates separate ExportCSVConfigs.
        tasks = []
        for i, query in enumerate(queries):
            if not query._select:
                raise ValueError("Cannot export empty selection to CSV")
            clone = Fragment(parent=query)
            clone._is_export = True
            clone._source = runtime_env.get_source_pos()
            clone._annotations = [f.annotation(builtins.export, [ir.Literal(mm_types.String, f"__batch_export_{i}")])]
            tasks.append(model._compiler.fragment(clone))

        # Combine all tasks into a single logical task
        combined_task = f.logical(tasks)
        ir_model = model._to_ir()

        meta = {"operation": "batch_export_to_csv", "num_exports": len(queries)}
        with debugging.span("batch_export_to_csv", num_exports=len(queries)):
            csv_info = self.execute(ir_model, combined_task, format="csv", meta=meta)

        if csv_info.empty or "path" not in csv_info.columns:
            raise ValueError("Batch CSV export returned no paths")

        paths = csv_info["path"].tolist()
        assert len(paths) == len(queries), (
            f"Expected {len(queries)} CSV paths but got {len(paths)}"
        )
        return paths

# The following two functions are a temporary workaround for limited FD Salsa storage in the
# backend See: https://github.com/RelationalAI/relationalai-python/pull/3262

# Adds constraints declared in previous epochs to the query epoch.
def _add_previous_constraint_declarations(query_epoch: lqp_bundle.ir.Epoch, prev_epochs: list[lqp_bundle.ir.Epoch]):
    constraints: list[lqp_bundle.ir.Constraint] = []

    # Find all declared constraints in previous epochs
    for epoch in prev_epochs:
        for write in epoch.writes:
            if isinstance(write.write_type, lqp_bundle.ir.Define):
                fragment = write.write_type.fragment
                for decl in fragment.declarations:
                    if isinstance(decl, lqp_bundle.ir.Constraint):
                        constraints.append(decl)

    # Add the collected constraints to the define frags of the query epoch
    for write in query_epoch.writes:
        if isinstance(write.write_type, lqp_bundle.ir.Define):
            fragment = write.write_type.fragment
            # Prepend the collected constraints to the declarations
            assert isinstance(fragment.declarations, list)
            fragment.declarations.extend(_fd_with_next_id(fd) for fd in constraints)

# Creates a copy of the given FD with a new ID to avoid multiple declarations with the same ID.
def _fd_with_next_id(fd: lqp_bundle.ir.Constraint) -> lqp_bundle.ir.Constraint:
    assert isinstance(fd, lqp_bundle.ir.FunctionalDependency)
    next_id = utils.lqp_hash(str((fd.name,)))
    lqp_name = lqp_bundle.ir.RelationId(id=next_id, meta=None)

    return lqp_bundle.ir.FunctionalDependency(
        name=lqp_name,
        guard=fd.guard,
        keys=fd.keys,
        values=fd.values,
        meta=None
    )
