import pytest

from soigia.pipeline import BasePipeline, Pipeline, PipelineStatus
from soigia.datatable import DataTable


def test_pipeline_decorator_and_hooks():
    pipeline = BasePipeline(name="demo")
    events = []

    @pipeline.before_run
    def before(ctx):
        events.append(("before_run", ctx.run_id))

    @pipeline.after_run
    def after(ctx):
        events.append(("after_run", ctx.run_id))

    @pipeline.step(name="double", retries=1)
    def double(data, ctx):
        events.append(("step", ctx.current_step, ctx.current_attempt))
        return data * 2

    outcome = pipeline.run(10)

    assert outcome.status == PipelineStatus.SUCCESS
    assert outcome.data == 20
    assert events[0][0] == "before_run"
    assert events[-1][0] == "after_run"
    assert events[1] == ("step", "double", 1)


def test_pipeline_condition_and_skip():
    pipeline = BasePipeline(name="demo")

    @pipeline.step(name="skip_me", condition=lambda data, ctx: False)
    def skipped(data, ctx):  # pragma: no cover - this should not run
        return data + 1

    outcome = pipeline.run(3)

    assert outcome.status == PipelineStatus.SUCCESS
    assert outcome.data == 3
    assert pipeline.steps[0].name == "skip_me"


def test_pipeline_subclass_auto_registers_decorated_methods():
    class DemoPipeline(Pipeline):
        stages = ["double", "add_three"]

        def load_data(self):
            return 4

        def double(self):
            self.datatable = self.datatable * 2
            return self.datatable

        def add_three(self):
            self.datatable = self.datatable + 3
            return self.datatable

    pipeline = DemoPipeline(name="demo")

    assert [step.name for step in pipeline.steps] == ["double", "add_three"]
    outcome = pipeline.run()
    assert outcome.status == PipelineStatus.SUCCESS
    assert isinstance(outcome.data, DataTable)
    assert outcome.data.to_rows() == [{"value": 11}]


def test_pipeline_exposes_shared_datatable_and_model():
    class DemoPipeline(Pipeline):
        stages = ["capture", "finalize"]

        def load_data(self):
            return 4

        def capture(self):
            assert isinstance(self.datatable, DataTable)
            assert self.datatable.to_rows() == [{"value": 4}]
            self.model.seen = getattr(self.model, "seen", 0) + 1
            self.datatable = self.datatable.assign(value=self.datatable["value"] + 1)
            return self.datatable

        def finalize(self):
            assert self.model.seen == 1
            assert self.datatable.to_rows() == [{"value": 5}]
            self.datatable = self.datatable.assign(value=self.datatable["value"] * 2)
            return self.datatable

    pipeline = DemoPipeline(name="demo")
    outcome = pipeline.run()

    assert outcome.status == PipelineStatus.SUCCESS
    assert isinstance(outcome.data, DataTable)
    assert outcome.data.to_rows() == [{"value": 10}]
    assert isinstance(pipeline.datatable, DataTable)
    assert pipeline.datatable.to_rows() == [{"value": 10}]
    assert pipeline.model.seen == 1


def test_pipeline_stage_methods_can_mutate_datatable_without_returning():
    class DemoPipeline(Pipeline):
        stages = ["capture", "finalize"]

        def load_data(self):
            return DataTable({"value": [4]})

        def capture(self):
            assert isinstance(self.datatable, DataTable)
            self.datatable = self.datatable.assign(value=self.datatable["value"] + 1)

        def finalize(self):
            assert isinstance(self.datatable, DataTable)
            self.datatable = self.datatable.assign(value=self.datatable["value"] * 2)

    pipeline = DemoPipeline(name="demo")
    outcome = pipeline.run()

    assert outcome.status == PipelineStatus.SUCCESS
    assert isinstance(outcome.data, DataTable)
    assert outcome.data.to_rows() == [{"value": 10}]
    assert isinstance(pipeline.datatable, DataTable)
    assert pipeline.datatable.to_rows() == [{"value": 10}]


