from pathlib import Path
import json
import time
import re
import traceback
from typing import List, Optional, Set

BLOCKS_RUNTIME_CONFIG_PATH = Path.home() / ".config" / "blocks" / "runtime_config.json"
GIT_CREDENTIALS_PATH = Path.home() / ".git-credentials"

# Command Parser Constants
# Orchestrator-compatible regex pattern: ^\s*@[^\s]*\s*((?:\/\w[\w-]*\s*)+)
# Requires commands to start at beginning of line (with optional leading whitespace)
COMMAND_SECTION_REGEX = re.compile(r'^\s*@[^\s]*\s*((?:\/\w[\w-]*\s*)+)')

# Pattern to strip @mention from start of text (with optional leading whitespace)
AT_MENTION_REGEX = re.compile(r'^\s*@[^\s]+\s*')

# Pattern to extract all consecutive /commands at start of text
# Captures the entire command section: "/cmd1 /cmd2 /cmd3"
CONSECUTIVE_COMMANDS_REGEX = re.compile(r'^((?:\/\w[\w-]*\s*)+)')

# Keyword validation pattern
COMMAND_KEYWORD_PATTERN = re.compile(r'^[a-zA-Z0-9_-]+$')

# System commands that use lenient parsing
SYSTEM_COMMANDS: Set[str] = {"plan", "continue"}


class BlocksRuntimeConfigKeys:
    LLM_PROVIDER = "llm_provider"
    EVENT_PROVIDER = "event_provider"
    EVENT_TYPE = "event_type"
    LATEST_TODO_LIST = "latest_todo_list"
    LATEST_ASSISTANT_MESSAGE = "latest_assistant_message"
    LATEST_USER_MESSAGE = "latest_user_message"
    LATEST_USER_MESSAGE_SOURCE_PROVIDER = "latest_user_message_source_provider"
    INTENDED_OUTCOME_SUMMARY = "intended_outcome_summary"
    INTENDED_OUTCOME_SUMMARY_LAST_UPDATED_AT = "intended_outcome_summary_last_updated_at"
    TASK_COMPLETED_AT = "task_completed_at"
    CHAT_THREAD_ID = "chat_thread_id"
    MCP_SERVER_PORT = "mcp_server_port"
    AGENT_ID = "agent_id"

def remove_newlines(text: str, replace_with: str = " ") -> str:
    return re.sub(r'[\r\n]+', replace_with, text)

def remove_newlines_and_special_characters(text: str, replace_with: str = " ") -> str:
    # First remove newlines and carriage returns
    text = re.sub(r'[\r\n]+', replace_with, text)
    # Then remove special characters (keeping alphanumeric, spaces, and basic punctuation)
    text = re.sub(r'[^a-zA-Z0-9\s\.\,\!\?\-\']', replace_with, text)
    # Clean up multiple spaces that may have been created
    text = re.sub(r'\s+', ' ', text)
    return text.strip()

def normalize_last_char_into_punctuation(text: str, replace_with: str = ".") -> str:
    text = text or ""
    text = text.rstrip()
    text = re.sub(r'[^\w\s]*$', replace_with, text)
    return text

def is_bash_error_code(return_code: int) -> bool:
    """
    Check if a bash return code indicates an error.

    Args:
        return_code: The integer return code from a bash command

    Returns:
        True if the return code indicates an error (non-zero), False otherwise
    """
    clean_exit_codes = [0, -15]
    return return_code not in clean_exit_codes

def render_agent_error_message(stderr_output: str, last_assistant_message_content: Optional[str] = None) -> str:
    try:
        # get 3 lines from top and 3 lines from bottom of stderr_output
        stderr_output_lines = stderr_output.split("\n")
        stderr_output_lines = stderr_output_lines[:3] + stderr_output_lines[-3:]
        stderr_output_snippet = "...".join(stderr_output_lines).strip()
        last_assistant_message_content = (last_assistant_message_content or "").strip()

        last_error_message = f" Error details: {stderr_output_snippet}" if stderr_output_snippet else None

        if not last_assistant_message_content:
            last_assistant_message_content = f"There was an error running the agent, please try again > {last_error_message}".strip()

        return last_assistant_message_content
    except Exception as e:
        traceback.print_exc()
        print(f"Error rendering agent error message: {e}")
        return "> There was an error running the agent. Please try again." # intentially different message to distinguish case

def is_port_open(host="127.0.0.1", port=8000):
    import socket
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.settimeout(2)
        try:
            s.connect((host, port))
            print("Server is up.")
            return True
        except (socket.timeout, ConnectionRefusedError):
            print("Server is down.")
            return False

