#
# Copyright (c) 2025-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#

"""Tests for `pipecat init` — initializing a project (AGENTS.md + CLAUDE.md)."""

from pathlib import Path

import pytest
from typer.testing import CliRunner

import pipecat.cli
import pipecat.cli.commands.init as init_mod
import pipecat.cli.scaffold as scaffold_mod
from pipecat.cli.main import app

runner = CliRunner()

AGENT_TEMPLATES = Path(pipecat.cli.__file__).parent / "agent_templates"


class _StubAsk:
    """Stand-in for a questionary prompt object: ``.ask()`` returns a preset value."""

    def __init__(self, value):
        self._value = value

    def ask(self):
        return self._value


def _confirm_matching(*phrases):
    """Stub ``questionary.confirm`` to say yes only to prompts naming ``phrases``.

    ``init`` asks several questions, and one of them offers to build the Context Hub
    index — a subprocess that downloads and indexes the corpus. Matching on the prompt
    text keeps a test from answering a question it did not mean to.
    """

    def _confirm(message, *args, **kwargs):
        return _StubAsk(any(phrase in message for phrase in phrases))

    return _confirm


class TestInitAgentReady:
    """Behavior of the `pipecat init` file-drop command."""

    def test_writes_all_files(self, tmp_path):
        result = runner.invoke(app, ["init", str(tmp_path)])
        assert result.exit_code == 0, result.output
        agents = tmp_path / "AGENTS.md"
        claude = tmp_path / "CLAUDE.md"
        getting_started = tmp_path / "GETTING_STARTED.md"
        assert agents.read_text(encoding="utf-8").strip()
        assert "pipecat init" in agents.read_text(encoding="utf-8")
        assert claude.read_text(encoding="utf-8").strip() == "@AGENTS.md"
        # Developer guidance: prompt-writing help with a copyable example prompt.
        gs_text = getting_started.read_text(encoding="utf-8")
        assert "first prompt" in gs_text
        assert "help me choose" in gs_text
        # The terminal output points the developer at it.
        assert "GETTING_STARTED.md" in result.output

    def test_getting_started_preserved_then_refreshed_with_flag(self, tmp_path):
        """GETTING_STARTED.md is kept on a plain re-run; --overwrite-guide refreshes it."""
        import pipecat

        runner.invoke(app, ["init", str(tmp_path)])
        (tmp_path / "GETTING_STARTED.md").write_text("my notes", encoding="utf-8")
        # Plain re-run preserves the developer's file.
        result = runner.invoke(app, ["init", str(tmp_path)])
        assert result.exit_code == 0, result.output
        assert (tmp_path / "GETTING_STARTED.md").read_text(encoding="utf-8") == "my notes"
        # --overwrite-guide rewrites it from the template, version-stamped.
        result = runner.invoke(app, ["init", str(tmp_path), "--overwrite-guide"])
        assert result.exit_code == 0, result.output
        text = (tmp_path / "GETTING_STARTED.md").read_text(encoding="utf-8")
        assert "first prompt" in text
        assert f"pipecat-ai {pipecat.__version__}" in text
        assert text.rstrip().endswith("-->")

    def test_agents_file_carries_version_stamp(self, tmp_path):
        """The written guide ends with a provenance footer naming the writing version."""
        import pipecat

        result = runner.invoke(app, ["init", str(tmp_path)])
        assert result.exit_code == 0, result.output
        text = (tmp_path / "AGENTS.md").read_text(encoding="utf-8")
        assert text.rstrip().endswith("-->")
        assert f"pipecat-ai {pipecat.__version__}" in text
        assert "Run `pipecat init --overwrite-guide` to refresh" in text
        # The bundled template itself stays unstamped — the version is known only at write time.
        assert "Generated by" not in (AGENT_TEMPLATES / "AGENTS.md").read_text(encoding="utf-8")

    def test_prompts_for_directory_when_omitted(self, tmp_path, monkeypatch):
        # Bare `init` prompts; the typed value is just a path → becomes the project folder.
        monkeypatch.chdir(tmp_path)
        result = runner.invoke(app, ["init"], input="mybot\n")
        assert result.exit_code == 0, result.output
        assert (tmp_path / "mybot" / "AGENTS.md").exists()

    def test_prompt_dot_sets_up_current_dir(self, tmp_path, monkeypatch):
        monkeypatch.chdir(tmp_path)
        result = runner.invoke(app, ["init"], input=".\n")
        assert result.exit_code == 0, result.output
        assert (tmp_path / "AGENTS.md").exists()

    def test_creates_missing_target_dir(self, tmp_path):
        target = tmp_path / "nested" / "bot"
        result = runner.invoke(app, ["init", str(target)])
        assert result.exit_code == 0, result.output
        assert (target / "AGENTS.md").exists()

    def test_rerun_preserves_existing_guides(self, tmp_path):
        runner.invoke(app, ["init", str(tmp_path)])
        (tmp_path / "AGENTS.md").write_text("my agents", encoding="utf-8")
        (tmp_path / "CLAUDE.md").write_text("# my own claude config", encoding="utf-8")
        result = runner.invoke(app, ["init", str(tmp_path)])
        assert result.exit_code == 0, result.output
        # Preserve-by-default: a plain re-run clobbers neither file.
        assert (tmp_path / "AGENTS.md").read_text(encoding="utf-8") == "my agents"
        assert (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") == "# my own claude config"

    def test_rerun_summary_when_all_kept(self, tmp_path):
        runner.invoke(app, ["init", str(tmp_path)])
        # Second run: everything is present and current, so nothing is (re)written.
        result = runner.invoke(app, ["init", str(tmp_path)])
        assert result.exit_code == 0, result.output
        assert "Kept" in result.output
        assert "already in place" in result.output
        assert "overwrite-guide" in result.output

    def test_overwrite_guide_overwrites_all(self, tmp_path):
        runner.invoke(app, ["init", str(tmp_path)])
        (tmp_path / "AGENTS.md").write_text("stale agents", encoding="utf-8")
        (tmp_path / "CLAUDE.md").write_text("# my own claude config", encoding="utf-8")
        result = runner.invoke(app, ["init", str(tmp_path), "--overwrite-guide"])
        assert result.exit_code == 0, result.output
        # Both are replaced with the current templates.
        assert "pipecat init" in (tmp_path / "AGENTS.md").read_text(encoding="utf-8")
        assert (tmp_path / "CLAUDE.md").read_text(encoding="utf-8").strip() == "@AGENTS.md"

    def test_scaffold_flags_build_in_place(self, tmp_path):
        # Scaffold flags now build the project in-place (no redirect to a separate command).
        result = runner.invoke(
            app,
            [
                "init",
                str(tmp_path),
                "--bot-type",
                "web",
                "-t",
                "daily",
                "-m",
                "cascade",
                "--stt",
                "deepgram_stt",
                "--llm",
                "openai_llm",
                "--tts",
                "cartesia_tts",
            ],
        )
        assert result.exit_code == 0, result.output
        # init initialized for agent-led dev and scaffolded a runnable bot in the same dir.
        assert (tmp_path / "AGENTS.md").exists()
        assert (tmp_path / "server" / "bot.py").exists()
        # The scaffold path skips the from-scratch developer guide.
        assert not (tmp_path / "GETTING_STARTED.md").exists()

    def test_stale_guide_nudges(self, tmp_path):
        runner.invoke(app, ["init", str(tmp_path)])
        # Simulate a guide written by an older Pipecat via an old version stamp.
        stale = "# old guide\n<!-- Generated by `pipecat init` (pipecat-ai 0.0.1). -->\n"
        (tmp_path / "AGENTS.md").write_text(stale, encoding="utf-8")
        result = runner.invoke(app, ["init", str(tmp_path)])
        assert result.exit_code == 0, result.output
        # Kept as-is, but the developer is told it's stale and how to refresh.
        assert (tmp_path / "AGENTS.md").read_text(encoding="utf-8") == stale
        assert "0.0.1" in result.output
        assert "overwrite-guide" in result.output

    def test_stale_guide_offers_refresh_interactively(self, tmp_path, monkeypatch):
        runner.invoke(app, ["init", str(tmp_path)])
        stale = "# old\n<!-- Generated by `pipecat init` (pipecat-ai 0.0.1). -->\n"
        (tmp_path / "AGENTS.md").write_text(stale, encoding="utf-8")
        # Force-interactive, confirm the refresh, then pick the default build method.
        monkeypatch.setattr(init_mod, "_is_interactive", lambda: True)
        import questionary

        monkeypatch.setattr(questionary, "confirm", _confirm_matching("Refresh the guide files"))
        monkeypatch.setattr(questionary, "select", lambda *a, **k: _StubAsk("agent"))
        result = runner.invoke(app, ["init", str(tmp_path)])
        assert result.exit_code == 0, result.output
        # Confirming overwrote the stale guide with the current template.
        assert "pipecat init" in (tmp_path / "AGENTS.md").read_text(encoding="utf-8")

    def test_stale_guide_refresh_declined_keeps_file(self, tmp_path, monkeypatch):
        runner.invoke(app, ["init", str(tmp_path)])
        stale = "# old\n<!-- Generated by `pipecat init` (pipecat-ai 0.0.1). -->\n"
        (tmp_path / "AGENTS.md").write_text(stale, encoding="utf-8")
        monkeypatch.setattr(init_mod, "_is_interactive", lambda: True)
        import questionary

        monkeypatch.setattr(questionary, "confirm", lambda *a, **k: _StubAsk(False))
        monkeypatch.setattr(questionary, "select", lambda *a, **k: _StubAsk("agent"))
        result = runner.invoke(app, ["init", str(tmp_path)])
        assert result.exit_code == 0, result.output
        # Declining leaves the developer's file untouched.
        assert (tmp_path / "AGENTS.md").read_text(encoding="utf-8") == stale

    def test_quickstart_scaffolds_and_writes_guide(self, tmp_path, monkeypatch):
        # `init quickstart` is the human front door for the canned bot: it scaffolds the
        # quickstart in-place AND drops the agent guide, all in ./pipecat-quickstart.
        monkeypatch.chdir(tmp_path)
        result = runner.invoke(app, ["init", "quickstart"])
        assert result.exit_code == 0, result.output
        project = tmp_path / "pipecat-quickstart"
        # Coding-agent guide files...
        assert (project / "AGENTS.md").exists()
        assert (project / "CLAUDE.md").read_text(encoding="utf-8").strip() == "@AGENTS.md"
        # ...plus a runnable bot in the same directory.
        assert (project / "server" / "bot.py").exists()

    def test_quickstart_omits_developer_guide(self, tmp_path, monkeypatch):
        # GETTING_STARTED.md is from-scratch onboarding; the prebuilt quickstart skips it.
        # Its one still-useful piece (Context Hub setup) lives in the scaffold's README.
        monkeypatch.chdir(tmp_path)
        result = runner.invoke(app, ["init", "quickstart"])
        assert result.exit_code == 0, result.output
        project = tmp_path / "pipecat-quickstart"
        assert not (project / "GETTING_STARTED.md").exists()
        assert "Context Hub" in (project / "README.md").read_text(encoding="utf-8")

    def test_target_is_a_file_errors(self, tmp_path):
        afile = tmp_path / "afile"
        afile.write_text("x", encoding="utf-8")
        result = runner.invoke(app, ["init", str(afile)])
        assert result.exit_code == 1
        assert "not a directory" in result.output


class TestBuildMethodRouting:
    """Interactive `init` routes the developer to a coding agent or an in-place scaffold."""

    @pytest.fixture
    def _select(self, monkeypatch):
        """Force-interactive init and stub questionary.select; returns a setter for the choice."""
        import questionary

        monkeypatch.setattr(init_mod, "_is_interactive", lambda: True)
        chosen = {"value": None}

        class _Q:
            def ask(self):
                return chosen["value"]

        monkeypatch.setattr(questionary, "select", lambda *a, **k: _Q())
        return lambda value: chosen.__setitem__("value", value)

    def test_scaffold_branch_scaffolds_in_place(self, tmp_path, monkeypatch, _select):
        _select("scaffold")
        captured = {}
        monkeypatch.setattr(
            scaffold_mod,
            "scaffold_interactive",
            lambda dest, name, in_place: captured.update(dest=dest, name=name, in_place=in_place),
        )
        result = runner.invoke(app, ["init", str(tmp_path)])
        assert result.exit_code == 0, result.output
        # The scaffold lands in init's target dir, in-place, named after it.
        assert captured == {"dest": tmp_path, "name": tmp_path.name, "in_place": True}
        # Scaffolding now ends in a built bot, so the from-scratch developer guide is
        # skipped — its place is taken by the scaffold's README (see test_quickstart_*).
        assert not (tmp_path / "GETTING_STARTED.md").exists()
        # ...but the core coding-agent guide files are still written.
        assert (tmp_path / "AGENTS.md").exists()
        assert (tmp_path / "CLAUDE.md").exists()

    def test_agent_branch_does_not_scaffold(self, tmp_path, monkeypatch, _select):
        _select("agent")
        monkeypatch.setattr(
            scaffold_mod,
            "scaffold_interactive",
            lambda *a, **k: pytest.fail("agent branch must not scaffold"),
        )
        result = runner.invoke(app, ["init", str(tmp_path)])
        assert result.exit_code == 0, result.output
        assert "your project is ready" in result.output.lower()
        assert not (tmp_path / "server").exists()
        # The coding-agent path gets the developer guide.
        assert (tmp_path / "GETTING_STARTED.md").exists()

    def test_question_skipped_when_already_scaffolded(self, tmp_path, monkeypatch):
        # Re-running init to refresh the guide in a scaffolded project must not offer to scaffold.
        (tmp_path / "server").mkdir()
        monkeypatch.setattr(init_mod, "_is_interactive", lambda: True)
        import questionary

        monkeypatch.setattr(
            questionary,
            "select",
            lambda *a, **k: pytest.fail("no prompt on an already-scaffolded dir"),
        )
        result = runner.invoke(app, ["init", str(tmp_path)])
        assert result.exit_code == 0, result.output
        assert (tmp_path / "AGENTS.md").exists()

    def test_no_prompt_without_a_tty(self, tmp_path, monkeypatch):
        # The default CliRunner stdin isn't a tty, so init stays a pure file-drop (today's behavior).
        import questionary

        monkeypatch.setattr(
            questionary, "select", lambda *a, **k: pytest.fail("no prompt without a tty")
        )
        result = runner.invoke(app, ["init", str(tmp_path)])
        assert result.exit_code == 0, result.output
        assert (tmp_path / "AGENTS.md").exists()
        assert not (tmp_path / "server").exists()


class TestRemovedCreateCommand:
    """`pipecat create` was folded into `init`; a hidden stub points users to it."""

    def test_create_is_hidden_from_help(self):
        out = runner.invoke(app, ["--help"]).output
        assert "create" not in out
        assert "init" in out

    def test_create_errors_with_pointer_to_init(self):
        # Old flags must not crash on parsing — the stub swallows them and prints guidance.
        result = runner.invoke(app, ["create", "--name", "x", "--bot-type", "web"])
        assert result.exit_code == 1
        assert "pipecat init" in result.output


class TestBundledGuide:
    """The guide must ship and be release-clean (catches packaging + content regressions)."""

    def test_bundled_files_exist(self):
        assert (AGENT_TEMPLATES / "AGENTS.md").is_file()
        assert (AGENT_TEMPLATES / "CLAUDE.md").is_file()
        assert (AGENT_TEMPLATES / "GETTING_STARTED.md").is_file()

    def test_agents_content_is_release_clean(self):
        text = (AGENT_TEMPLATES / "AGENTS.md").read_text(encoding="utf-8")
        assert text.strip()
        assert "pipecat init" in text
        # No local-checkout paths or the removed command name should ever ship.
        assert "/Users/" not in text
        assert "pipecat create" not in text
        assert "TODO(release)" not in text

    def test_claude_is_agents_import(self):
        assert (AGENT_TEMPLATES / "CLAUDE.md").read_text(encoding="utf-8").strip() == "@AGENTS.md"

    def test_getting_started_is_release_clean(self):
        text = (AGENT_TEMPLATES / "GETTING_STARTED.md").read_text(encoding="utf-8")
        assert text.strip()
        # No local-checkout paths, and the template ships unstamped (the
        # provenance footer is appended at write time).
        assert "/Users/" not in text
        assert "Generated by" not in text