def test_pipeline_datatable_supports_chainable_row_query_and_save(monkeypatch):
    from pathlib import Path

    sandbox = Path.cwd() / ".test_artifacts" / "pipeline_datatable_chain"
    sandbox.mkdir(parents=True, exist_ok=True)
    monkeypatch.chdir(sandbox)

    class DemoPipeline(Pipeline):
        stages = ["export"]

        def load_data(self):
            return DataTable(
                [
                    {"id": 1, "name": "alice", "status": "new"},
                    {"id": 2, "name": "bob", "status": "vip"},
                    {"id": 3, "name": "carol", "status": "active"},
                ]
            )

        def export(self):
            self.datatable.filter(status="vip").get(id=2).save_to_csv("exports/vip_user.csv")

    pipeline = DemoPipeline(name="demo")
    result = pipeline.run()

    assert result.success
    output = sandbox / "exports" / "vip_user.csv"
    assert output.exists()
    chain_result = pipeline.datatable.filter(status="vip").get(id=2)
    assert isinstance(chain_result, DataTable)
    assert chain_result.to_rows() == [{"id": 2, "name": "bob", "status": "vip"}]


def test_pipeline_helper_methods():
    import pandas as pd
    from pathlib import Path

    class DemoPipeline(Pipeline):
        stages = ["prepare", "finalize"]

        def load_data(self):
            import pandas as pd

            return pd.DataFrame({"name": ["alice"], "amount": [3]})

        def prepare(self):
            assert self.has_columns("name", "amount")
            self.snapshot_df("before")
            self.put("note", "ready")
            self.datatable = self.datatable.assign(amount2=self.datatable["amount"] * 2)
            return self.datatable

        def finalize(self):
            assert self.get("note") == "ready"
            self.restore_df("before")
            assert "amount2" not in self.datatable.columns
            self.datatable = self.datatable.assign(done=True)
            return self.datatable

    pipeline = DemoPipeline(name="demo")
    result = pipeline.run()

    assert result.status == PipelineStatus.SUCCESS
    assert result.rows == 1
    assert result.csv_path is not None
    assert result.db_path is not None
    assert result.parquet_path is not None
    assert result.summary_path is not None
    assert result.duration_seconds is not None
    assert pipeline.datatable.equals(pd.DataFrame({"name": ["alice"], "amount": [3], "done": [True]}))
    dump_dir = Path.cwd() / "data" / "DemoPipeline"
    assert (dump_dir / "01_prepare.csv").exists()
    assert (dump_dir / "02_finalize.csv").exists()
    summary_path = dump_dir / "pipeline_summary.md"
    summary_text = summary_path.read_text(encoding="utf-8", errors="replace")
    assert "# Pipeline Run Summary" in summary_text
    assert "| # | Step | Status | Rows | Added Columns | Removed Columns |" in summary_text
    assert "| 01 | prepare | success |" in summary_text
    assert "| 02 | finalize | success |" in summary_text
    assert "## Model" in summary_text


def test_pipeline_exposes_default_sqlite_db_and_upsert(monkeypatch):
    import pandas as pd
    from pathlib import Path

    sandbox = Path.cwd() / ".test_artifacts" / "pipeline_db_upsert"
    sandbox.mkdir(parents=True, exist_ok=True)
    monkeypatch.chdir(sandbox)

    class DemoPipeline(Pipeline):
        stages = ["seed", "upsert", "read_back"]

        def load_data(self):
            return pd.DataFrame({"id": [1, 2], "value": ["a", "b"]})

        def seed(self):
            self.db.set("items", self.datatable)
            return self.datatable

        def upsert(self):
            self.upsert_rows("items", [{"id": 2, "value": "b-updated"}, {"id": 3, "value": "c"}])
            return self.datatable

        def read_back(self):
            self.datatable = self.db.get("items").sort_values("id").reset_index(drop=True)
            return self.datatable

    pipeline = DemoPipeline(name="demo", config={"db_upsert_keys": {"items": ["id"]}})

    assert pipeline.db_path.exists()
    assert pipeline.table_exists("soigia_meta")
    assert "soigia_meta" in pipeline.list_tables()

    result = pipeline.run()

    assert result.success
    assert list(pipeline.datatable["id"]) == [1, 2, 3]
    assert list(pipeline.datatable["value"]) == ["a", "b-updated", "c"]
    meta = pipeline.db.read_df("soigia_meta").set_index("key")["value"].to_dict()
    assert meta["pipeline_class"] == "DemoPipeline"
    assert meta["pipeline_name"] == "demo"
    assert meta["db_path"] == str(pipeline.db_path)


