"""
Comprehensive test suite for HLD Generator.

Tests every module: config, scanner, regex parser, graph builder,
LLM client (fallback mode), renderer, plugin system, and edge cases.
"""

import json
import tempfile
import textwrap
from pathlib import Path

from hld_generator.config import HLDConfig, LANGUAGE_MAP, SKIP_DIRS, SKIP_FILES
from hld_generator.scanner import FileScanner
from hld_generator.parsers.base import ParsedFile, ParsedEntity, ImportInfo, EntityType
from hld_generator.parsers.regex_parser import RegexParser
from hld_generator.graph import GraphBuilder
from hld_generator.llm import LLMClient, LLMAnalysis
from hld_generator.fallback import FallbackAnalyzer
from hld_generator.renderer import HLDRenderer
from hld_generator.plugins import PluginRegistry, HOOK_POINTS


parser = RegexParser()


def _parse(code: str, ext: str, lang: str) -> ParsedFile:
    with tempfile.NamedTemporaryFile(suffix=ext, mode="w", delete=False) as f:
        f.write(textwrap.dedent(code))
        f.flush()
        return parser.parse_file(Path(f.name), lang)


def _make_parsed(path: str, lang: str, imports: list[str] = None,
                 classes: list[str] = None, functions: list[str] = None) -> ParsedFile:
    pf = ParsedFile(file_path=path, language=lang, line_count=10)
    for cls in (classes or []):
        pf.entities.append(ParsedEntity(name=cls, entity_type=EntityType.CLASS, line_start=1, line_end=5))
    for fn in (functions or []):
        pf.entities.append(ParsedEntity(name=fn, entity_type=EntityType.FUNCTION, line_start=1, line_end=5))
    for imp in (imports or []):
        pf.imports.append(ImportInfo(module=imp, names=[]))
    return pf


# ═══════════════════════════════════════════════════════════════════════════════
# CONFIG TESTS
# ═══════════════════════════════════════════════════════════════════════════════


def test_default_config_uses_anthropic():
    cfg = HLDConfig()
    assert cfg.llm_provider == "anthropic"
    assert cfg.llm_model == "claude-sonnet-4-20250514"


def test_openai_config_default_model():
    cfg = HLDConfig(llm_provider="openai")
    assert cfg.llm_model == "gpt-4o"


def test_provider_none_gives_empty_model():
    cfg = HLDConfig(llm_provider="none")
    assert cfg.llm_model == ""


def test_custom_model_overrides_default():
    cfg = HLDConfig(llm_provider="anthropic", llm_model="claude-opus-4-20250514")
    assert cfg.llm_model == "claude-opus-4-20250514"


def test_language_map_has_all_major_languages():
    for ext in [".py", ".js", ".ts", ".java", ".go", ".rs", ".c", ".cpp", ".rb", ".cs", ".kt", ".swift"]:
        assert ext in LANGUAGE_MAP, f"Missing {ext}"


def test_skip_dirs_includes_common_vendor_dirs():
    for d in ["node_modules", ".git", "__pycache__", ".venv", "dist", "build"]:
        assert d in SKIP_DIRS, f"Missing {d}"


def test_skip_files_includes_lock_files():
    for f in ["package-lock.json", "yarn.lock", "Cargo.lock", "go.sum"]:
        assert f in SKIP_FILES, f"Missing {f}"


# ═══════════════════════════════════════════════════════════════════════════════
# SCANNER TESTS
# ═══════════════════════════════════════════════════════════════════════════════


def test_scan_directory_finds_source_files():
    with tempfile.TemporaryDirectory() as d:
        root = Path(d)
        (root / "main.py").write_text("def main(): pass")
        (root / "utils.js").write_text("function util() {}")
        (root / "readme.txt").write_text("not code")

        files = FileScanner(HLDConfig(target=root)).scan()
        exts = {Path(f.relative_path).suffix for f in files}
        assert ".py" in exts
        assert ".js" in exts
        assert ".txt" not in exts


def test_scan_skips_node_modules_and_git():
    with tempfile.TemporaryDirectory() as d:
        root = Path(d)
        (root / "node_modules").mkdir()
        (root / "node_modules" / "lib.js").write_text("module")
        (root / ".git").mkdir()
        (root / ".git" / "config.py").write_text("git internal")
        (root / "app.py").write_text("def app(): pass")

        files = FileScanner(HLDConfig(target=root)).scan()
        assert len(files) == 1
        assert files[0].relative_path == "app.py"


def test_scan_skips_test_files_by_default():
    with tempfile.TemporaryDirectory() as d:
        root = Path(d)
        (root / "app.py").write_text("code")
        (root / "test_app.py").write_text("test")
        (root / "app_test.py").write_text("test")
        (root / "app.spec.js").write_text("test")

        files = FileScanner(HLDConfig(target=root)).scan()
        assert len(files) == 1
        assert files[0].relative_path == "app.py"


def test_scan_includes_test_files_when_configured():
    with tempfile.TemporaryDirectory() as d:
        root = Path(d)
        (root / "app.py").write_text("code")
        (root / "test_app.py").write_text("test")

        files = FileScanner(HLDConfig(target=root, include_tests=True)).scan()
        assert len(files) == 2


