"""End-to-end integration tests for exokit scaffold.

Verifies the complete project structure produced by ``scaffold.generate()``,
including templates, static files, directory stubs, and manifest.

APX is the only component mocked (requires external binary). Everything
else runs against the real template engine and real templates.
"""

from __future__ import annotations

import json
import re
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest
import yaml
from typer.testing import CliRunner

from exokit.cli import cli_app as app
from exokit.core.models import (
    DemoManifest,
    PrereqResult,
    QuestionnaireAnswers,
    ScaffoldContext,
    ScaffoldResult,
)
from exokit.core.questionnaire import build_context
from exokit.core.scaffold import generate
from exokit.core.skills import ESSENTIAL_SKILLS, bundle_skills, generate_mcp_config

runner = CliRunner()


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture()
def sample_answers() -> QuestionnaireAnswers:
    """Full FSI sample answers."""
    return QuestionnaireAnswers(
        company="Acme Bank",
        industry="fsi",
        demo_description="Test demo description",
        catalog="acme_bank_fsi",
        warehouse_id="abc123def456",
        workspace_host="https://adb-1234567890.azuredatabricks.net",
        databricks_profile="DEFAULT",
        lakebase_url="postgresql://user:pass@host:5432/db",
        notification_email="demo@acmebank.com",
    )


@pytest.fixture()
def context(sample_answers: QuestionnaireAnswers) -> ScaffoldContext:
    """Build a ScaffoldContext from sample answers."""
    return build_context(sample_answers)


@pytest.fixture()
def project_dir(tmp_path: Path, context: ScaffoldContext) -> Path:
    """Generate a full project and return the project directory."""
    output = tmp_path / "acme-demo"
    generate(context, output)
    return output


@pytest.fixture()
def skills_source(tmp_path: Path) -> Path:
    """Create a mock skills source with all essential skills."""
    source = tmp_path / "skills_src"
    for skill_name in ESSENTIAL_SKILLS:
        skill_dir = source / skill_name
        skill_dir.mkdir(parents=True)
        (skill_dir / "skill.md").write_text(f"# {skill_name}\n")
    return source


# ---------------------------------------------------------------------------
# Project structure tests
# ---------------------------------------------------------------------------