def test_pipeline_respects_env_default_db_path(monkeypatch):
    from pathlib import Path
    import pandas as pd

    sandbox = Path.cwd() / ".test_artifacts" / "pipeline_env_default_db"
    sandbox.mkdir(parents=True, exist_ok=True)
    monkeypatch.chdir(sandbox)
    monkeypatch.setenv("SOIGIA_DB_PATH", str(sandbox / "custom" / "pipeline.sqlite3"))

    class DemoPipeline(Pipeline):
        stages = ["seed"]

        def load_data(self):
            return pd.DataFrame({"id": [1], "value": ["a"]})

        def seed(self):
            self.db.set("items", self.datatable)
            return self.datatable

    pipeline = DemoPipeline(name="demo")

    assert pipeline.db_path == (sandbox / "custom" / "pipeline.sqlite3").resolve()
    result = pipeline.run()
    assert result.success
    assert pipeline.db_path.exists()


def test_pipeline_supports_delete_insert_mode(monkeypatch):
    import pandas as pd
    from pathlib import Path

    sandbox = Path.cwd() / ".test_artifacts" / "pipeline_db_delete_insert"
    sandbox.mkdir(parents=True, exist_ok=True)
    monkeypatch.chdir(sandbox)

    class DemoPipeline(Pipeline):
        stages = ["seed", "sync", "read_back"]

        def load_data(self):
            return pd.DataFrame({"id": [1, 2], "value": ["a", "b"]})

        def seed(self):
            self.db.set("items", self.datatable)
            return self.datatable

        def sync(self):
            self.upsert_rows(
                "items",
                [{"id": 2, "value": "b-replaced"}, {"id": 3, "value": "c"}],
                mode="delete-insert",
            )
            return self.datatable

        def read_back(self):
            self.datatable = self.db.get("items").sort_values("id").reset_index(drop=True)
            return self.datatable

    pipeline = DemoPipeline(name="demo", config={"db_upsert_keys": {"items": ["id"]}})
    result = pipeline.run()

    assert result.success
    assert list(pipeline.datatable["id"]) == [1, 2, 3]
    assert list(pipeline.datatable["value"]) == ["a", "b-replaced", "c"]
    assert pipeline.db.table_exists("items")


def test_pipeline_supports_insert_mode_and_aliases(monkeypatch):
    import pandas as pd
    from pathlib import Path

    sandbox = Path.cwd() / ".test_artifacts" / "pipeline_db_insert"
    sandbox.mkdir(parents=True, exist_ok=True)
    monkeypatch.chdir(sandbox)

    class DemoPipeline(Pipeline):
        stages = ["seed", "append", "read_back"]

        def load_data(self):
            return pd.DataFrame({"id": [1], "value": ["a"]})

        def seed(self):
            self.db.set("items", self.datatable)
            return self.datatable

        def append(self):
            self.db.upsert_rows(
                "items",
                [{"id": 2, "value": "b"}, {"id": 3, "value": "c"}],
                mode="insert",
            )
            return self.datatable

        def read_back(self):
            self.datatable = self.db.get("items").sort_values("id").reset_index(drop=True)
            return self.datatable

    pipeline = DemoPipeline(name="demo")
    result = pipeline.run()

    assert result.success
    assert list(pipeline.datatable["id"]) == [1, 2, 3]
    assert list(pipeline.datatable["value"]) == ["a", "b", "c"]
    assert pipeline.table_exists("items")
    assert "items" in pipeline.list_tables()