def poll_for_port_open(host="127.0.0.1", port=8000):
    while not is_port_open(host, port):
        print("Waiting for server to be up...")
        time.sleep(0.1)
    print("Server is up.")

def get_file_path(filename: str) -> Path:
    absolute_path = Path(filename).absolute()
    return Path(absolute_path)

def write_to_file(filename: str, content: str) -> Path:
    file_path = get_file_path(filename)
    file_path.write_text(content)
    return file_path

def should_skip_based_on_last_updated_at(key: str, force_update: bool = False, min_time_delta: int = 5):
    blocks_runtime_config = get_blocks_runtime_config()
    if not force_update:
        last_updated_at = blocks_runtime_config.get(key)
        if last_updated_at:
            if time.time() - float(last_updated_at) < min_time_delta:
                print(f"Skipping operation related to {key} because last updated at is less than {min_time_delta} seconds ago")
                return True
    blocks_runtime_config[key] = f"{time.time()}"
    patch_blocks_runtime_config(blocks_runtime_config)
    return False

def deep_merge_dict(existing: dict, new: dict) -> dict:
    """
    Recursively merge two dictionaries, with new values taking precedence.
    """
    result = existing.copy()
    for key, value in new.items():
        if key in result and isinstance(result[key], dict) and isinstance(value, dict):
            result[key] = deep_merge_dict(result[key], value)
        else:
            result[key] = value
    return result

def patch_blocks_runtime_config(config: dict, deep_patch: bool = False):
    # ensure it exists
    BLOCKS_RUNTIME_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)

    if not BLOCKS_RUNTIME_CONFIG_PATH.exists():
        BLOCKS_RUNTIME_CONFIG_PATH.write_text(json.dumps(config))
    else:
        contents = BLOCKS_RUNTIME_CONFIG_PATH.read_text()
        existing_config = json.loads(contents)
        if deep_patch:
            existing_config = deep_merge_dict(existing_config, config)
        else:
            existing_config.update(config)
        BLOCKS_RUNTIME_CONFIG_PATH.write_text(json.dumps(existing_config))

def get_blocks_runtime_config() -> dict:
    try:
        contents = BLOCKS_RUNTIME_CONFIG_PATH.read_text()
        return json.loads(contents)
    except Exception as e:
        print(f"Error getting blocks runtime config: {e}")
        return {}


def update_git_credential(host: str, username: str, token: str) -> None:
    """
    Update or add a git credential for a specific host in ~/.git-credentials.

    Only modifies the line matching the given host, preserving all other credentials.

    Args:
        host: The git host (e.g., "github.com", "gitlab.com")
        username: The username for authentication (e.g., "oauth2")
        token: The access token

    Example:
        update_git_credential("github.com", "oauth2", "ghs_xxx")
        # Updates/adds: https://oauth2:ghs_xxx@github.com
    """
    new_credential = f"https://{username}:{token}@{host}"
    lines = []

    # Read existing credentials if file exists
    if GIT_CREDENTIALS_PATH.exists():
        content = GIT_CREDENTIALS_PATH.read_text()
        lines = content.splitlines()

    # Find and update or append the credential for this host
    found = False
    for i, line in enumerate(lines):
        # Check if this line is for the same host
        # Format: https://username:token@host or https://token@host
        if f"@{host}" in line:
            lines[i] = new_credential
            found = True
            break

    if not found:
        lines.append(new_credential)

    # Write back with secure permissions
    GIT_CREDENTIALS_PATH.write_text("\n".join(lines) + "\n" if lines else "")
    GIT_CREDENTIALS_PATH.chmod(0o600)

