"""AL Language Server implementation for Microsoft Dynamics 365 Business Central.

You can pass the following entries in ``ls_specific_settings["al"]``:
    - al_extension_version: Override the pinned AL VS Code extension version
      downloaded by Serena (default: the bundled Serena version).
"""

import logging
import os
import pathlib
import platform
import re
import stat
import time
from pathlib import Path

from overrides import override

from solidlsp import ls_types
from solidlsp.language_servers.common import quote_windows_path
from solidlsp.ls import DocumentSymbols, LSPFileBuffer, RawDocumentSymbol, SolidLanguageServer
from solidlsp.ls_config import Language, LanguageServerConfig
from solidlsp.ls_types import SymbolKind, UnifiedSymbolInformation
from solidlsp.ls_utils import FileUtils
from solidlsp.lsp_protocol_handler.lsp_types import Definition, DefinitionParams, LocationLink
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings

log = logging.getLogger(__name__)

# Version pinning convention (see eclipse_jdtls.py for the full spec):
#   INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it.
#   DEFAULT_* — bumped on upgrades; goes into a versioned subdir.
INITIAL_AL_EXTENSION_VERSION = "18.0.2242655"
INITIAL_AL_EXTENSION_SHA256 = "3971995e61a59dc4fcce4a65053072a67991ed624a16635c4f2911f12564b2b9"
DEFAULT_AL_EXTENSION_VERSION = "18.0.2242655"
DEFAULT_AL_EXTENSION_SHA256 = "3971995e61a59dc4fcce4a65053072a67991ed624a16635c4f2911f12564b2b9"


def _al_extension_sha(version: str) -> str | None:
    if version == INITIAL_AL_EXTENSION_VERSION:
        return INITIAL_AL_EXTENSION_SHA256
    if version == DEFAULT_AL_EXTENSION_VERSION:
        return DEFAULT_AL_EXTENSION_SHA256
    return None


def _al_extension_dirname(version: str) -> str:
    # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir
    return "al-extension" if version == INITIAL_AL_EXTENSION_VERSION else f"al-extension-{version}"


AL_EXTENSION_ALLOWED_HOSTS = ("marketplace.visualstudio.com",)