class TestProjectStructure:
    """Verify complete project structure after scaffold."""

    def test_claude_md_exists(self, project_dir: Path) -> None:
        """CLAUDE.md must exist at project root."""
        assert (project_dir / "CLAUDE.md").exists()

    def test_claude_md_has_expected_content(self, project_dir: Path) -> None:
        """CLAUDE.md must contain key sections."""
        content = (project_dir / "CLAUDE.md").read_text()
        assert "Acme Bank" in content
        assert "Technology Stack" in content
        assert "Wave Structure" in content
        assert len(content.splitlines()) > 300

    def test_claude_agents_directory(self, project_dir: Path) -> None:
        """.claude/agents/ must have 4 agent files."""
        agents_dir = project_dir / ".claude" / "agents"
        assert agents_dir.is_dir()
        agent_files = list(agents_dir.glob("*.md"))
        assert len(agent_files) == 4
        agent_names = sorted(f.name for f in agent_files)
        assert "lakehouse-dev.md" in agent_names
        assert "app-dev.md" in agent_names
        assert "dashboard-dev.md" in agent_names
        assert "reviewer.md" in agent_names

    def test_claude_commands_directory(self, project_dir: Path) -> None:
        """.claude/commands/ must have command files."""
        commands_dir = project_dir / ".claude" / "commands"
        assert commands_dir.is_dir()
        cmd_files = list(commands_dir.glob("*.md"))
        assert len(cmd_files) >= 3

    def test_databricks_yml_valid_yaml(self, project_dir: Path) -> None:
        """databricks.yml must be at project root and be valid YAML."""
        yml_path = project_dir / "databricks.yml"
        assert yml_path.exists()
        parsed = yaml.safe_load(yml_path.read_text())
        assert parsed is not None
        assert "bundle" in parsed
        assert parsed["bundle"]["name"] == "acme-bank-fsi-demo"

    def test_demo_manifest_valid_json(self, project_dir: Path) -> None:
        """conf/demo_manifest.json must be valid JSON matching DemoManifest."""
        manifest_path = project_dir / "conf" / "demo_manifest.json"
        assert manifest_path.exists()
        data = json.loads(manifest_path.read_text())
        manifest = DemoManifest.model_validate(data)
        assert manifest.meta.company == "Acme Bank"
        assert manifest.meta.industry == "fsi"
        assert len(manifest.waves) == 5
        assert all(w.status == "pending" for w in manifest.waves)

    def test_lakehouse_stub_dirs(self, project_dir: Path) -> None:
        """All lakehouse stub directories must exist."""
        assert (project_dir / "src" / "data_generation" / "structured").is_dir()
        assert (project_dir / "src" / "data_generation" / "unstructured").is_dir()
        assert (project_dir / "src" / "pipelines" / "bronze").is_dir()
        assert (project_dir / "src" / "pipelines" / "silver").is_dir()
        assert (project_dir / "src" / "pipelines" / "gold").is_dir()
        assert (project_dir / "src" / "ml").is_dir()
        assert (project_dir / "src" / "dashboards").is_dir()
        assert (project_dir / "src" / "agents").is_dir()

    def test_resources_dirs(self, project_dir: Path) -> None:
        """Resources directories must exist at the correct path (not under lakehouse/)."""
        assert (project_dir / "resources" / "jobs").is_dir()
        assert (project_dir / "resources" / "pipelines").is_dir()
        # Should NOT have a lakehouse/ directory
        assert not (project_dir / "lakehouse").exists()

    def test_lakehouse_template_output_paths(self, project_dir: Path) -> None:
        """Lakehouse templates should render to resources/, not lakehouse/resources/."""
        # The job templates should be in resources/jobs/
        jobs_dir = project_dir / "resources" / "jobs"
        job_files = list(jobs_dir.glob("*.yml"))
        assert len(job_files) >= 2  # data_generation_job.yml + ml_training_job.yml

        # The pipeline template should be in resources/pipelines/
        pipelines_dir = project_dir / "resources" / "pipelines"
        pipeline_files = list(pipelines_dir.glob("*.yml"))
        assert len(pipeline_files) >= 1  # main_pipeline.yml

    def test_docs_directory(self, project_dir: Path) -> None:
        """docs/ and docs/supplements/ must exist with content."""
        assert (project_dir / "docs").is_dir()
        assert (project_dir / "docs" / "supplements").is_dir()
        assert (project_dir / "docs" / "IMPLEMENTATION_PLAN.md").exists()
        assert (project_dir / "docs" / "PROGRESS.md").exists()

    def test_scripts_directory(self, project_dir: Path) -> None:
        """scripts/ must have deploy and validate scripts."""
        scripts_dir = project_dir / "scripts"
        assert scripts_dir.is_dir()
        assert (scripts_dir / "deploy.sh").exists()
        assert (scripts_dir / "validate.sh").exists()

    def test_env_example_exists(self, project_dir: Path) -> None:
        """.env.example must exist."""
        assert (project_dir / ".env.example").exists()

    def test_gitignore_exists(self, project_dir: Path) -> None:
        """.gitignore must exist."""
        assert (project_dir / ".gitignore").exists()

    def test_pyproject_toml_exists(self, project_dir: Path) -> None:
        """pyproject.toml must exist."""
        assert (project_dir / "pyproject.toml").exists()

    def test_agent_memory_directories(self, project_dir: Path) -> None:
        """Agent memory directories must exist after scaffold."""
        assert (project_dir / ".claude" / "agent-memory" / "lakehouse-dev").is_dir()
        assert (project_dir / ".claude" / "agent-memory" / "app-dev").is_dir()
        assert (project_dir / ".claude" / "agent-memory" / "dashboard-dev").is_dir()
        assert (project_dir / ".claude" / "agent-memory" / "reviewer").is_dir()

    def test_resources_dashboards_directory(self, project_dir: Path) -> None:
        """resources/dashboards/ directory must exist after scaffold."""
        assert (project_dir / "resources" / "dashboards").is_dir()

    def test_no_duplicate_manifest_in_files(self, context: ScaffoldContext, tmp_path: Path) -> None:
        """conf/demo_manifest.json should only appear once in files_created."""
        output = tmp_path / "dedup-test"
        result = generate(context, output)
        manifest_entries = [f for f in result.files_created if f == "conf/demo_manifest.json"]
        assert len(manifest_entries) == 1


# ---------------------------------------------------------------------------
# Scaffold result validation
# ---------------------------------------------------------------------------