def test_scan_respects_max_files_limit():
    with tempfile.TemporaryDirectory() as d:
        root = Path(d)
        for i in range(20):
            (root / f"file{i}.py").write_text(f"x = {i}")

        files = FileScanner(HLDConfig(target=root, max_files=5)).scan()
        assert len(files) == 5


def test_scan_skips_empty_files():
    with tempfile.TemporaryDirectory() as d:
        root = Path(d)
        (root / "empty.py").write_text("")
        (root / "real.py").write_text("x = 1")

        files = FileScanner(HLDConfig(target=root)).scan()
        assert len(files) == 1


def test_scan_single_file():
    with tempfile.NamedTemporaryFile(suffix=".py", mode="w", delete=False) as f:
        f.write("def hello(): pass")
        f.flush()
        files = FileScanner(HLDConfig(target=Path(f.name))).scan()
        assert len(files) == 1
        assert files[0].language == "python"


def test_scan_nonexistent_path_returns_empty():
    files = FileScanner(HLDConfig(target=Path("/nonexistent/path"))).scan()
    assert files == []


def test_scan_skips_oversized_files():
    with tempfile.TemporaryDirectory() as d:
        root = Path(d)
        (root / "small.py").write_text("x = 1")
        (root / "big.py").write_text("x" * 1000)

        files = FileScanner(HLDConfig(target=root, max_file_size=500)).scan()
        assert len(files) == 1
        assert files[0].relative_path == "small.py"


# ═══════════════════════════════════════════════════════════════════════════════
# REGEX PARSER TESTS
# ═══════════════════════════════════════════════════════════════════════════════


def test_python_classes_functions_methods_imports_decorators():
    r = _parse("""\
        # Module doc
        import os
        from pathlib import Path, PurePath
        from .local import helper

        class MyClass:
            def method_one(self):
                pass
            def method_two(self):
                pass

        class AnotherClass:
            pass

        def standalone():
            pass
    """, ".py", "python")

    assert len(r.classes) == 2
    assert r.classes[0].name == "MyClass"
    assert r.classes[1].name == "AnotherClass"

    funcs = [e for e in r.entities if e.entity_type in (EntityType.FUNCTION, EntityType.METHOD)]
    assert len(funcs) == 3

    methods = [e for e in r.entities if e.entity_type == EntityType.METHOD]
    assert len(methods) == 2
    assert all(m.parent == "MyClass" for m in methods)

    assert len(r.imports) == 3
    assert any(imp.is_relative for imp in r.imports)


def test_python_deeply_nested_methods_detected():
    r = _parse("""\
        class Outer:
            class Inner:
                def deep_method(self):
                    pass
            def outer_method(self):
                pass
    """, ".py", "python")

    funcs = [e for e in r.entities if e.entity_type in (EntityType.FUNCTION, EntityType.METHOD)]
    func_names = {f.name for f in funcs}
    assert "deep_method" in func_names
    assert "outer_method" in func_names


def test_python_flask_fastapi_endpoints():
    r = _parse("""\
        from fastapi import FastAPI
        app = FastAPI()

        @app.get("/api/users")
        def list_users(): pass

        @app.post("/api/users")
        def create_user(): pass

        @router.delete("/api/users/{id}")
        def delete_user(): pass
    """, ".py", "python")

    eps = r.endpoints
    assert len(eps) == 3
    routes = {e.name for e in eps}
    assert "GET /api/users" in routes
    assert "POST /api/users" in routes
    assert "DELETE /api/users/{id}" in routes


def test_javascript_classes_functions_arrow_functions_imports_exports():
    r = _parse("""\
        import React from 'react';
        import { useState } from 'react';
        const axios = require('axios');

        class UserService {
            async getUsers() { }
        }

        function formatDate(d) { }
        const processData = async (input) => { };

        export default class AppComponent {}
    """, ".js", "javascript")

    assert len(r.classes) >= 1
    assert any(c.name == "UserService" for c in r.classes)
    func_names = {e.name for e in r.entities if e.entity_type in (EntityType.FUNCTION, EntityType.METHOD)}
    assert "formatDate" in func_names
    assert "processData" in func_names
    assert len(r.imports) >= 2
    assert len(r.exports) >= 1


def test_javascript_express_endpoints():
    r = _parse("""\
        const express = require('express');
        const app = express();

        app.get('/api/health', (req, res) => {});
        router.post('/api/login', handleLogin);
        app.put('/api/users/:id', updateUser);
    """, ".js", "javascript")

    eps = r.endpoints
    assert len(eps) >= 3
    routes = {e.name for e in eps}
    assert "GET /api/health" in routes
    assert "POST /api/login" in routes


def test_typescript_interfaces_and_type_exports():
    r = _parse("""\
        import { Request } from 'express';

        interface UserDTO {
            name: string;
        }

        interface AuthService {
            login(): void;
        }

        export function createApp() {}
        export const config = {};
    """, ".ts", "typescript")

    ifaces = [e for e in r.entities if e.entity_type == EntityType.INTERFACE]
    assert len(ifaces) == 2
    assert len(r.exports) >= 1