def test_pipeline_syncs_data_df_to_db_and_back(monkeypatch):
    import pandas as pd
    from pathlib import Path

    sandbox = Path.cwd() / ".test_artifacts" / "pipeline_df_sync"
    sandbox.mkdir(parents=True, exist_ok=True)
    monkeypatch.chdir(sandbox)

    class DemoPipeline(Pipeline):
        stages = ["push", "pull"]

        def load_data(self):
            return pd.DataFrame({"id": [1, 2], "value": ["a", "b"]})

        def push(self):
            self.sync_data_df("items")
            self.from_df("audit", self.datatable)
            return self.datatable

        def pull(self):
            self.load_data_df("items")
            return self.datatable

    pipeline = DemoPipeline(name="demo")
    result = pipeline.run()

    assert result.success
    assert pipeline.df.equals(pd.DataFrame({"id": [1, 2], "value": ["a", "b"]}))
    assert pipeline.to_df("items").equals(pd.DataFrame({"id": [1, 2], "value": ["a", "b"]}))
    assert pipeline.to_df("audit").equals(pd.DataFrame({"id": [1, 2], "value": ["a", "b"]}))
    assert pipeline.table_exists("items")
    assert pipeline.table_exists("audit")
    assert pipeline.get_data("items").equals(pd.DataFrame({"id": [1, 2], "value": ["a", "b"]}))
    assert pipeline._data["items"].equals(pd.DataFrame({"id": [1, 2], "value": ["a", "b"]}))


def test_pipeline_envs_supports_quick_sqlite_reads(monkeypatch):
    import pandas as pd
    from pathlib import Path

    sandbox = Path.cwd() / ".test_artifacts" / "pipeline_envs"
    sandbox.mkdir(parents=True, exist_ok=True)
    monkeypatch.chdir(sandbox)

    class DemoPipeline(Pipeline):
        stages = ["seed", "query"]

        def load_data(self):
            return pd.DataFrame({"id": [1, 2, 3], "value": ["a", "b", "c"]})

        def seed(self):
            self.sync_data_df("orders")
            return self.datatable

        def query(self):
            rows = self.envs.orders.filter(self.envs.orders.id > 1).to_df()
            self.set_data("filtered_orders", rows)
            return self.datatable

    pipeline = DemoPipeline(name="demo")
    result = pipeline.run()

    assert result.success
    assert pipeline.get_data("filtered_orders").equals(pd.DataFrame({"id": [2, 3], "value": ["b", "c"]}))
    assert pipeline.envs.orders.filter(id__gt=1).to_df().equals(pd.DataFrame({"id": [2, 3], "value": ["b", "c"]}))


def test_pipeline_supports_datatable_alias(monkeypatch):
    import pandas as pd
    from pathlib import Path

    sandbox = Path.cwd() / ".test_artifacts" / "pipeline_datatable_alias"
    sandbox.mkdir(parents=True, exist_ok=True)
    monkeypatch.chdir(sandbox)

    class DemoPipeline(Pipeline):
        stages = ["seed", "load_back"]

        def load_data(self):
            return pd.DataFrame({"id": [1], "value": ["a"]})

        def seed(self):
            self.datatable = self.datatable.assign(value=self.datatable["value"].str.upper())
            self.sync_datatable("items")
            return self.datatable

        def load_back(self):
            self.load_datatable("items")
            return self.datatable

    pipeline = DemoPipeline(name="demo")
    result = pipeline.run()

    assert result.success
    assert pipeline.get_datatable().equals(pd.DataFrame({"id": [1], "value": ["A"]}))
    assert pipeline.datatable.equals(pd.DataFrame({"id": [1], "value": ["A"]}))


def test_pipeline_datatable_property_set_and_get():
    import pandas as pd

    pipeline = Pipeline(name="demo")
    frame = pd.DataFrame({"id": [1], "value": ["a"]})

    pipeline.datatable = frame

    assert pipeline.datatable.equals(frame)
    assert pipeline.get_datatable().equals(frame)

    replacement = pd.DataFrame({"id": [2], "value": ["b"]})
    assert pipeline.set_datatable(replacement).equals(replacement)
    assert pipeline.datatable.equals(replacement)