class TestScaffoldResult:
    """Verify the ScaffoldResult returned by generate()."""

    def test_result_type(self, context: ScaffoldContext, tmp_path: Path) -> None:
        """generate() should return a ScaffoldResult."""
        result = generate(context, tmp_path / "proj")
        assert isinstance(result, ScaffoldResult)

    def test_result_has_files(self, context: ScaffoldContext, tmp_path: Path) -> None:
        """Result should list created files."""
        result = generate(context, tmp_path / "proj")
        assert len(result.files_created) > 0
        assert "CLAUDE.md" in result.files_created

    def test_result_manifest_valid(self, context: ScaffoldContext, tmp_path: Path) -> None:
        """Result manifest should match DemoManifest model."""
        result = generate(context, tmp_path / "proj")
        assert isinstance(result.manifest, DemoManifest)
        assert result.manifest.meta.company == "Acme Bank"

    def test_result_no_warnings_with_real_templates(
        self, context: ScaffoldContext, tmp_path: Path
    ) -> None:
        """With real templates, there should be no warnings."""
        result = generate(context, tmp_path / "proj")
        assert len(result.warnings) == 0


# ---------------------------------------------------------------------------
# Skills integration
# ---------------------------------------------------------------------------


class TestSkillsIntegration:
    """Verify skills bundling works with the generated project."""

    def test_bundle_skills_into_project(self, project_dir: Path, skills_source: Path) -> None:
        """Skills should be bundled into the generated project."""
        result = bundle_skills(project_dir, skills_source=skills_source)
        assert len(result) == len(ESSENTIAL_SKILLS)
        for skill in ESSENTIAL_SKILLS:
            assert (project_dir / ".claude" / "skills" / skill).is_dir()

    def test_mcp_config_generated(self, project_dir: Path, tmp_path: Path) -> None:
        """.mcp.json should be generated in the project."""
        kit_path = tmp_path / "ai-dev-kit"
        (kit_path / "mcp" / "databricks-mcp").mkdir(parents=True)

        mcp_path = generate_mcp_config(project_dir, ai_dev_kit_path=kit_path)
        assert mcp_path.exists()
        config = json.loads(mcp_path.read_text())
        assert "mcpServers" in config


# ---------------------------------------------------------------------------
# CLI validate integration
# ---------------------------------------------------------------------------


class TestValidateIntegration:
    """Verify exokit validate runs without error."""

    @patch("exokit.cli.check_all")
    def test_validate_runs_without_error(self, mock_check_all: MagicMock) -> None:
        """exokit validate should exit 0 with mocked prereqs."""
        mock_check_all.return_value = [
            PrereqResult(name="databricks-cli", passed=True, message="Found", version="0.18.0"),
            PrereqResult(name="uv", passed=True, message="Found", version="0.4.0"),
            PrereqResult(name="node", passed=True, message="Found", version="20.0.0"),
            PrereqResult(name="apx", passed=True, message="Found", version="1.0.0"),
            PrereqResult(name="databricks-auth-DEFAULT", passed=True, message="Configured"),
        ]

        result = runner.invoke(app, ["validate"])
        assert result.exit_code == 0
        assert "5 checks passed" in result.output


# ---------------------------------------------------------------------------
# Full init integration (mock APX + prereqs + questionnaire only)
# ---------------------------------------------------------------------------


class TestFullInitIntegration:
    """Full end-to-end init test — only APX, wizard, and external tools are mocked."""

    @patch("exokit.cli.questionary")
    @patch("exokit.cli.generate_mcp_config")
    @patch("exokit.cli.bundle_skills")
    @patch("exokit.cli.apx_scaffold_app")
    @patch("exokit.cli._run_wizard")
    @patch("exokit.cli.check_all")
    def test_full_init_produces_complete_project(
        self,
        mock_check_all: MagicMock,
        mock_wizard: MagicMock,
        mock_apx: MagicMock,
        mock_bundle: MagicMock,
        mock_mcp: MagicMock,
        mock_questionary: MagicMock,
        tmp_path: Path,
    ) -> None:
        """Full init should produce a complete project structure."""
        mock_check_all.return_value = [
            PrereqResult(name="uv", passed=True, message="OK"),
        ]
        mock_questionary.confirm.return_value.ask.return_value = True
        mock_wizard.return_value = QuestionnaireAnswers(
            company="TestCo",
            industry="retail",
            demo_description="Test demo description",
            catalog="testco_retail",
            warehouse_id="wh999",
            workspace_host="https://test.databricks.net",
            notification_email="test@test.com",
        )
        mock_bundle.return_value = []
        mock_mcp.return_value = tmp_path / ".mcp.json"

        output_dir = tmp_path / "full-project"
        result = runner.invoke(app, ["init", str(output_dir)])

        assert result.exit_code == 0

        # Verify scaffold.generate ran (real function, not mocked)
        assert (output_dir / "CLAUDE.md").exists()
        assert (output_dir / "databricks.yml").exists()
        assert (output_dir / "conf" / "demo_manifest.json").exists()
        assert (output_dir / ".claude" / "agents").is_dir()
        assert (output_dir / "docs").is_dir()
        assert (output_dir / "scripts").is_dir()

        # Verify APX was called
        mock_apx.assert_called_once()

        # Verify manifest content
        manifest_data = json.loads((output_dir / "conf" / "demo_manifest.json").read_text())
        assert manifest_data["meta"]["company"] == "TestCo"
        assert manifest_data["meta"]["industry"] == "retail"