class ALLanguageServer(SolidLanguageServer):
    """
    Language server implementation for AL (Microsoft Dynamics 365 Business Central).

    This implementation uses the AL Language Server from the VS Code AL extension
    (ms-dynamics-smb.al). The extension must be installed or available locally.

    Key Features:
    - Automatic download of AL extension from VS Code marketplace if not present
    - Platform-specific executable detection (Windows/Linux/macOS)
    - Special initialization sequence required by AL Language Server
    - Custom AL-specific LSP commands (al/gotodefinition, al/setActiveWorkspace)
    - File opening requirement before symbol retrieval
    - `al_extension_version` to override the bundled AL VS Code extension version
    """

    # Regex pattern to match AL object names like:
    # - 'Table 50000 "TEST Customer"' -> captures 'TEST Customer'
    # - 'Codeunit 50000 CustomerMgt' -> captures 'CustomerMgt'
    # - 'Interface IPaymentProcessor' -> captures 'IPaymentProcessor'
    # - 'Enum 50000 CustomerType' -> captures 'CustomerType'
    # Pattern: <ObjectType> [<ID>] (<QuotedName>|<UnquotedName>)
    _AL_OBJECT_NAME_PATTERN = re.compile(
        r"^(?:Table|Page|Codeunit|Enum|Interface|Report|Query|XMLPort|PermissionSet|"
        r"PermissionSetExtension|Profile|PageExtension|TableExtension|EnumExtension|"
        r"PageCustomization|ReportExtension|ControlAddin|DotNetPackage)"  # Object type
        r"(?:\s+\d+)?"  # Optional object ID
        r"\s+"  # Required space before name
        r'(?:"([^"]+)"|(\S+))$'  # Quoted name (group 1) or unquoted identifier (group 2)
    )

    @staticmethod
    def _extract_al_display_name(full_name: str) -> str:
        """
        Extract the display name from an AL symbol's full name.

        AL Language Server returns symbol names in format:
        - 'Table 50000 "TEST Customer"' -> 'TEST Customer'
        - 'Codeunit 50000 CustomerMgt' -> 'CustomerMgt'
        - 'Interface IPaymentProcessor' -> 'IPaymentProcessor'
        - 'fields' -> 'fields' (non-AL-object symbols pass through unchanged)

        Args:
            full_name: The full symbol name as returned by AL Language Server

        Returns:
            The extracted display name for matching, or the original name if not an AL object

        """
        match = ALLanguageServer._AL_OBJECT_NAME_PATTERN.match(full_name)
        if match:
            # Return quoted name (group 1) or unquoted name (group 2)
            return match.group(1) or match.group(2) or full_name
        return full_name

    def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
        """
        Initialize the AL Language Server.

        Args:
            config: Language server configuration
            logger: Logger instance for debugging
            repository_root_path: Root path of the AL project (must contain app.json)
            solidlsp_settings: Solid LSP settings

        Note:
            The initialization process will automatically:
            1. Check for AL extension in the resources directory
            2. Download it from VS Code marketplace if not found
            3. Extract and configure the platform-specific executable

        """
        # Setup runtime dependencies and get the language server command
        # This will download the AL extension if needed
        cmd = self._setup_runtime_dependencies(config, solidlsp_settings)

        self._project_load_check_supported: bool = True
        """Whether the AL server supports the project load status check request.
        
        Some AL server versions don't support the 'al/hasProjectClosureLoadedRequest'
        custom LSP request. This flag starts as True and is set to False if the
        request fails, preventing repeated unsuccessful attempts.
        """

        super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd=cmd, cwd=repository_root_path), "al", solidlsp_settings)

        # Cache mapping (file_path, line, char) -> original_full_name for hover injection
        self._al_original_names: dict[tuple[str, int, int], str] = {}

    @staticmethod
    def _normalize_path(path: str) -> str:
        """Normalize file path for consistent cache key usage across platforms."""
        return path.replace("\\", "/")

    @classmethod
    def _download_al_extension(cls, url: str, target_dir: str, expected_sha256: str | None) -> bool:
        """
        Download and extract the AL extension from VS Code marketplace.

        The VS Code marketplace packages extensions as .vsix files (which are ZIP archives).
        This method downloads the VSIX file and extracts it to get the language server binaries.

        Note:
            The download includes progress tracking and proper user-agent headers
            to ensure compatibility with the VS Code marketplace.

        """
        try:
            log.info(f"Downloading AL extension from {url}")
            os.makedirs(target_dir, exist_ok=True)
            FileUtils.download_and_extract_archive_verified(
                url,
                target_dir,
                "zip",
                expected_sha256=expected_sha256,
                allowed_hosts=AL_EXTENSION_ALLOWED_HOSTS,
            )
            log.info("AL extension extracted successfully")
            return True

        except Exception as e:
            log.error(f"Error downloading/extracting AL extension: {e}")
            return False

    @classmethod
    def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> str:
        """
        Setup runtime dependencies for AL Language Server and return the command to start the server.

        This method handles the complete setup process:
        1. Checks for existing AL extension installations
        2. Downloads from VS Code marketplace if not found
        3. Configures executable permissions on Unix systems
        4. Returns the properly formatted command string

        The AL Language Server executable is located in different paths based on the platform:
        - Windows: bin/win32/Microsoft.Dynamics.Nav.EditorServices.Host.exe
        - Linux: bin/linux/Microsoft.Dynamics.Nav.EditorServices.Host
        - macOS: bin/darwin/Microsoft.Dynamics.Nav.EditorServices.Host
        """
        system = platform.system()

        # Find existing extension or download if needed
        extension_path = cls._find_al_extension(solidlsp_settings)
        if extension_path is None:
            log.info("AL extension not found on disk, attempting to download...")
            extension_path = cls._download_and_install_al_extension(solidlsp_settings)

        if extension_path is None:
            raise RuntimeError(
                "Failed to locate or download AL Language Server. Please either:\n"
                "1. Set AL_EXTENSION_PATH environment variable to the AL extension directory\n"
                "2. Install the AL extension in VS Code (ms-dynamics-smb.al)\n"
                "3. Ensure internet connection for automatic download"
            )

        # Build executable path based on platform
        executable_path = cls._get_executable_path(extension_path, system)

        if not os.path.exists(executable_path):
            raise RuntimeError(f"AL Language Server executable not found at: {executable_path}")

        # Prepare and return the executable command
        return cls._prepare_executable(executable_path, system)

    @classmethod
    def _find_al_extension(cls, solidlsp_settings: SolidLSPSettings) -> str | None:
        """
        Find AL extension in various locations.

        Search order:
        1. Environment variable (AL_EXTENSION_PATH)
        2. Default download location (~/.serena/ls_resources/al-extension)
        3. VS Code installed extensions

        Returns:
            Path to AL extension directory or None if not found

        """
        # Check environment variable
        env_path = os.environ.get("AL_EXTENSION_PATH")
        if env_path and os.path.exists(env_path):
            log.debug(f"Found AL extension via AL_EXTENSION_PATH: {env_path}")
            return env_path
        elif env_path:
            log.warning(f"AL_EXTENSION_PATH set but directory not found: {env_path}")

        # Check the resolved-version download location (versioned for non-INITIAL, legacy "al-extension" for INITIAL)
        al_settings = solidlsp_settings.get_ls_specific_settings(Language.AL)
        al_extension_version = al_settings.get("al_extension_version", DEFAULT_AL_EXTENSION_VERSION)
        default_path = os.path.join(cls.ls_resources_dir(solidlsp_settings), _al_extension_dirname(al_extension_version), "extension")
        if os.path.exists(default_path):
            log.debug(f"Found AL extension in default location: {default_path}")
            return default_path

        # Search VS Code extensions
        vscode_path = cls._find_al_extension_in_vscode()
        if vscode_path:
            log.debug(f"Found AL extension in VS Code: {vscode_path}")
            return vscode_path

        log.debug("AL extension not found in any known location")
        return None

    @classmethod
    def _download_and_install_al_extension(cls, solidlsp_settings: SolidLSPSettings) -> str | None:
        """
        Download and install AL extension from VS Code marketplace.

        Returns:
            Path to installed extension or None if download failed

        """
        al_settings = solidlsp_settings.get_ls_specific_settings(Language.AL)
        al_extension_version = al_settings.get("al_extension_version", DEFAULT_AL_EXTENSION_VERSION)
        al_extension_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), _al_extension_dirname(al_extension_version))
        al_extension_url = (
            "https://marketplace.visualstudio.com/_apis/public/gallery/publishers/ms-dynamics-smb/"
            f"vsextensions/al/{al_extension_version}/vspackage"
        )

        log.info(f"Downloading AL extension from: {al_extension_url}")

        if cls._download_al_extension(al_extension_url, al_extension_dir, _al_extension_sha(al_extension_version)):
            extension_path = os.path.join(al_extension_dir, "extension")
            if os.path.exists(extension_path):
                log.info("AL extension downloaded and installed successfully")
                return extension_path
            else:
                log.error(f"Download completed but extension not found at: {extension_path}")
        else:
            log.error("Failed to download AL extension from marketplace")

        return None

    @classmethod
    def _get_executable_path(cls, extension_path: str, system: str) -> str:
        """
        Build platform-specific executable path.

        Args:
            extension_path: Path to AL extension directory
            system: Operating system name

        Returns:
            Full path to executable

        """
        if system == "Windows":
            return os.path.join(extension_path, "bin", "win32", "Microsoft.Dynamics.Nav.EditorServices.Host.exe")
        elif system == "Linux":
            return os.path.join(extension_path, "bin", "linux", "Microsoft.Dynamics.Nav.EditorServices.Host")
        elif system == "Darwin":
            return os.path.join(extension_path, "bin", "darwin", "Microsoft.Dynamics.Nav.EditorServices.Host")
        else:
            raise RuntimeError(f"Unsupported platform: {system}")

    @classmethod
    def _prepare_executable(cls, executable_path: str, system: str) -> str:
        """
        Prepare the executable by setting permissions and handling path quoting.

        Args:
            executable_path: Path to the executable
            system: Operating system name
            logger: Logger instance

        Returns:
            Properly formatted command string

        """
        # Make sure executable has proper permissions on Unix-like systems
        if system in ["Linux", "Darwin"]:
            st = os.stat(executable_path)
            os.chmod(executable_path, st.st_mode | stat.S_IEXEC)
            log.debug(f"Set execute permission on: {executable_path}")

        log.info(f"Using AL Language Server executable: {executable_path}")

        # The AL Language Server uses stdio for LSP communication by default
        # Use the utility function to handle Windows path quoting
        return quote_windows_path(executable_path)

    @classmethod
    def _get_language_server_command_fallback(cls) -> str:
        """
        Get the command to start the AL language server.

        Returns:
            Command string to launch the AL language server

        Raises:
            RuntimeError: If AL extension cannot be found

        """
        # Check if AL extension path is configured via environment variable
        al_extension_path = os.environ.get("AL_EXTENSION_PATH")

        if not al_extension_path:
            # Try to find the extension in the current working directory
            # (for development/testing when extension is in the serena repo)
            cwd_path = Path.cwd()
            potential_extension = None

            # Look for ms-dynamics-smb.al-* directories
            for item in cwd_path.iterdir():
                if item.is_dir() and item.name.startswith("ms-dynamics-smb.al-"):
                    potential_extension = item
                    break

            if potential_extension:
                al_extension_path = str(potential_extension)
                log.debug(f"Found AL extension in current directory: {al_extension_path}")
            else:
                # Try to find in common VS Code extension locations
                al_extension_path = cls._find_al_extension_in_vscode()

        if not al_extension_path:
            raise RuntimeError(
                "AL Language Server not found. Please either:\n"
                "1. Set AL_EXTENSION_PATH environment variable to the VS Code AL extension directory\n"
                "2. Install the AL extension in VS Code (ms-dynamics-smb.al)\n"
                "3. Place the extension directory in the current working directory"
            )

        # Determine platform-specific executable
        system = platform.system()
        if system == "Windows":
            executable = os.path.join(al_extension_path, "bin", "win32", "Microsoft.Dynamics.Nav.EditorServices.Host.exe")
        elif system == "Linux":
            executable = os.path.join(al_extension_path, "bin", "linux", "Microsoft.Dynamics.Nav.EditorServices.Host")
        elif system == "Darwin":
            executable = os.path.join(al_extension_path, "bin", "darwin", "Microsoft.Dynamics.Nav.EditorServices.Host")
        else:
            raise RuntimeError(f"Unsupported platform: {system}")

        # Verify executable exists
        if not os.path.exists(executable):
            raise RuntimeError(
                f"AL Language Server executable not found at: {executable}\nPlease ensure the AL extension is properly installed."
            )

        # Make sure executable has proper permissions on Unix-like systems
        if system in ["Linux", "Darwin"]:
            st = os.stat(executable)
            os.chmod(executable, st.st_mode | stat.S_IEXEC)

        log.info(f"Using AL Language Server executable: {executable}")

        # The AL Language Server uses stdio for LSP communication (no --stdio flag needed)
        # Use the utility function to handle Windows path quoting
        return quote_windows_path(executable)

    @classmethod
    def _find_al_extension_in_vscode(cls) -> str | None:
        """
        Try to find AL extension in common VS Code extension locations.

        Returns:
            Path to AL extension directory or None if not found

        """
        home = Path.home()
        possible_paths = []

        # Common VS Code extension paths
        if platform.system() == "Windows":
            possible_paths.extend(
                [
                    home / ".vscode" / "extensions",
                    home / ".vscode-insiders" / "extensions",
                    Path(os.environ.get("APPDATA", "")) / "Code" / "User" / "extensions",
                    Path(os.environ.get("APPDATA", "")) / "Code - Insiders" / "User" / "extensions",
                ]
            )
        else:
            possible_paths.extend(
                [
                    home / ".vscode" / "extensions",
                    home / ".vscode-server" / "extensions",
                    home / ".vscode-insiders" / "extensions",
                ]
            )

        for base_path in possible_paths:
            if base_path.exists():
                log.debug(f"Searching for AL extension in: {base_path}")
                # Look for AL extension directories
                for item in base_path.iterdir():
                    if item.is_dir() and item.name.startswith("ms-dynamics-smb.al-"):
                        log.debug(f"Found AL extension at: {item}")
                        return str(item)

        return None

    @staticmethod
    def _get_initialize_params(repository_absolute_path: str) -> dict:
        """
        Returns the initialize params for the AL Language Server.
        """
        # Ensure we have an absolute path for URI generation
        repository_path = pathlib.Path(repository_absolute_path).resolve()
        root_uri = repository_path.as_uri()

        # AL requires extensive capabilities based on VS Code trace
        initialize_params = {
            "processId": os.getpid(),
            "rootPath": str(repository_path),
            "rootUri": root_uri,
            "capabilities": {
                "workspace": {
                    "applyEdit": True,
                    "workspaceEdit": {
                        "documentChanges": True,
                        "resourceOperations": ["create", "rename", "delete"],
                        "failureHandling": "textOnlyTransactional",
                        "normalizesLineEndings": True,
                    },
                    "configuration": True,
                    "didChangeWatchedFiles": {"dynamicRegistration": True},
                    "symbol": {"dynamicRegistration": True, "symbolKind": {"valueSet": list(range(1, 27))}},
                    "executeCommand": {"dynamicRegistration": True},
                    "didChangeConfiguration": {"dynamicRegistration": True},
                    "workspaceFolders": True,
                },
                "textDocument": {
                    "synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True},
                    "completion": {
                        "dynamicRegistration": True,
                        "contextSupport": True,
                        "completionItem": {
                            "snippetSupport": True,
                            "commitCharactersSupport": True,
                            "documentationFormat": ["markdown", "plaintext"],
                            "deprecatedSupport": True,
                            "preselectSupport": True,
                        },
                    },
                    "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]},
                    "definition": {"dynamicRegistration": True, "linkSupport": True},
                    "references": {"dynamicRegistration": True},
                    "documentHighlight": {"dynamicRegistration": True},
                    "documentSymbol": {
                        "dynamicRegistration": True,
                        "symbolKind": {"valueSet": list(range(1, 27))},
                        "hierarchicalDocumentSymbolSupport": True,
                    },
                    "codeAction": {"dynamicRegistration": True},
                    "formatting": {"dynamicRegistration": True},
                    "rangeFormatting": {"dynamicRegistration": True},
                    "rename": {"dynamicRegistration": True, "prepareSupport": True},
                },
                "window": {
                    "showMessage": {"messageActionItem": {"additionalPropertiesSupport": True}},
                    "showDocument": {"support": True},
                    "workDoneProgress": True,
                },
            },
            "trace": "verbose",
            "workspaceFolders": [{"uri": root_uri, "name": repository_path.name}],
        }

        return initialize_params

    @override
    def _start_server(self) -> None:
        """
        Starts the AL Language Server process and initializes it.

        This method sets up custom notification handlers for AL-specific messages
        before starting the server. The AL server sends various notifications
        during initialization and project loading that need to be handled.
        """

        # Set up event handlers
        def do_nothing(params: str) -> None:
            return

        def window_log_message(msg: dict) -> None:
            log.info(f"AL LSP: window/logMessage: {msg}")

        def publish_diagnostics(params: dict) -> None:
            # AL server publishes diagnostics during initialization
            uri = params.get("uri", "")
            diagnostics = params.get("diagnostics", [])
            log.debug(f"AL LSP: Diagnostics for {uri}: {len(diagnostics)} issues")

        def handle_al_notifications(params: dict) -> None:
            # AL server sends custom notifications during project loading
            log.debug("AL LSP: Notification received")

        # Register handlers for AL-specific notifications
        # These notifications are sent by the AL server during initialization and operation
        self.server.on_notification("window/logMessage", window_log_message)  # Server log messages
        self.server.on_notification("textDocument/publishDiagnostics", publish_diagnostics)  # Compilation diagnostics
        self.server.on_notification("$/progress", do_nothing)  # Progress notifications during loading
        self.server.on_notification("al/refreshExplorerObjects", handle_al_notifications)  # AL-specific object updates

        # Start the server process
        log.info("Starting AL Language Server process")
        self.server.start()

        # Send initialize request
        initialize_params = self._get_initialize_params(self.repository_root_path)

        log.info("Sending initialize request from LSP client to AL LSP server and awaiting response")

        # Send initialize and wait for response
        resp = self.server.send_request("initialize", initialize_params)
        if resp is None:
            raise RuntimeError("AL Language Server initialization failed - no response")

        log.info("AL Language Server initialized successfully")

        # Send initialized notification
        self.server.send_notification("initialized", {})
        log.info("Sent initialized notification")

    @override
    def start(self) -> "ALLanguageServer":
        """
        Start the AL Language Server with special initialization.
        """
        # Call parent start method
        super().start()

        # AL-specific post-initialization
        self._post_initialize_al_workspace()

        # Note: set_active_workspace() can be called manually if needed for multi-workspace scenarios
        # We don't call it automatically to avoid issues during single-workspace initialization

        return self

    def _post_initialize_al_workspace(self) -> None:
        """
        Post-initialization setup for AL Language Server.

        The AL server requires additional setup after initialization:
        1. Send workspace configuration - provides AL settings and paths
        2. Open app.json to trigger project loading - AL uses app.json to identify project structure
        3. Optionally wait for project to be loaded if supported

        This special initialization sequence is unique to AL and necessary for proper
        symbol resolution and navigation features.
        """
        # No sleep needed - server is already initialized

        # Send workspace configuration first
        # This tells AL about assembly paths, package caches, and code analysis settings
        try:
            self.server.send_notification(
                "workspace/didChangeConfiguration",
                {
                    "settings": {
                        "workspacePath": self.repository_root_path,
                        "alResourceConfigurationSettings": {
                            "assemblyProbingPaths": ["./.netpackages"],
                            "codeAnalyzers": [],
                            "enableCodeAnalysis": False,
                            "backgroundCodeAnalysis": "Project",
                            "packageCachePaths": ["./.alpackages"],
                            "ruleSetPath": None,
                            "enableCodeActions": True,
                            "incrementalBuild": False,
                            "outputAnalyzerStatistics": True,
                            "enableExternalRulesets": True,
                        },
                        "setActiveWorkspace": True,
                        "expectedProjectReferenceDefinitions": [],
                        "activeWorkspaceClosure": [self.repository_root_path],
                    }
                },
            )
            log.debug("Sent workspace configuration")
        except Exception as e:
            log.warning(f"Failed to send workspace config: {e}")

        # Check if app.json exists and open it
        # app.json is the AL project manifest file (similar to package.json for Node.js)
        # Opening it triggers AL to load the project and index all AL files
        app_json_path = Path(self.repository_root_path) / "app.json"
        if app_json_path.exists():
            try:
                with open(app_json_path, encoding="utf-8") as f:
                    app_json_content = f.read()

                # Use forward slashes for URI
                app_json_uri = app_json_path.as_uri()

                # Send textDocument/didOpen for app.json
                self.server.send_notification(
                    "textDocument/didOpen",
                    {"textDocument": {"uri": app_json_uri, "languageId": "json", "version": 1, "text": app_json_content}},
                )

                log.debug(f"Opened app.json: {app_json_uri}")
            except Exception as e:
                log.warning(f"Failed to open app.json: {e}")

        # Try to set active workspace (AL-specific custom LSP request)
        # This is optional and may not be supported by all AL server versions
        workspace_uri = Path(self.repository_root_path).resolve().as_uri()
        try:
            result = self.server.send_request(
                "al/setActiveWorkspace",
                {
                    "currentWorkspaceFolderPath": {"uri": workspace_uri, "name": Path(self.repository_root_path).name, "index": 0},
                    "settings": {
                        "workspacePath": self.repository_root_path,
                        "setActiveWorkspace": True,
                    },
                    "timeout": 2,  # Quick timeout since this is optional
                },
            )
            log.debug(f"Set active workspace result: {result}")
        except Exception as e:
            # This is a custom AL request, not critical if it fails
            log.debug(f"Failed to set active workspace (non-critical): {e}")

        # Check if project supports load status check (optional)
        # Many AL server versions don't support this, so we use a short timeout
        # and continue regardless of the result
        self._wait_for_project_load(timeout=3)

    @override
    def is_ignored_dirname(self, dirname: str) -> bool:
        """
        Define AL-specific directories to ignore during file scanning.

        These directories contain generated files, dependencies, or cache data
        that should not be analyzed for symbols.

        Args:
            dirname: Directory name to check

        Returns:
            True if directory should be ignored

        """
        al_ignore_dirs = {
            ".alpackages",  # AL package cache - downloaded dependencies
            ".alcache",  # AL compiler cache - intermediate compilation files
            ".altemplates",  # AL templates - code generation templates
            ".snapshots",  # Test snapshots - test result snapshots
            "out",  # Compiled output - generated .app files
            ".vscode",  # VS Code settings - editor configuration
            "Reference",  # Reference assemblies - .NET dependencies
            ".netpackages",  # .NET packages - NuGet packages for AL
            "bin",  # Binary output - compiled binaries
            "obj",  # Object files - intermediate build artifacts
        }

        # Check parent class ignore list first, then AL-specific
        return super().is_ignored_dirname(dirname) or dirname in al_ignore_dirs

    @override
    def request_full_symbol_tree(self, within_relative_path: str | None = None) -> list[UnifiedSymbolInformation]:
        """
        Override to handle AL's requirement of opening files before requesting symbols.

        The AL Language Server requires files to be explicitly opened via textDocument/didOpen
        before it can provide meaningful symbols. Without this, it only returns directory symbols.
        This is different from most language servers which can provide symbols for unopened files.

        This method:
        1. Scans the repository for all AL files (.al and .dal extensions)
        2. Opens each file with the AL server
        3. Requests symbols for each file
        4. Combines all symbols into a hierarchical tree structure
        5. Closes the files to free resources

        Args:
            within_relative_path: Restrict search to this file or directory path
            include_body: Whether to include symbol body content

        Returns:
            Full symbol tree with all AL symbols from opened files organized by directory

        """
        log.debug("AL: Starting request_full_symbol_tree with file opening")

        # Determine the root path for scanning
        if within_relative_path is not None:
            within_abs_path = os.path.join(self.repository_root_path, within_relative_path)
            if not os.path.exists(within_abs_path):
                raise FileNotFoundError(f"File or directory not found: {within_abs_path}")

            if os.path.isfile(within_abs_path):
                # Single file case - use parent class implementation
                root_nodes = self.request_document_symbols(within_relative_path).root_symbols
                return root_nodes

            # Directory case - scan within this directory
            scan_root = Path(within_abs_path)
        else:
            # Scan entire repository
            scan_root = Path(self.repository_root_path)

        # For AL, we always need to open files to get symbols
        al_files = []

        # Walk through the repository to find all AL files
        for root, dirs, files in os.walk(scan_root):
            # Skip ignored directories
            dirs[:] = [d for d in dirs if not self.is_ignored_dirname(d)]

            # Find AL files
            for file in files:
                if file.endswith((".al", ".dal")):
                    file_path = Path(root) / file
                    # Use forward slashes for consistent paths
                    try:
                        relative_path = str(file_path.relative_to(self.repository_root_path)).replace("\\", "/")
                        al_files.append((file_path, relative_path))
                    except ValueError:
                        # File is outside repository root, skip it
                        continue

        log.debug(f"AL: Found {len(al_files)} AL files")

        if not al_files:
            log.warning("AL: No AL files found in repository")
            return []

        # Collect all symbols from all files
        all_file_symbols: list[UnifiedSymbolInformation] = []

        file_symbol: UnifiedSymbolInformation
        for file_path, relative_path in al_files:
            try:
                # Use our overridden request_document_symbols which handles opening
                log.debug(f"AL: Getting symbols for {relative_path}")
                all_syms, root_syms = self.request_document_symbols(relative_path).get_all_symbols_and_roots()

                if root_syms:
                    # Create a file-level symbol containing the document symbols
                    file_symbol = {
                        "name": file_path.stem,  # Just the filename without extension
                        "kind": SymbolKind.File,
                        "children": root_syms,
                        "location": {
                            "uri": file_path.as_uri(),
                            "relativePath": relative_path,
                            "absolutePath": str(file_path),
                            "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}},
                        },
                    }
                    all_file_symbols.append(file_symbol)
                    log.debug(f"AL: Added {len(root_syms)} symbols from {relative_path}")
                elif all_syms:
                    # If we only got all_syms but not root, use all_syms
                    file_symbol = {
                        "name": file_path.stem,
                        "kind": SymbolKind.File,
                        "children": all_syms,
                        "location": {
                            "uri": file_path.as_uri(),
                            "relativePath": relative_path,
                            "absolutePath": str(file_path),
                            "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}},
                        },
                    }
                    all_file_symbols.append(file_symbol)
                    log.debug(f"AL: Added {len(all_syms)} symbols from {relative_path}")

            except Exception as e:
                log.warning(f"AL: Failed to get symbols for {relative_path}: {e}")

        if all_file_symbols:
            log.debug(f"AL: Returning symbols from {len(all_file_symbols)} files")

            # Group files by directory
            directory_structure: dict[str, list] = {}

            for file_symbol in all_file_symbols:
                rel_path = file_symbol["location"]["relativePath"]
                assert rel_path is not None
                path_parts = rel_path.split("/")

                if len(path_parts) > 1:
                    # File is in a subdirectory
                    dir_path = "/".join(path_parts[:-1])
                    if dir_path not in directory_structure:
                        directory_structure[dir_path] = []
                    directory_structure[dir_path].append(file_symbol)
                else:
                    # File is in root
                    if "." not in directory_structure:
                        directory_structure["."] = []
                    directory_structure["."].append(file_symbol)

            # Build hierarchical structure
            result = []
            repo_path = Path(self.repository_root_path)
            for dir_path, file_symbols in directory_structure.items():
                if dir_path == ".":
                    # Root level files
                    result.extend(file_symbols)
                else:
                    # Create directory symbol
                    dir_symbol = {
                        "name": Path(dir_path).name,
                        "kind": SymbolKind.Package,  # Package/Directory
                        "children": file_symbols,
                        "location": {
                            "relativePath": dir_path,
                            "absolutePath": str(repo_path / dir_path),
                            "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}},
                        },
                    }
                    result.append(dir_symbol)

            return result
        else:
            log.warning("AL: No symbols found in any files")
            return []

    # ===== Phase 1: Custom AL Command Implementations =====

    @override
    def _send_definition_request(self, definition_params: DefinitionParams) -> Definition | list[LocationLink] | None:
        """
        Override to use AL's custom gotodefinition command.

        AL Language Server uses 'al/gotodefinition' instead of the standard
        'textDocument/definition' request. This custom command provides better
        navigation for AL-specific constructs like table extensions, page extensions,
        and codeunit references.

        If the custom command fails, we fall back to the standard LSP method.
        """
        # Convert standard params to AL format (same structure, different method)
        al_params = {"textDocument": definition_params["textDocument"], "position": definition_params["position"]}

        try:
            # Use custom AL command instead of standard LSP
            response = self.server.send_request("al/gotodefinition", al_params)
            log.debug(f"AL gotodefinition response: {response}")
            return response  # type: ignore[return-value]
        except Exception as e:
            log.warning(f"Failed to use al/gotodefinition, falling back to standard: {e}")
            # Fallback to standard LSP method if custom command fails
            return super()._send_definition_request(definition_params)

    def check_project_loaded(self) -> bool:
        """
        Check if AL project closure is fully loaded.

        Uses AL's custom 'al/hasProjectClosureLoadedRequest' to determine if
        the project and all its dependencies have been fully loaded and indexed.
        This is important because AL operations may fail or return incomplete
        results if the project is still loading.

        Returns:
            bool: True if project is loaded, False otherwise

        """
        if not hasattr(self, "server") or not self.server_started:
            log.debug("Cannot check project load - server not started")
            return False

        # Check if we've already determined this request isn't supported
        if not self._project_load_check_supported:
            return True  # Assume loaded if check isn't supported

        try:
            # Use a very short timeout since this is just a status check
            response = self.server.send_request("al/hasProjectClosureLoadedRequest", {"timeout": 1})
            # Response can be boolean directly, dict with 'loaded' field, or None
            if isinstance(response, bool):
                return response
            elif isinstance(response, dict):
                return response.get("loaded", False)
            elif response is None:
                # None typically means the project is still loading
                log.debug("Project load check returned None")
                return False
            else:
                log.debug(f"Unexpected response type for project load check: {type(response)}")
                return False
        except Exception as e:
            # Mark as unsupported to avoid repeated failed attempts
            self._project_load_check_supported = False
            log.debug(f"Project load check not supported by this AL server version: {e}")
            # Assume loaded if we can't check
            return True

    def _wait_for_project_load(self, timeout: int = 3) -> bool:
        """
        Wait for project to be fully loaded.

        Polls the AL server to check if the project is loaded.
        This is optional as not all AL server versions support this check.
        We use a short timeout and continue regardless of the result.

        Args:
            timeout: Maximum time to wait in seconds (default 3s)

        Returns:
            bool: True if project loaded within timeout, False otherwise

        """
        start_time = time.time()
        log.debug(f"Checking AL project load status (timeout: {timeout}s)...")

        while time.time() - start_time < timeout:
            if self.check_project_loaded():
                elapsed = time.time() - start_time
                log.info(f"AL project fully loaded after {elapsed:.1f}s")
                return True
            time.sleep(0.5)

        log.debug(f"Project load check timed out after {timeout}s (non-critical)")
        return False

    def set_active_workspace(self, workspace_uri: str | None = None) -> None:
        """
        Set the active AL workspace.

        This is important when multiple workspaces exist to ensure operations
        target the correct workspace. The AL server can handle multiple projects
        simultaneously, but only one can be "active" at a time for operations
        like symbol search and navigation.

        This uses the custom 'al/setActiveWorkspace' LSP command.

        Args:
            workspace_uri: URI of workspace to set as active, or None to use repository root

        """
        if not hasattr(self, "server") or not self.server_started:
            log.debug("Cannot set active workspace - server not started")
            return

        if workspace_uri is None:
            workspace_uri = Path(self.repository_root_path).resolve().as_uri()

        params = {"workspaceUri": workspace_uri}

        try:
            self.server.send_request("al/setActiveWorkspace", params)
            log.info(f"Set active workspace to: {workspace_uri}")
        except Exception as e:
            log.warning(f"Failed to set active workspace: {e}")
            # Non-critical error, continue operation

    @override
    def request_document_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None) -> DocumentSymbols:
        """
        Override to normalize AL symbol names by stripping object type and ID metadata.

        AL Language Server returns symbol names with full object format like
        'Table 50000 "TEST Customer"', but symbol names should be pure without metadata.
        This follows the same pattern as Java LS which strips type information from names.

        Metadata (object type, ID) is available via the hover LSP method when using
        include_info=True in find_symbol.
        """
        # Normalize path separators for cross-platform compatibility (backslash → forward slash)
        relative_file_path = self._normalize_path(relative_file_path)

        # Get symbols from parent implementation
        document_symbols = super().request_document_symbols(relative_file_path, file_buffer=file_buffer)

        return document_symbols

    def _normalize_symbol_name(self, symbol: RawDocumentSymbol, relative_file_path: str) -> str:
        original_name = symbol["name"]
        normalized_name = self._extract_al_display_name(original_name)

        if symbol.get("kind") in (SymbolKind.Function, SymbolKind.Method) and "(" in normalized_name:
            normalized_name = normalized_name.split("(", 1)[0].strip()

        if symbol.get("kind") == SymbolKind.Method and normalized_name.lower().startswith("action "):
            normalized_name = normalized_name.split(None, 1)[-1].strip()

        if symbol.get("kind") == SymbolKind.Field and ":" in normalized_name:
            normalized_name = normalized_name.split(":", 1)[0].strip()

        # Store original name if it was normalized for an AL object declaration
        # Only store if we have valid position data to avoid false matches at (0, 0)
        if original_name != normalized_name and self._AL_OBJECT_NAME_PATTERN.match(original_name):
            sel_range = symbol.get("selectionRange")
            if sel_range:
                start = sel_range.get("start")  # type: ignore
                if start and "line" in start and "character" in start:
                    line = start["line"]
                    char = start["character"]
                    self._al_original_names[(relative_file_path, line, char)] = original_name

        return normalized_name

    @override
    def _document_symbols_cache_fingerprint(self) -> int:
        normalize_symbol_name_version = 1
        return normalize_symbol_name_version

    @override
    def request_hover(
        self, relative_file_path: str, line: int, column: int, file_buffer: LSPFileBuffer | None = None
    ) -> ls_types.Hover | None:
        """
        Override to inject original AL object name (with type and ID) into hover responses.

        When hovering over a symbol whose name was normalized, we prepend the original
        full name (e.g., 'Table 50000 "TEST Customer"') to the hover content.
        """
        # Normalize path separators for cross-platform compatibility (backslash → forward slash)
        relative_file_path = self._normalize_path(relative_file_path)

        hover = super().request_hover(relative_file_path, line, column, file_buffer=file_buffer)

        if hover is None:
            return None

        # Check if we have an original name for this position
        original_name = self._al_original_names.get((relative_file_path, line, column))

        if original_name and "contents" in hover:
            contents = hover["contents"]
            if isinstance(contents, dict) and "value" in contents:
                # Prepend the original full name to the hover content
                prefix = f"**{original_name}**\n\n---\n\n"
                contents["value"] = prefix + contents["value"]

        return hover