def test_java_classes_interfaces_methods_spring_endpoints():
    r = _parse("""\
        package com.example;

        import java.util.List;
        import java.util.Optional;

        public class UserController {
            public List<User> getUsers() { return null; }
            private void validate(User u) { }

            @GetMapping("/api/users")
            public List<User> listAll() { return null; }
        }

        interface UserRepository {
            Optional<User> findById(Long id);
        }
    """, ".java", "java")

    assert len(r.classes) >= 1
    assert len(r.imports) >= 2
    assert len(r.endpoints) >= 1


def test_go_structs_interfaces_functions_methods():
    r = _parse("""\
        package main

        import "fmt"
        import "net/http"

        type Server struct {
            port int
        }

        type Handler interface {
            Handle(r *Request)
        }

        func (s *Server) Start() { }

        func main() {
            fmt.Println("hello")
        }
    """, ".go", "go")

    class_names = {e.name for e in r.entities if e.entity_type == EntityType.CLASS}
    assert "Server" in class_names

    iface_names = {e.name for e in r.entities if e.entity_type == EntityType.INTERFACE}
    assert "Handler" in iface_names

    func_names = {e.name for e in r.entities if e.entity_type in (EntityType.FUNCTION, EntityType.METHOD)}
    assert "main" in func_names
    assert "Start" in func_names


def test_rust_structs_enums_traits_functions_imports():
    r = _parse("""\
        use std::collections::HashMap;
        use crate::models::User;

        struct Config {
            port: u16,
        }

        enum Status {
            Active,
            Inactive,
        }

        trait Validator {
            fn validate(&self) -> bool;
        }

        fn process(data: &str) -> Result<(), Error> {
            Ok(())
        }
    """, ".rs", "rust")

    class_names = {e.name for e in r.entities if e.entity_type == EntityType.CLASS}
    assert "Config" in class_names
    assert "Status" in class_names
    assert "Validator" in class_names
    assert len(r.imports) >= 2
    func_names = {e.name for e in r.entities if e.entity_type in (EntityType.FUNCTION, EntityType.METHOD)}
    assert "process" in func_names


def test_c_structs_functions_includes():
    r = _parse("""\
        #include <stdio.h>
        #include "myheader.h"

        typedef struct node {
            int value;
            struct node *next;
        } Node;

        int main(int argc, char *argv[]) {
            return 0;
        }
    """, ".c", "c")

    assert len(r.imports) >= 2
    func_names = {e.name for e in r.entities if e.entity_type in (EntityType.FUNCTION, EntityType.METHOD)}
    assert "main" in func_names


def test_csharp_classes_methods_using_statements():
    r = _parse("""\
        using System;
        using System.Collections.Generic;

        public class UserService {
            public async Task<List<User>> GetUsers() { }
            private void Validate(User u) { }
        }

        interface IUserRepo {
            User FindById(int id);
        }
    """, ".cs", "csharp")

    assert len(r.classes) >= 1
    assert len(r.imports) >= 2


def test_kotlin_classes_functions_imports():
    r = _parse("""\
        import com.example.model.User
        import kotlinx.coroutines.flow.Flow

        class UserService {
            fun getUsers(): List<User> = emptyList()
        }

        object Singleton {
            fun instance() = this
        }

        interface Repository {
            fun findAll(): Flow<User>
        }
    """, ".kt", "kotlin")

    assert len(r.classes) >= 2  # class + object
    func_names = {e.name for e in r.entities if e.entity_type in (EntityType.FUNCTION, EntityType.METHOD)}
    assert "getUsers" in func_names


def test_unknown_language_uses_generic_patterns():
    r = _parse("""\
        class MyWidget
        function doStuff(x)
        import something
    """, ".dart", "dart")

    # Should still extract something via generic patterns
    class_names = {e.name for e in r.entities if e.entity_type == EntityType.CLASS}
    assert "MyWidget" in class_names


def test_empty_file_produces_no_errors():
    with tempfile.NamedTemporaryFile(suffix=".py", mode="w", delete=False) as f:
        f.write("")
        f.flush()
        # Empty file should be skipped by scanner, but parser should handle it
        r = parser.parse_file(Path(f.name), "python")
        assert r.line_count == 1  # just newline
        assert len(r.errors) == 0


def test_file_summary_extraction():
    r = _parse("""\
        # This is a module
        # It does important things

        import os
    """, ".py", "python")

    assert "This is a module" in r.raw_summary


def test_django_url_pattern_detection():
    r = _parse("""\
        from django.urls import path
        urlpatterns = [
            path('api/users/', views.user_list),
            path('api/users/<int:pk>/', views.user_detail),
        ]
    """, ".py", "python")

    eps = r.endpoints
    assert len(eps) >= 2


def test_spring_boot_endpoint_detection():
    r = _parse("""\
        @RestController
        public class ApiController {
            @GetMapping("/api/health")
            public String health() { return "ok"; }

            @PostMapping("/api/data")
            public void save() { }
        }
    """, ".java", "java")

    eps = r.endpoints
    assert len(eps) >= 2