# ---------------------------------------------------------------------------
# Bundle validate readiness
# ---------------------------------------------------------------------------

_VAR_REF_PATTERN = re.compile(r"\$\{var\.(\w+)\}")


class TestBundleValidateReady:
    """Verify the scaffold output would pass ``databricks bundle validate``.

    All ``${var.X}`` references in job and pipeline YAML files must have a
    corresponding variable declaration in ``databricks.yml``.
    """

    def test_all_var_references_declared(self, context: ScaffoldContext, tmp_path: Path) -> None:
        """Every ${var.X} in job/pipeline YAMLs must be declared in databricks.yml."""
        output = tmp_path / "validate-ready"
        generate(context, output)

        # Parse databricks.yml and extract declared variable names
        databricks_yml = output / "databricks.yml"
        assert databricks_yml.exists(), "databricks.yml must exist"
        parsed = yaml.safe_load(databricks_yml.read_text())
        assert parsed is not None
        declared_vars = set(parsed.get("variables", {}).keys())

        # Collect all ${var.X} references from job and pipeline YAMLs
        referenced_vars: set[str] = set()
        for glob_pattern in (
            "resources/jobs/*.yml",
            "resources/pipelines/*.yml",
            "resources/dashboards/*.yml",
        ):
            for yml_file in output.glob(glob_pattern):
                content = yml_file.read_text()
                referenced_vars.update(_VAR_REF_PATTERN.findall(content))

        # Every referenced variable must be declared
        undeclared = referenced_vars - declared_vars
        assert not undeclared, (
            f"Undeclared variables found in YAML resources: {undeclared}. Declared: {declared_vars}"
        )

    def test_setup_catalog_notebook_exists(self, context: ScaffoldContext, tmp_path: Path) -> None:
        """src/data_generation/setup_catalog.py must exist in the output."""
        output = tmp_path / "catalog-check"
        generate(context, output)
        assert (output / "src" / "data_generation" / "setup_catalog.py").exists()

    def test_data_gen_job_references_existing_notebook(
        self, context: ScaffoldContext, tmp_path: Path
    ) -> None:
        """All data generation job tasks must reference existing notebooks."""
        output = tmp_path / "notebook-ref-check"
        generate(context, output)

        job_file = output / "resources" / "jobs" / "data_generation_job.yml"
        assert job_file.exists(), "data_generation_job.yml must exist"
        parsed = yaml.safe_load(job_file.read_text())

        # Navigate to the tasks list
        jobs = parsed.get("resources", {}).get("jobs", {})
        assert jobs, "Job file must have resources.jobs"
        job_def = next(iter(jobs.values()))
        tasks = job_def.get("tasks", [])
        assert len(tasks) >= 2, "Data generation job must have at least 2 tasks"

        # Every task must reference an existing notebook
        for task in tasks:
            notebook_path: str = task["notebook_task"]["notebook_path"]
            # Remove the ${workspace.file_path}/ prefix if present
            relative_path = notebook_path.replace("${workspace.file_path}/", "")
            # The notebook should exist as a .py file
            notebook_file = output / f"{relative_path}.py"
            assert notebook_file.exists(), (
                f"Notebook referenced in task '{task.get('task_key')}' not found: {notebook_file}"
            )


# ---------------------------------------------------------------------------
# Display name duplication
# ---------------------------------------------------------------------------