def test_pipeline_datatable_coerces_tabular_values_to_datatable():
    import pandas as pd

    pipeline = Pipeline(name="demo")

    pipeline.datatable = pd.DataFrame({"id": [1], "value": ["a"]})
    assert isinstance(pipeline.datatable, DataTable)

    pipeline.datatable = [{"id": 2, "value": "b"}]
    assert isinstance(pipeline.datatable, DataTable)

    pipeline.datatable = {"id": [3], "value": ["c"]}
    assert isinstance(pipeline.datatable, DataTable)


def test_pipeline_datatable_allows_non_tabular_values():
    pipeline = Pipeline(name="demo")

    pipeline.datatable = 123
    assert isinstance(pipeline.datatable, DataTable)
    assert pipeline.datatable.to_rows() == [{"value": 123}]
    assert isinstance(pipeline.datatable, DataTable)
    assert pipeline.datatable.to_rows() == [{"value": 123}]


def test_pipeline_db_upsert_update_columns_and_invalid_mode(monkeypatch):
    import pandas as pd
    from pathlib import Path

    sandbox = Path.cwd() / ".test_artifacts" / "pipeline_upsert_update_columns"
    sandbox.mkdir(parents=True, exist_ok=True)
    monkeypatch.chdir(sandbox)

    class DemoPipeline(Pipeline):
        stages = ["seed", "sync", "read_back"]

        def load_data(self):
            return pd.DataFrame({"id": [1], "name": ["alice"], "status": ["new"]})

        def seed(self):
            self.db.set("items", self.datatable)
            return self.datatable

        def sync(self):
            self.db.upsert(
                "items",
                pd.DataFrame({"id": [1], "name": ["alice-updated"], "status": ["ignored"]}),
                key_columns=["id"],
                update_columns=["name"],
            )
            return self.datatable

        def read_back(self):
            self.datatable = self.load_datatable("items")
            return self.datatable

    pipeline = DemoPipeline(name="demo")
    result = pipeline.run()

    assert result.success
    assert list(pipeline.datatable["name"]) == ["alice-updated"]
    assert list(pipeline.datatable["status"]) == ["new"]

    with pytest.raises(ValueError):
        pipeline.db.upsert("items", pd.DataFrame({"id": [2]}), mode="bad-mode")


def test_pipeline_push_and_pull_aliases(monkeypatch):
    import pandas as pd
    from pathlib import Path

    sandbox = Path.cwd() / ".test_artifacts" / "pipeline_push_pull_aliases"
    sandbox.mkdir(parents=True, exist_ok=True)
    monkeypatch.chdir(sandbox)

    pipeline = Pipeline(name="demo")
    payload = pd.DataFrame({"id": [1, 2], "value": ["a", "b"]})

    pipeline.push("items", payload)
    pulled = pipeline.pull("items")

    assert pulled.equals(payload)
    assert pipeline.datatable.equals(payload)
    assert pipeline.db.get("items").equals(payload)


def test_pipeline_db_delete_insert_creates_missing_table(monkeypatch):
    import pandas as pd
    from pathlib import Path

    sandbox = Path.cwd() / ".test_artifacts" / "pipeline_delete_insert_new_table"
    sandbox.mkdir(parents=True, exist_ok=True)
    monkeypatch.chdir(sandbox)

    class DemoPipeline(Pipeline):
        stages = ["sync", "read_back"]

        def load_data(self):
            return pd.DataFrame({"id": [1, 2], "value": ["a", "b"]})

        def sync(self):
            self.db.upsert("items", self.datatable, mode="delete-insert", key_columns=["id"])
            return self.datatable

        def read_back(self):
            self.datatable = self.load_datatable("items").sort_values("id").reset_index(drop=True)
            return self.datatable

    pipeline = DemoPipeline(name="demo")
    result = pipeline.run()

    assert result.success
    assert pipeline.datatable.equals(pd.DataFrame({"id": [1, 2], "value": ["a", "b"]}))
    assert pipeline.table_exists("items")


def test_pipeline_before_and_after_methods_are_called():
    class DemoPipeline(Pipeline):
        stages = ["step_one"]

        def load_data(self):
            return 0

        def before_run(self):
            self.model.events = ["before"]

        def after_run(self):
            self.model.events.append("after")

        def step_one(self):
            self.model.events.append("step")
            self.datatable = 1
            return self.datatable

    pipeline = DemoPipeline(name="demo")
    outcome = pipeline.run()

    assert outcome.status == PipelineStatus.SUCCESS
    assert pipeline.model.events == ["before", "step", "after"]