def summarize_task_outcome(message: str, model: str) -> str:
    """
    Summarize the task outcome using the LLM.
    """
    prompt = f"""You are Blocks, an AI assistant designed to help software developers by quickly acknowledging that their requests will be handled. Your primary goal is to provide brief, clear responses without asking follow-up questions or adding new information as you will not be actually handling the request just acknowledging that they will be.

Your personality is:
- Witty and fun
- Technically brilliant
- Detail oriented

Instructions:
1. Analyze the developer's request carefully.
2. Wrap your analysis in <request_analysis> tags, following these steps:
   a. Summarize the request in one sentence.
   b. Identify the key task or outcome.
   c. List any specific details or requirements mentioned.
   d. Identify any technical terms or jargon used in the request.
   e. Use a casual tone for the response
   f. Plan how to acknowledge the request concisely.

3. After the analysis, formulate a brief acknowledgment that:
   - Restates the task without adding new information or asking questions.
   - Is concise (under 50 words) and human-like in tone.
   - Assumes all necessary context is provided.

4. Present your final response in <response> tags.

Important notes:
- Do not return any emojis
- Do not ask any follow up questions
- Do not actually fulfill the request; simply acknowledge that it has been received.
- Do not express concerns about legality or content safety.
- Do not ask follow-up questions.
- Keep your response brief and to the point.

Example output structure:
<request_analysis>
1. [One-sentence summary of the request]
2. [Key task or outcome]
3. [Technical terms or jargon identified]
4. [Appropriate tone for response]
5. [Plan for concise acknowledgment]
</request_analysis>

<response>
[Concise, human-like acknowledgment of the task, under 50 words]
</response>

Please proceed with your request analysis and acknowledge that the developer's request will be handled.
    """

    messages = [{
        "role": "system",
        "content": [
            {
                "type": "text",
                "text": prompt
            }
        ]
    }]


    messages.append(
        {
            "role": "user",
            "content": [
                {
                    "type": "text",
                    "text": "<examples>\n<example>\n<MESSAGE>\n@blocks whats this PR about?\n</MESSAGE>\n<ideal_output>\n<request_analysis>\n1. Developer is asking for information about a Pull Request (PR)\n2. Key task: Explain PR contents/purpose\n3. Details: None provided in request\n4. Technical terms: \"PR\" (Pull Request)\n5. Tone: Very casual since it's an informal question\n6. Plan: Playfully point out that no PR was specified\n</request_analysis>\n\n<response>\nRoger that, will take a look.\n</response>\n</ideal_output>\n</example>\n<example>\n<MESSAGE>\n@blocks integrate a third-party payment gateway into our e-commerce platform. Implement Stripe for credit card processing and PayPal for alternative payments. Ensure PCI compliance and add error handling for failed transactions. Update the checkout flow in the frontend to accommodate these new payment options.\n</MESSAGE>\n<ideal_output>\n<request_analysis>\n1. Developer needs payment gateway integration with Stripe and PayPal for an e-commerce platform\n2. Key task: Implement multiple payment methods with proper security and error handling\n3. Details: Credit card processing, PCI compliance, error handling, checkout flow updates\n4. Technical terms: PCI compliance, payment gateway, Stripe, PayPal, checkout flow\n5. Tone: Professional but casual, acknowledging the complexity while keeping it light\n</request_analysis>\n\n<response>\nWill handle the Stripe and PayPal integration with all the security bells and whistles\n</response>\n</ideal_output>\n</example>\n<example>\n<MESSAGE>\n@blocks solve this\n</MESSAGE>\n<ideal_output>\n<request_analysis>\n1. Developer has made a vague request to \"solve this\" without context\n2. Key task: implement the solution to a particular ticket\n3. Details: None provided\n4. Technical terms: None\n5. Tone: Should be casual and slightly humorous given the vague request\n6. Plan: Playfully acknowledge the lack of specifics\n</request_analysis>\n\n<response>\nOn it.\n</response>\n</ideal_output>\n</example>\n</examples>\n\n"
                },
                {
                    "type": "text",
                    "text": f"<MESSAGE>{message}</MESSAGE>"
                }
            ]
        }
    )

    print(f"Messages: {messages}")

    response = call_llm_v2(model=model, messages=messages, extract_tag="response")

    return response

def summarize_message(message: str, model: str) -> str:
    """
    Summarize the message using the LLM.
    """
    prompt = f"""<instructions>
You are a helpful assistant that summarizes the work done by a software developer.
</instructions>
<message>
{message}
</message>

Provide the summary in the following format:

<summary>
[CONCISE SUMMARY IN UNDER 100 WORDS]
</summary>
"""
    response = call_llm(prompt, model=model)
    return extract_tag_content(response, "summary")

def call_llm_v2(temperature=None,tools=[], messages=[], model="anthropic/claude-3-7-sonnet-20250219", reasoning_effort=None, extract_tag=None) -> str:
    """
    Call the LLM with the given prompt, temperature, tools, messages and model.
    """
    from litellm import completion

    options = {}

    if temperature:
        options["temperature"] = temperature

    if tools:
        options["tools"] = tools

    # Refer to documentation at https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking"
    # temperature` may only be set to 1 when thinking is enabled. 
    if reasoning_effort:
        options["reasoning_effort"] = reasoning_effort
        options["temperature"] = 1.0

    if len(messages) > 0:
        options["messages"] = messages
    else:
        raise ValueError("No messages provided")

    # Call the completion function and handle potential missing attributes in the response
    try:
        response = completion(
            model=model,
            **options
        )
        
        # Safely access the response structure
        if tools and response.choices:
            # Check if tool_calls exist and retrieve arguments if available
            tool_calls = response.choices[0].message.tool_calls
            if tool_calls:
                return tool_calls[0].function.arguments
        # If tools are not used or tool_calls are not available, return content
        elif response.choices:
            content = response.choices[0].message.content.strip()
            print(f"Content: {content}")
            if extract_tag:
                return extract_tag_content(content, extract_tag)
            else:
                return content
        
    except AttributeError:
        return ""