# ═══════════════════════════════════════════════════════════════════════════════
# GRAPH BUILDER TESTS
# ═══════════════════════════════════════════════════════════════════════════════


def test_graph_builds_nodes_and_edges_from_imports():
    files = [
        _make_parsed("src/main.py", "python", imports=["src.utils"]),
        _make_parsed("src/utils.py", "python"),
    ]
    cg = GraphBuilder().build(files)
    assert len(cg.modules) == 2
    assert cg.graph.has_edge("src/main.py", "src/utils.py")


def test_graph_resolves_dotted_imports():
    files = [
        _make_parsed("app/controllers/user.py", "python", imports=["app.models.user"]),
        _make_parsed("app/models/user.py", "python"),
    ]
    cg = GraphBuilder().build(files)
    assert cg.graph.has_edge("app/controllers/user.py", "app/models/user.py")


def test_graph_resolves_relative_imports():
    files = [
        _make_parsed("pkg/api.py", "python", imports=[".helpers"]),
        _make_parsed("pkg/helpers.py", "python"),
    ]
    cg = GraphBuilder().build(files)
    assert cg.graph.has_edge("pkg/api.py", "pkg/helpers.py")


def test_graph_handles_init_py_package_resolution():
    files = [
        _make_parsed("app/main.py", "python", imports=["app.utils"]),
        _make_parsed("app/utils/__init__.py", "python"),
    ]
    cg = GraphBuilder().build(files)
    assert cg.graph.has_edge("app/main.py", "app/utils/__init__.py")


def test_graph_identifies_entry_points():
    files = [
        _make_parsed("main.py", "python", imports=["utils"]),
        _make_parsed("utils.py", "python"),
    ]
    cg = GraphBuilder().build(files)
    assert "main.py" in cg.entry_points
    assert "utils.py" not in cg.entry_points


def test_graph_identifies_leaf_modules():
    files = [
        _make_parsed("main.py", "python", imports=["utils"]),
        _make_parsed("utils.py", "python"),
    ]
    cg = GraphBuilder().build(files)
    assert "utils.py" in cg.leaf_modules


def test_graph_identifies_hub_modules():
    files = [
        _make_parsed("a.py", "python", imports=["hub"]),
        _make_parsed("b.py", "python", imports=["hub"]),
        _make_parsed("c.py", "python", imports=["hub"]),
        _make_parsed("d.py", "python", imports=["hub"]),
        _make_parsed("hub.py", "python"),
    ]
    cg = GraphBuilder().build(files)
    assert "hub.py" in cg.hub_modules


def test_graph_handles_short_name_collisions_correctly():
    # Two files named "utils.py" in different dirs should NOT clash
    files = [
        _make_parsed("pkg_a/utils.py", "python"),
        _make_parsed("pkg_b/utils.py", "python"),
        _make_parsed("main.py", "python", imports=["pkg_a.utils"]),
    ]
    cg = GraphBuilder().build(files)
    # Should resolve to the specific path, not arbitrary collision
    assert cg.graph.has_edge("main.py", "pkg_a/utils.py")
    assert not cg.graph.has_edge("main.py", "pkg_b/utils.py")


def test_graph_counts_languages_and_lines():
    files = [
        _make_parsed("app.py", "python"),
        _make_parsed("lib.js", "javascript"),
    ]
    cg = GraphBuilder().build(files)
    assert cg.languages["python"] == 1
    assert cg.languages["javascript"] == 1
    assert cg.total_lines == 20


def test_graph_packages_grouping():
    files = [
        _make_parsed("src/models/user.py", "python"),
        _make_parsed("src/models/post.py", "python"),
        _make_parsed("src/api/routes.py", "python"),
    ]
    cg = GraphBuilder().build(files)
    assert "src/models" in cg.packages
    assert len(cg.packages["src/models"]) == 2
    assert "src/api" in cg.packages


def test_graph_summary_is_non_empty():
    files = [_make_parsed("main.py", "python", classes=["App"], functions=["main"])]
    cg = GraphBuilder().build(files)
    summary = GraphBuilder.graph_summary(cg)
    assert "Total modules" in summary
    assert "main.py" in summary


def test_graph_with_no_files_produces_empty_graph():
    cg = GraphBuilder().build([])
    assert len(cg.modules) == 0
    assert cg.graph.number_of_edges() == 0


def test_graph_ignores_unresolvable_external_imports():
    files = [
        _make_parsed("main.py", "python", imports=["flask", "requests", "numpy"]),
    ]
    cg = GraphBuilder().build(files)
    assert cg.graph.number_of_edges() == 0  # no internal matches