def test_pipeline_module_entrypoint_import():
    from soigia.pipeline import BasePipeline as PublicBasePipeline

    assert PublicBasePipeline is BasePipeline


def test_pipeline_exposes_source_path():
    from pathlib import Path

    class PathPipeline(Pipeline):
        stages = []

    pipeline = PathPipeline(name="demo")

    assert pipeline.path == Path(__file__).resolve()


def test_pipeline_display_datatable_returns_rendered_text(monkeypatch):
    from pathlib import Path

    sandbox = Path.cwd() / ".test_artifacts" / "pipeline_display_datatable"
    sandbox.mkdir(parents=True, exist_ok=True)
    monkeypatch.chdir(sandbox)

    class DemoPipeline(Pipeline):
        stages = []

        def load_data(self):
            return DataTable({"id": [1], "name": ["alice"]})

    pipeline = DemoPipeline(name="demo")
    pipeline.datatable = DataTable({"id": [1], "name": ["alice"]})
    text = pipeline.display_datatable()

    assert isinstance(text, str)
    assert "alice" in text
    assert "id" in text.lower()


def test_pipeline_logging_datatable_writes_to_log(monkeypatch):
    from pathlib import Path

    sandbox = Path.cwd() / ".test_artifacts" / "pipeline_logging_datatable"
    sandbox.mkdir(parents=True, exist_ok=True)
    monkeypatch.chdir(sandbox)

    class DemoPipeline(Pipeline):
        stages = []

        def load_data(self):
            return DataTable({"id": [1], "name": ["alice"]})

    pipeline = DemoPipeline(name="demo")
    pipeline.datatable = DataTable({"id": [1], "name": ["alice"]})
    text = pipeline.logging_datatable()

    log_path = Path("logs") / "DemoPipeline.log"
    assert text
    assert log_path.exists()
    content = log_path.read_text(encoding="utf-8", errors="replace")
    assert "alice" in content
    assert "id" in content.lower()


def test_pipeline_cli_runs_with_optional_overrides(monkeypatch, capsys):
    from pathlib import Path

    sandbox = Path.cwd() / ".test_artifacts" / "pipeline_cli"
    sandbox.mkdir(parents=True, exist_ok=True)
    monkeypatch.chdir(sandbox)

    class DemoPipeline(Pipeline):
        stages = ["normalize"]

        def load_data(self):
            return DataTable({"id": [1], "name": ["alice"]})

        def normalize(self):
            if self.config.get("uppercase"):
                self.datatable["name"] = self.datatable["name"].str.upper()

    outcome = DemoPipeline.cli(["--name", "cli-demo", "--set", "uppercase=true", "--step", "normalize", "--show-datatable"])
    captured = capsys.readouterr()

    assert outcome.status == PipelineStatus.SUCCESS
    assert outcome.data.to_rows() == [{"id": 1, "name": "ALICE"}]
    assert "ALICE" in captured.out