def call_llm(prompt: str, temperature=None, tools=[], messages=[], model="anthropic/claude-3-7-sonnet-20250219", reasoning_effort=None, extract_tag=None) -> str:
    """
    Call the LLM with the given prompt, temperature, tools, messages and model.
    """
    from litellm import completion

    options = {}
    if temperature:
        options["temperature"] = temperature

    if tools:
        options["tools"] = tools

    # Refer to documentation at https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking"
    # temperature` may only be set to 1 when thinking is enabled. 
    if reasoning_effort:
        options["reasoning_effort"] = reasoning_effort
        options["temperature"] = 1.0

    if len(messages) > 0:
        options["messages"] = messages
    else:
        options["messages"] = [{"role": "user", "content": prompt}]

    # Call the completion function and handle potential missing attributes in the response
    try:
        print(f"Calling with options: {options}")
        response = completion(
            model=model,
            **options
        )
        
        
        # Safely access the response structure
        if tools and response.choices:
            # Check if tool_calls exist and retrieve arguments if available
            tool_calls = response.choices[0].message.tool_calls
            if tool_calls:
                return tool_calls[0].function.arguments
        # If tools are not used or tool_calls are not available, return content
        elif response.choices:
            content = response.choices[0].message.content.strip()
            if extract_tag:
                return extract_tag_content(content, extract_tag)
            else:
                return content
        
    except AttributeError:
        return ""
        
def extract_tag_content(text: str, tag: str) -> str:
    """
    Extract content between opening and closing XML-style tags.
    
    Args:
        text: The input string containing tagged content
        tag: The name of the tag to extract content from
        
    Returns:
        The content between the opening and closing tags, or None if tags not found
    """
    # Create the pattern to match content between opening and closing tags
    pattern = f"<{tag}>(.*?)</{tag}>"
    
    # Use re.DOTALL to make '.' match newlines as well
    match = re.search(pattern, text, re.DOTALL)
    
    if match:
        return match.group(1).strip()
    else:
        return None

def get_unset_command(var_names: List[str] = []) -> str:
    var_names = var_names or []
    var_names.extend(["${!BVAR*}", "${!AVAR*}", "${!EVAR*}"])
    return f"BVAR=BLOCKS_ AVAR=AWS_ EVAR=ECS_ unset {' '.join(var_names)}"



def to_pascal_case(text: str) -> str:
    """
    Convert a string to PascalCase, handling underscores and preserving existing capitalization.

    Examples:
        "blah_blo_SomeThing" -> "BlahBloSomeThing"
        "hello_world" -> "HelloWorld"
        "already_Good" -> "AlreadyGood"
        "multiple___underscores" -> "MultipleUnderscores"
        "" -> ""

    Args:
        text: The input string to convert

    Returns:
        The PascalCase version of the input string
    """
    if not text:
        return ""

    # Split by underscores and filter out empty parts, split by - or _
    parts = [part for part in re.split(r"[-_]", text) if part]

    # Capitalize the first character of each part while preserving the rest
    pascal_parts = [part[0].upper() + part[1:] if part else "" for part in parts]

    return "".join(pascal_parts)


def remove_messages(text, messages_to_remove):
    """
    Remove specified messages from text and clean up whitespace.

    Args:
        text: The original text string
        messages_to_remove: List of message strings to remove

    Returns:
        Cleaned text with messages removed and extra spaces cleaned up
    """
    result = text

    # Remove each message from the text
    for message in messages_to_remove:
        # Remove the message
        result = result.replace(message, "")

    # Clean up multiple spaces (replace 2+ spaces with single space)
    import re
    result = re.sub(r' {2,}', ' ', result)

    # Remove leading and trailing whitespace
    result = result.strip()

    return result