def test_graph_resolves_go_module_prefixed_imports():
    files = [
        _make_parsed("cmd/main.go", "go", imports=["MyApp/internal/config", "MyApp/internal/server"]),
        _make_parsed("internal/config/config.go", "go"),
        _make_parsed("internal/server/server.go", "go"),
        _make_parsed("internal/server/handlers.go", "go"),
    ]
    cg = GraphBuilder().build(files)
    # Should resolve Go module imports to files in the target package dir
    assert cg.graph.number_of_edges() >= 2, f"Expected >=2 edges, got {cg.graph.number_of_edges()}"
    # cmd/main.go should import from internal/config and internal/server
    assert any(
        src == "cmd/main.go" and "internal/config" in dst
        for src, dst in cg.graph.edges
    ), "cmd/main.go should have edge to internal/config"
    assert any(
        src == "cmd/main.go" and "internal/server" in dst
        for src, dst in cg.graph.edges
    ), "cmd/main.go should have edge to internal/server"


def test_graph_resolves_go_imports_without_module_prefix_via_suffix_stripping():
    files = [
        _make_parsed("cmd/app/main.go", "go", imports=["SomeModule/pkg/utils"]),
        _make_parsed("pkg/utils/helpers.go", "go"),
    ]
    cg = GraphBuilder().build(files)
    assert cg.graph.number_of_edges() >= 1, f"Expected >=1 edge, got {cg.graph.number_of_edges()}"


def test_graph_ignores_go_stdlib_imports():
    files = [
        _make_parsed("main.go", "go", imports=["fmt", "net/http", "os"]),
    ]
    cg = GraphBuilder().build(files)
    assert cg.graph.number_of_edges() == 0  # stdlib, no internal matches


def test_go_module_prefix_detection_works_correctly():
    files = [
        _make_parsed("cmd/main.go", "go", imports=["MyProject/internal/config", "MyProject/internal/server", "fmt"]),
        _make_parsed("internal/config/config.go", "go"),
        _make_parsed("internal/server/server.go", "go"),
    ]
    prefix, base_dir = GraphBuilder._detect_go_module(files)
    assert prefix == "MyProject", f"Expected 'MyProject', got '{prefix}'"
    assert base_dir == "", f"Expected empty base_dir, got '{base_dir}'"


# ═══════════════════════════════════════════════════════════════════════════════
# LLM CLIENT TESTS (fallback mode, no API key)
# ═══════════════════════════════════════════════════════════════════════════════


def test_fallback_analysis_produces_valid_structure():
    config = HLDConfig(llm_provider="none")
    files = [
        _make_parsed("src/api/routes.py", "python", classes=["Router"], functions=["get_users"]),
        _make_parsed("src/models/user.py", "python", classes=["User"]),
    ]
    cg = GraphBuilder().build(files)
    analysis = LLMClient(config).analyse(cg)

    assert analysis.project_name == "Codebase Analysis"
    assert "python" in analysis.summary.lower()
    assert len(analysis.components) > 0
    assert analysis.mermaid_diagram.startswith("graph TD")
    assert analysis.tech_stack.get("languages") == ["python"]


def test_fallback_mermaid_handles_numeric_starting_filenames():
    config = HLDConfig(llm_provider="none")
    files = [_make_parsed("404.py", "python", classes=["NotFound"])]
    cg = GraphBuilder().build(files)
    analysis = LLMClient(config).analyse(cg)

    # Mermaid IDs must start with a letter
    assert "graph TD" in analysis.mermaid_diagram
    # Should NOT have an ID starting with '4'
    for line in analysis.mermaid_diagram.split("\n"):
        stripped = line.strip()
        if stripped and not stripped.startswith(("graph", "subgraph", "end", "...")):
            # Node definitions should start with a letter
            if "[" in stripped:
                node_id = stripped.split("[")[0].strip()
                if node_id and not node_id.startswith("n_"):
                    assert node_id[0].isalpha(), f"Bad Mermaid ID: {node_id}"


def test_fallback_layer_detection():
    assert FallbackAnalyzer.guess_layer("src/controllers", []) == "API"
    assert FallbackAnalyzer.guess_layer("src/models", []) == "Data Access"
    assert FallbackAnalyzer.guess_layer("src/services", []) == "Business Logic"
    assert FallbackAnalyzer.guess_layer("src/utils", []) == "Shared/Utils"
    assert FallbackAnalyzer.guess_layer("config", []) == "Config"
    assert FallbackAnalyzer.guess_layer("src/api", ["/health"]) == "API"
    assert FallbackAnalyzer.guess_layer("src/components", []) == "UI"
    assert FallbackAnalyzer.guess_layer("deploy", []) == "Infrastructure"


def test_json_response_parsing():
    config = HLDConfig(llm_provider="none")
    client = LLMClient(config)
    raw = json.dumps({
        "project_name": "TestProject",
        "summary": "A test project",
        "architecture_pattern": "MVC",
        "components": [{"name": "API", "purpose": "Routes", "key_modules": [], "layer": "API"}],
        "data_flow": [],
        "external_dependencies": [],
        "tech_stack": {"languages": ["python"]},
        "mermaid_diagram": "graph TD\n    A-->B",
        "observations": ["Looks good"],
    })
    analysis = client._parse_response(raw)
    assert analysis.project_name == "TestProject"
    assert analysis.architecture_pattern == "MVC"
    assert len(analysis.components) == 1
    assert analysis.mermaid_diagram == "graph TD\n    A-->B"