def test_pipeline_loaders_support_data_csv_and_yaml():
    import pandas as pd
    import yaml
    from pathlib import Path

    class LoaderPipeline(Pipeline):
        stages = ["normalize"]

        def load_data(self):
            return [{"name": " alice ", "amount": "12.5"}, {"name": "bob", "amount": "7"}]

        def normalize(self):
            self.logging("loaders-test-marker")
            df = pd.DataFrame(self.require_df())
            df["name"] = df["name"].astype(str).str.strip()
            df["amount"] = pd.to_numeric(df["amount"], errors="coerce")
            self.datatable = df
            return self.datatable

    data_pipeline = LoaderPipeline(name="data")
    data_result = data_pipeline.run()
    assert data_result.success
    assert data_pipeline.df.shape == (2, 2)
    dump_dir = Path.cwd() / "data" / "LoaderPipeline"
    assert (dump_dir / "01_normalize.csv").exists()
    log_path = Path.cwd() / "logs" / "LoaderPipeline.log"
    assert log_path.exists()
    assert "loaders-test-marker" in log_path.read_text(encoding="utf-8", errors="replace")
    summary_text = (dump_dir / "pipeline_summary.md").read_text(encoding="utf-8", errors="replace")
    assert "# Pipeline Run Summary" in summary_text
    assert "| 01 | normalize | success |" in summary_text

    artifact_root = Path.cwd() / ".test_artifacts"
    artifact_root.mkdir(exist_ok=True)

    csv_path = artifact_root / "sales-loader.csv"
    pd.DataFrame([{"name": "alice", "amount": 12.5}, {"name": "bob", "amount": 7.0}]).to_csv(csv_path, index=False)

    class CsvPipeline(Pipeline):
        stages = ["normalize"]

        def load_data(self):
            return self.load_csv(csv_path)

        def normalize(self):
            self.datatable["name"] = self.datatable["name"].str.upper()
            return self.datatable

    csv_pipeline = CsvPipeline(name="csv")
    csv_result = csv_pipeline.run()
    assert csv_result.success
    assert list(csv_pipeline.df["name"]) == ["ALICE", "BOB"]
    assert (Path.cwd() / "data" / "CsvPipeline" / "01_normalize.csv").exists()

    yaml_path = artifact_root / "sales-loader.yaml"
    yaml_payload = [{"name": "carol", "amount": 9.5}, {"name": "dave", "amount": 3.25}]
    yaml_path.write_text(
        yaml.safe_dump(yaml_payload, sort_keys=False, allow_unicode=True),
        encoding="utf-8",
    )

    class YamlPipeline(Pipeline):
        stages = ["normalize"]

        def load_data(self):
            payload = self.load_yaml(yaml_path)
            self.datatable = pd.DataFrame(payload)
            return self.datatable

        def normalize(self):
            self.datatable["amount"] = pd.to_numeric(self.datatable["amount"], errors="coerce")
            return self.datatable

    yaml_pipeline = YamlPipeline(name="yaml")
    yaml_result = yaml_pipeline.run()
    assert yaml_result.success
    assert yaml_pipeline.df.shape == (2, 2)
    assert (Path.cwd() / "data" / "YamlPipeline" / "01_normalize.csv").exists()


def test_pipeline_supports_explicit_csv_and_parquet_helpers():
    import pandas as pd
    from pathlib import Path

    artifact_root = Path.cwd() / ".test_artifacts" / "file_helpers"
    artifact_root.mkdir(parents=True, exist_ok=True)

    source_df = pd.DataFrame({"name": ["alice", "bob"], "amount": [12.5, 7.0]})
    csv_path = artifact_root / "source.csv"
    parquet_path = artifact_root / "source.parquet"
    source_df.to_csv(csv_path, index=False)

    class FileHelperPipeline(Pipeline):
        stages = ["load", "save"]

        def load_data(self):
            return self.load_data_from_csv(csv_path)

        def load(self):
            self.datatable["amount"] = pd.to_numeric(self.datatable["amount"], errors="coerce")
            return self.datatable

        def save(self):
            self.save_data_to_csv(artifact_root / "output.csv")
            self.save_data_to_parquet(parquet_path)
            return self.datatable

    pipeline = FileHelperPipeline(name="file-helper")
    result = pipeline.run()

    assert result.success
    assert (artifact_root / "output.csv").exists()
    assert parquet_path.exists()

    loaded = pipeline.load_data_from_parquet(parquet_path)
    assert loaded.equals(pd.DataFrame({"name": ["alice", "bob"], "amount": [12.5, 7.0]}))


def test_pipeline_pipelines_alias_still_works():
    class AliasPipeline(Pipeline):
        pipelines = ["double"]

        def load_data(self):
            return 2

        def double(self):
            self.datatable = self.datatable * 2
            return self.datatable

    pipeline = AliasPipeline(name="alias")
    outcome = pipeline.run()
    assert outcome.success
    assert isinstance(outcome.data, DataTable)
    assert outcome.data.to_rows() == [{"value": 4}]