class TestDisplayNameNoDuplication:
    """Verify no 'FSI FSI' duplication when company is empty."""

    _TEXT_EXTENSIONS = {".md", ".yml", ".json", ".toml", ".sh", ".py", ".txt"}

    @pytest.fixture()
    def no_company_context(self) -> ScaffoldContext:
        """Context with empty company and fsi industry."""
        answers = QuestionnaireAnswers(
            company="",
            industry="fsi",
            demo_description="Generic FSI demo",
            catalog="fsi_demo",
            warehouse_id="wh-test-123",
            workspace_host="https://adb-test.azuredatabricks.net",
            databricks_profile="DEFAULT",
            notification_email="test@example.com",
        )
        return build_context(answers)

    def test_no_fsi_fsi_in_rendered_files(
        self, no_company_context: ScaffoldContext, tmp_path: Path
    ) -> None:
        """No rendered text file should contain 'FSI FSI'."""
        output = tmp_path / "no-dup-test"
        generate(no_company_context, output)

        violations: list[str] = []
        for path in output.rglob("*"):
            if not path.is_file():
                continue
            if path.suffix not in self._TEXT_EXTENSIONS:
                continue
            content = path.read_text(encoding="utf-8", errors="ignore")
            if "FSI FSI" in content:
                violations.append(str(path.relative_to(output)))

        assert not violations, f"Files containing 'FSI FSI': {violations}"

    def test_no_financial_services_fsi_in_rendered_files(
        self, no_company_context: ScaffoldContext, tmp_path: Path
    ) -> None:
        """No rendered text file should contain 'Financial Services FSI'."""
        output = tmp_path / "no-redundancy-test"
        generate(no_company_context, output)

        violations: list[str] = []
        for path in output.rglob("*"):
            if not path.is_file():
                continue
            if path.suffix not in self._TEXT_EXTENSIONS:
                continue
            content = path.read_text(encoding="utf-8", errors="ignore")
            if "Financial Services FSI" in content:
                violations.append(str(path.relative_to(output)))

        assert not violations, f"Files containing 'Financial Services FSI': {violations}"

    def test_demo_title_is_financial_services_demo(
        self, no_company_context: ScaffoldContext, tmp_path: Path
    ) -> None:
        """demo_title should be 'Financial Services Demo' when company is empty."""
        output = tmp_path / "demo-title-test"
        generate(no_company_context, output)

        claude_md = output / "CLAUDE.md"
        assert claude_md.exists()
        content = claude_md.read_text()
        assert "Financial Services Demo" in content, (
            "CLAUDE.md should contain 'Financial Services Demo' as the demo title"
        )

    def test_display_name_is_financial_services(
        self, no_company_context: ScaffoldContext, tmp_path: Path
    ) -> None:
        """CLAUDE.md should use 'Financial Services' as the display name."""
        output = tmp_path / "display-name-test"
        generate(no_company_context, output)

        claude_md = output / "CLAUDE.md"
        assert claude_md.exists()
        content = claude_md.read_text()
        assert "Financial Services" in content, (
            "CLAUDE.md should contain 'Financial Services' as the display name"
        )


# ---------------------------------------------------------------------------
# Complete directory structure
# ---------------------------------------------------------------------------


class TestDirectoryStructureComplete:
    """Verify ALL expected directories and files exist after scaffold."""

    _EXPECTED_PATHS: list[tuple[str, str]] = [
        (".claude/agent-memory/lakehouse-dev/", "dir"),
        (".claude/agent-memory/app-dev/", "dir"),
        (".claude/agent-memory/dashboard-dev/", "dir"),
        (".claude/agent-memory/reviewer/", "dir"),
        ("resources/dashboards/", "dir"),
        ("requirements.txt", "file"),
        ("src/data_generation/setup_catalog.py", "file"),
        ("src/data_generation/CLAUDE.md", "file"),
        ("src/pipelines/CLAUDE.md", "file"),
        ("src/ml/CLAUDE.md", "file"),
        ("scripts/deploy-all.sh", "file"),
        ("scripts/deploy-lakehouse.sh", "file"),
        ("scripts/deploy-app.sh", "file"),
        ("scripts/get_job_errors.py", "file"),
        ("docs/TALK_TRACK.md", "file"),
        ("src/data_generation/structured/generate_sample.py", "file"),
        ("src/pipelines/bronze/sample_customers_bronze.sql", "file"),
        ("src/pipelines/silver/sample_customers_silver.sql", "file"),
        ("src/pipelines/gold/sample_summary_gold.sql", "file"),
        ("src/ml/train_sample_model.py", "file"),
        ("src/dashboards/sample_dashboard.lvdash.json", "file"),
        ("resources/dashboards/sample_dashboard.yml", "file"),
        ("src/dashboards/setup_sample_genie.py", "file"),
    ]

    def test_all_expected_paths_exist(self, context: ScaffoldContext, tmp_path: Path) -> None:
        """Every expected directory and file must exist after scaffold."""
        output = tmp_path / "structure-test"
        generate(context, output)

        missing: list[str] = []
        for rel_path, kind in self._EXPECTED_PATHS:
            target = output / rel_path
            if kind == "dir":
                if not target.is_dir():
                    missing.append(f"{rel_path} (expected directory)")
            else:
                if not target.is_file():
                    missing.append(f"{rel_path} (expected file)")

        assert not missing, "Missing paths in scaffold output:\n" + "\n".join(
            f"  - {m}" for m in missing
        )