def test_json_parsing_handles_markdown_fences():
    config = HLDConfig(llm_provider="none")
    client = LLMClient(config)
    raw = '```json\n{"project_name": "Fenced", "summary": "test"}\n```'
    analysis = client._parse_response(raw)
    assert analysis.project_name == "Fenced"


def test_json_parsing_handles_malformed_response_gracefully():
    config = HLDConfig(llm_provider="none")
    client = LLMClient(config)
    raw = "This is not JSON at all"
    analysis = client._parse_response(raw)
    assert "JSON parse error" in analysis.error
    assert analysis.summary  # should contain raw[:500]


def test_fallback_mermaid_with_empty_packages():
    cg = GraphBuilder().build([])
    mermaid = FallbackAnalyzer.fallback_mermaid(cg)
    assert "graph TD" in mermaid
    assert "No modules found" in mermaid


# ═══════════════════════════════════════════════════════════════════════════════
# RENDERER TESTS
# ═══════════════════════════════════════════════════════════════════════════════


def test_renderer_creates_all_three_output_files():
    with tempfile.TemporaryDirectory() as d:
        out = Path(d) / "output"
        renderer = HLDRenderer(out)

        analysis = LLMAnalysis(
            project_name="Test",
            summary="A test project",
            mermaid_diagram="graph TD\n    A-->B",
            components=[{"name": "Core", "layer": "Business Logic", "purpose": "Main logic", "key_modules": ["main.py"]}],
        )
        files_list = [_make_parsed("main.py", "python")]
        cg = GraphBuilder().build(files_list)
        output = renderer.render(analysis, cg)

        filenames = {p.name for p in output}
        assert "hld_report.md" in filenames
        assert "architecture.mmd" in filenames
        assert "graph_summary.md" in filenames


def test_hld_report_contains_key_sections():
    with tempfile.TemporaryDirectory() as d:
        out = Path(d) / "output"
        renderer = HLDRenderer(out)

        analysis = LLMAnalysis(
            project_name="MyApp",
            summary="An application",
            architecture_pattern="MVC",
            mermaid_diagram="graph TD\n    A-->B",
            tech_stack={"languages": ["python"], "frameworks": ["FastAPI"]},
            observations=["Well structured"],
        )
        files_list = [_make_parsed("main.py", "python")]
        cg = GraphBuilder().build(files_list)
        renderer.render(analysis, cg)

        report = (out / "hld_report.md").read_text()
        assert "MyApp" in report
        assert "MVC" in report
        assert "FastAPI" in report
        assert "mermaid" in report
        assert "Well structured" in report


def test_mermaid_file_contains_diagram():
    with tempfile.TemporaryDirectory() as d:
        out = Path(d) / "output"
        renderer = HLDRenderer(out)

        analysis = LLMAnalysis(mermaid_diagram="graph TD\n    A-->B")
        cg = GraphBuilder().build([])
        renderer.render(analysis, cg)

        mmd = (out / "architecture.mmd").read_text()
        assert mmd == "graph TD\n    A-->B"


def test_renderer_handles_empty_analysis_gracefully():
    with tempfile.TemporaryDirectory() as d:
        out = Path(d) / "output"
        renderer = HLDRenderer(out)
        analysis = LLMAnalysis()
        cg = GraphBuilder().build([])
        output = renderer.render(analysis, cg)
        assert len(output) == 3


# ═══════════════════════════════════════════════════════════════════════════════
# PLUGIN SYSTEM TESTS
# ═══════════════════════════════════════════════════════════════════════════════


def test_register_and_retrieve_parser_plugin():
    reg = PluginRegistry()

    @reg.register_parser("lua")
    class LuaParser:
        def parse_file(self, file_path, language):
            return ParsedFile(file_path=str(file_path), language=language)

    assert reg.has_parser("lua")
    p = reg.get_parser("lua")
    assert p is not None


def test_register_and_retrieve_llm_provider_plugin():
    reg = PluginRegistry()

    @reg.register_llm_provider("ollama")
    class OllamaProvider:
        def call(self, context, system_prompt):
            return '{"project_name": "test"}'

    assert reg.has_llm_provider("ollama")
    assert "ollama" in reg.available_llm_providers


def test_register_and_retrieve_renderer_plugin():
    reg = PluginRegistry()

    @reg.register_renderer("html")
    class HTMLRenderer:
        def render(self, analysis, code_graph, output_dir):
            return []

    assert reg.has_renderer("html")


def test_hook_registration_and_execution():
    reg = PluginRegistry()
    calls = []

    @reg.register_hook("post_parse")
    def add_marker(parsed_files):
        calls.append("post_parse")
        return parsed_files

    result = reg.run_hooks("post_parse", ["file1", "file2"])
    assert calls == ["post_parse"]
    assert result == ["file1", "file2"]


def test_hook_pipeline_chains_results():
    reg = PluginRegistry()

    @reg.register_hook("post_parse")
    def first(data):
        return data + ["added_by_first"]

    @reg.register_hook("post_parse")
    def second(data):
        return data + ["added_by_second"]

    result = reg.run_hooks("post_parse", ["original"])
    assert result == ["original", "added_by_first", "added_by_second"]