def test_pipeline_protected_hooks_cannot_be_overridden():
    with pytest.raises(TypeError):

        class BadPreprocessPipeline(Pipeline):
            def preprocess_data(self):  # pragma: no cover - defined only to trigger the guard
                return self.datatable

    with pytest.raises(TypeError):

        class BadPostprocessPipeline(Pipeline):
            def postprocess_data(self):  # pragma: no cover - defined only to trigger the guard
                return self.datatable

    with pytest.raises(TypeError):

        class BadSavePipeline(Pipeline):
            def save_data_to_db(self):  # pragma: no cover - defined only to trigger the guard
                return self.datatable

    with pytest.raises(TypeError):

        class BadSaveOutputsPipeline(Pipeline):
            def save_outputs(self):  # pragma: no cover - defined only to trigger the guard
                return self.datatable


def test_pipeline_defaults_to_empty_dataframe_when_load_data_not_overridden():
    class EmptyPipeline(Pipeline):
        stages = []

    pipeline = EmptyPipeline(name="empty")
    result = pipeline.run()

    assert result.success
    assert result.rows == 0
    assert hasattr(pipeline.datatable, "empty")
    assert pipeline.datatable.empty
    assert result.csv_path is not None
    assert result.db_path is None
    assert result.parquet_path is None


def test_pipeline_uses_load_data_return_value():
    class ReturnPipeline(Pipeline):
        stages = ["double"]

        def load_data(self):
            return DataTable({"value": [3]})

        def double(self):
            self.datatable = self.datatable * 2
            return self.datatable

    pipeline = ReturnPipeline(name="return")
    result = pipeline.run()

    assert result.success
    assert isinstance(pipeline.datatable, DataTable)
    assert pipeline.datatable.to_rows() == [{"value": 6}]


def test_pipeline_loads_yaml_config_into_namespace():
    import yaml
    from pathlib import Path

    config_dir = Path(__file__).resolve().parent
    config_path = config_dir / "config.yaml"
    config_path.write_text(
        yaml.safe_dump({"factor": 4, "label": "demo", "nested": {"enabled": True}}, sort_keys=False),
        encoding="utf-8",
    )

    class YamlConfigPipeline(Pipeline):
        stages = ["scale"]

        def load_data(self):
            return DataTable({"value": [3]})

        def scale(self):
            self.datatable = self.datatable * self.config.factor
            self.model.label = self.config.label
            self.model.enabled = self.config.nested["enabled"]
            return self.datatable

    pipeline = YamlConfigPipeline(name="yaml-config")
    result = pipeline.run()

    assert result.success
    assert isinstance(pipeline.datatable, DataTable)
    assert pipeline.datatable.to_rows() == [{"value": 12}]
    assert pipeline.config.factor == 4
    assert pipeline.config.label == "demo"
    assert pipeline.config.nested["enabled"] is True
    assert pipeline.config.get("factor") == 4
    config_path.unlink(missing_ok=True)


def test_pipeline_restores_snapshot_when_stage_fails():
    import pandas as pd

    class FailingPipeline(Pipeline):
        stages = ["prepare", "explode"]

        def load_data(self):
            return pd.DataFrame({"value": [1, 2]})

        def prepare(self):
            self.datatable = self.datatable.assign(prepared=True)
            return self.datatable

        def explode(self):
            self.datatable = self.datatable.assign(bad=True)
            raise RuntimeError("boom")

    pipeline = FailingPipeline(name="failing")
    result = pipeline.run()

    assert result.failed
    assert list(pipeline.datatable.columns) == ["value", "prepared"]
    assert "bad" not in pipeline.datatable.columns


def test_pipeline_executes_registered_rollback_actions_on_failure():
    class SideEffectPipeline(Pipeline):
        stages = ["prepare", "explode"]

        def load_data(self):
            return 1

        def prepare(self):
            self.model.events = []
            self.model.events.append("prepare")
            self.on_rollback(lambda: self.model.events.append("undo"))
            self.datatable = 2
            return self.datatable

        def explode(self):
            self.model.events.append("explode")
            raise RuntimeError("boom")

    pipeline = SideEffectPipeline(name="side-effect")
    result = pipeline.run()

    assert result.failed
    assert pipeline.model.events == ["prepare", "explode", "undo"]