# Command Parser Functions
def extract_commands_strict(text: str) -> List[str]:
    """
    Extract slash commands using strict orchestrator regex logic.
    Requires: @mention /command format

    Examples:
        "@blocks /plan" -> ['plan']
        "@blocks /plan /continue" -> ['plan', 'continue']
        "  @blocks /command" -> ['command'] (leading whitespace OK)
        "/plan" -> [] (no @mention)
        "snjwsnkw @blocks /command" -> [] (text before @mention)

    Args:
        text: Input text to parse for commands

    Returns:
        List of command keywords (without leading slash)
    """
    stripped_text = (text or "").strip()

    # Apply regex to extract command section
    match = COMMAND_SECTION_REGEX.search(stripped_text)

    if not match or len(match.groups()) < 1:
        return []

    # Extract the commands section (group 1)
    commands_section = match.group(1).strip()

    # Split by whitespace and process each command
    commands = []
    for command_with_slash in commands_section.split():
        # Remove leading slash
        command = command_with_slash.lstrip('/').strip()
        if command:
            commands.append(command)

    return commands


def strip_at_mention(text: str) -> str:
    """
    Strip @mention from the start of text if present.

    Examples:
        "@blocks /plan" -> "/plan"
        "  @blocks /plan" -> "/plan"
        "/plan" -> "/plan"
        "text @blocks /plan" -> "text @blocks /plan" (no change, text before @)

    Args:
        text: Input text to strip @mention from

    Returns:
        Text with @mention removed from start, or original text if no @mention at start
    """
    stripped = (text or "").strip()
    return AT_MENTION_REGEX.sub('', stripped)


def extract_commands_lenient(text: str) -> List[str]:
    """
    Extract all consecutive slash commands from start of text.
    First strips @mention if present, then extracts all /commands.

    Examples:
        "/plan" -> ['plan']
        "/plan blahh" -> ['plan']
        "@blocks /command" -> ['command']
        "  @blocks /plan" -> ['plan'] (leading whitespace OK)
        "/plan /continue" -> ['plan', 'continue']
        "/opencode /plan fix it" -> ['opencode', 'plan']
        "@blocks /opencode /plan fix it" -> ['opencode', 'plan']
        "snjwsnkw @blocks /command" -> [] (text before @mention)
        "explanation /continue ok" -> [] (text before command)
        "@blocks /opencode /plan this is my folder /home/user" -> ['opencode', 'plan']

    Args:
        text: Input text to parse for commands

    Returns:
        List of command keywords (without leading slash)
    """
    stripped_text = (text or "").strip()

    # Check if there's text before any @mention or /command
    # If text doesn't start with whitespace, @, or /, reject it
    if stripped_text and not stripped_text[0] in (' ', '\t', '@', '/'):
        return []

    # Strip @mention if present at start
    text_without_mention = strip_at_mention(stripped_text)

    # Check if after stripping @mention, we still have invalid text before /
    # (handles case like "snjwsnkw @blocks /command" where strip_at_mention doesn't match)
    if text_without_mention and not text_without_mention.startswith('/'):
        return []

    # Extract all consecutive /commands at start
    match = CONSECUTIVE_COMMANDS_REGEX.match(text_without_mention)

    if not match:
        return []

    # Parse the commands section
    commands_section = match.group(1)
    commands = []
    for token in commands_section.split():
        if token.startswith('/'):
            command = token.lstrip('/').strip()
            if command:
                commands.append(command)

    return commands


def has_system_command(text: str, command_name: str) -> bool:
    """
    Check if a system command (plan, continue) is present using LENIENT parsing.

    Use this for system commands like /plan and /continue that should work
    in various formats: "@blocks /plan", "/plan blah", "text /plan more text"

    Args:
        text: Input text to search
        command_name: System command to look for (without leading slash)

    Returns:
        True if system command is present, False otherwise
    """
    if command_name.lower() not in SYSTEM_COMMANDS:
        raise ValueError(f"Command '{command_name}' is not a system command. Use has_command() instead.")

    commands = extract_commands_lenient(text)
    return command_name.lower() in [cmd.lower() for cmd in commands]


def has_command(text: str, command_name: str) -> bool:
    """
    Check if a specific command is present using STRICT orchestrator parsing.

    Use this for non-system commands that require @mention format.

    Args:
        text: Input text to search
        command_name: Command to look for (without leading slash)

    Returns:
        True if command is present, False otherwise
    """
    commands = extract_commands_strict(text)
    return command_name.lower() in [cmd.lower() for cmd in commands]


def validate_command_keyword(keyword: str) -> bool:
    """
    Validate a command keyword matches allowed pattern.

    Args:
        keyword: Command keyword to validate

    Returns:
        True if valid, False otherwise
    """
    return bool(COMMAND_KEYWORD_PATTERN.match(keyword))