def test_hook_with_none_return_passes_through():
    reg = PluginRegistry()

    @reg.register_hook("post_scan")
    def noop(data):
        return None  # should pass through

    result = reg.run_hooks("post_scan", [1, 2, 3])
    assert result == [1, 2, 3]


def test_invalid_hook_point_raises_value_error():
    reg = PluginRegistry()
    try:
        @reg.register_hook("invalid_point")
        def bad(data):
            pass
        assert False, "Should have raised"
    except ValueError as e:
        assert "invalid_point" in str(e)


def test_endpoint_pattern_registration():
    reg = PluginRegistry()
    reg.register_endpoint_pattern(r'@MyRoute\("([^"]+)"\)', name="MyFramework")
    assert len(reg.custom_endpoint_patterns) == 1


def test_language_extension_registration():
    reg = PluginRegistry()
    reg.register_language(".vue", "javascript")
    reg.register_language("svelte", "javascript")
    assert reg.custom_language_map[".vue"] == "javascript"
    assert reg.custom_language_map[".svelte"] == "javascript"


def test_plugin_loading_from_directory():
    reg = PluginRegistry()
    with tempfile.TemporaryDirectory() as d:
        plugin_dir = Path(d)
        (plugin_dir / "test_plugin.py").write_text(textwrap.dedent("""\
            import sys, os
            sys.path.insert(0, os.environ.get("HLD_PATH", "."))
            from hld_generator.plugins import registry
            registry.register_language(".xyz", "custom_lang")
        """))

        import os
        os.environ["HLD_PATH"] = str(Path(__file__).resolve().parent.parent)
        n = reg.load_plugins_from_dir(plugin_dir)
        # The plugin writes to the global registry, not this local one
        # So just verify it loaded without errors
        assert n == 1


def test_plugin_summary():
    reg = PluginRegistry()
    assert reg.summary() == "No plugins registered"

    @reg.register_parser("test_lang")
    class TP:
        def parse_file(self, fp, lang): pass

    assert "test_lang" in reg.summary()


def test_all_hook_points_are_valid():
    expected = {
        "pre_scan", "post_scan", "pre_parse", "post_parse",
        "pre_graph", "post_graph", "pre_llm", "post_llm",
        "pre_render", "post_render",
    }
    assert HOOK_POINTS == expected


# ═══════════════════════════════════════════════════════════════════════════════
# EDGE CASE / INTEGRATION TESTS
# ═══════════════════════════════════════════════════════════════════════════════


def test_mermaid_safe_id_helper():
    assert FallbackAnalyzer.safe_mermaid_id("src/main.py") == "src_main_py"
    assert FallbackAnalyzer.safe_mermaid_id("404.py").startswith("n_")
    assert FallbackAnalyzer.safe_mermaid_id("123_file.ts").startswith("n_")
    assert FallbackAnalyzer.safe_mermaid_id("(root)") == "root"
    assert FallbackAnalyzer.safe_mermaid_id("my-pkg/file.js") == "my_pkg_file_js"


def test_multi_language_project_graph():
    files = [
        _make_parsed("backend/app.py", "python", imports=["backend.models"]),
        _make_parsed("backend/models.py", "python"),
        _make_parsed("frontend/App.tsx", "typescript", imports=["./utils"]),
        _make_parsed("frontend/utils.ts", "typescript"),
    ]
    cg = GraphBuilder().build(files)
    assert cg.languages["python"] == 2
    assert cg.languages["typescript"] == 2
    assert cg.graph.number_of_edges() >= 1


def test_parsed_file_properties_filter_correctly():
    pf = ParsedFile(file_path="test.py", language="python")
    pf.entities = [
        ParsedEntity(name="MyClass", entity_type=EntityType.CLASS, line_start=1, line_end=10),
        ParsedEntity(name="IFace", entity_type=EntityType.INTERFACE, line_start=12, line_end=15),
        ParsedEntity(name="Rec", entity_type=EntityType.STRUCT, line_start=16, line_end=20),
        ParsedEntity(name="func1", entity_type=EntityType.FUNCTION, line_start=21, line_end=25),
        ParsedEntity(name="meth1", entity_type=EntityType.METHOD, line_start=5, line_end=8),
        ParsedEntity(name="GET /api", entity_type=EntityType.ENDPOINT, line_start=30, line_end=30),
    ]
    assert len(pf.classes) == 3   # CLASS + INTERFACE + STRUCT
    assert len(pf.functions) == 2  # FUNCTION + METHOD
    assert len(pf.endpoints) == 1


def test_very_large_function_list_is_truncated_in_module_details():
    config = HLDConfig(llm_provider="none", max_tokens_per_chunk=100)
    files = []
    for i in range(50):
        files.append(_make_parsed(f"mod_{i}.py", "python", functions=[f"fn_{j}" for j in range(10)]))
    cg = GraphBuilder().build(files)
    details = LLMClient(config)._build_module_details(cg)
    assert "truncated" in details


def test_scanner_handles_symlinks_gracefully():
    with tempfile.TemporaryDirectory() as d:
        root = Path(d)
        (root / "real.py").write_text("x = 1")
        try:
            (root / "link.py").symlink_to(root / "real.py")
        except OSError:
            return  # symlinks not supported on this OS
        files = FileScanner(HLDConfig(target=root)).scan()
        assert len(files) >= 1


def test_php_parsing():
    r = _parse("""\
        <?php
        use App\\Models\\User;

        class UserController {
            function index() {}
            function store() {}
        }

        function helper_fn() {}
    """, ".php", "php")

    assert len(r.classes) >= 1
    func_names = {e.name for e in r.entities if e.entity_type in (EntityType.FUNCTION, EntityType.METHOD)}
    assert "index" in func_names


def test_scala_parsing():
    r = _parse("""\
        import scala.collection.mutable

        case class User(name: String)

        object UserService {
            def findAll(): List[User] = List()
        }

        trait Repository {
            def save(entity: Any): Unit
        }
    """, ".scala", "scala")

    class_names = {e.name for e in r.entities if e.entity_type == EntityType.CLASS}
    assert "User" in class_names
    assert "UserService" in class_names
    assert "Repository" in class_names


# ═══════════════════════════════════════════════════════════════════════════════
# HTML RENDERER TESTS
# ═══════════════════════════════════════════════════════════════════════════════


def test_html_renderer_produces_output_file():
    from hld_generator.html_renderer import HTMLRenderer
    with tempfile.TemporaryDirectory() as d:
        out = Path(d) / "output"
        renderer = HTMLRenderer(out)
        analysis = LLMAnalysis(
            project_name="TestProject",
            summary="A test project",
            mermaid_diagram="graph TD\n    A-->B",
            components=[{"name": "Core", "layer": "API", "purpose": "Routes", "key_modules": ["main.py"]}],
        )
        cg = GraphBuilder().build([_make_parsed("pkg/main.py", "python")])
        files = renderer.render(analysis, cg)

        assert len(files) == 1
        assert files[0].name == "hld_viewer.html"
        assert files[0].exists()
        html = files[0].read_text()
        assert "TestProject" in html
        assert "mermaid" in html.lower()
        assert "svgPanZoom" in html or "svg-pan-zoom" in html
        assert "diagramDetailed" in html
        assert "diagramPackage" in html


def test_package_diagram_collapses_files_into_packages():
    from hld_generator.html_renderer import HTMLRenderer
    files = [
        _make_parsed("api/routes.py", "python", imports=["services.auth"]),
        _make_parsed("api/views.py", "python"),
        _make_parsed("services/auth.py", "python"),
        _make_parsed("services/user.py", "python"),
    ]
    cg = GraphBuilder().build(files)
    diagram = HTMLRenderer._build_package_diagram(cg)

    # Should have package-level nodes, not file-level
    assert "api" in diagram.lower()
    assert "services" in diagram.lower()
    # Should NOT have individual file names as node IDs
    assert "routes_py" not in diagram
    assert "auth_py" not in diagram
    # Should have at least one inter-package edge
    assert "-->" in diagram


def test_package_diagram_deduplicates_edges():
    from hld_generator.html_renderer import HTMLRenderer
    files = [
        _make_parsed("api/a.py", "python", imports=["models.user"]),
        _make_parsed("api/b.py", "python", imports=["models.order"]),
        _make_parsed("models/user.py", "python"),
        _make_parsed("models/order.py", "python"),
    ]
    cg = GraphBuilder().build(files)
    diagram = HTMLRenderer._build_package_diagram(cg)

    # api → models should appear only once (deduplicated)
    lines = [ln.strip() for ln in diagram.split("\n") if "-->" in ln]
    edge_pairs = [(ln.split("-->")[0].strip(), ln.split("-->")[1].strip()) for ln in lines]
    assert len(edge_pairs) == len(set(edge_pairs)), f"Duplicate edges found: {edge_pairs}"


def test_html_report_escapes_special_characters():
    from hld_generator.html_renderer import HTMLRenderer
    with tempfile.TemporaryDirectory() as d:
        renderer = HTMLRenderer(Path(d))
        analysis = LLMAnalysis(
            project_name='<script>alert("xss")</script>',
            summary='Test with "quotes" & <tags>',
        )
        cg = GraphBuilder().build([])
        files = renderer.render(analysis, cg)
        html = files[0].read_text()
        # XSS payload should be escaped
        assert "<script>alert" not in html
        assert "&lt;script&gt;" in html
        assert "&amp;" in html


def test_html_renderer_handles_empty_analysis():
    from hld_generator.html_renderer import HTMLRenderer
    with tempfile.TemporaryDirectory() as d:
        renderer = HTMLRenderer(Path(d))
        analysis = LLMAnalysis()
        cg = GraphBuilder().build([])
        files = renderer.render(analysis, cg)
        assert len(files) == 1
        html = files[0].read_text()
        assert "<!DOCTYPE html>" in html


def test_cli_html_flag_exists():
    """Verify the --html flag is accepted by the CLI parser."""
    from hld_generator.cli import build_parser
    p = build_parser()
    args = p.parse_args([".", "--html"])
    assert args.html is True
    args2 = p.parse_args(["."])
    assert args2.html is False
