# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
AI Call Looping Module

Handles AI calls with looping and repair logic, including:
- Looping with JSON repair and continuation
- KPI definition and tracking
- Progress tracking and iteration management

FLOW LOGIC

VARIABLES:
- jsonBase: str (merged JSON so far, starts empty)
- lastValidCompletePart: str (fallback for failures)
- mergeFailCount: int = 0 (max 3)

FLOW:
┌─────────────────────────────────────────────────────────────────┐
│ 1. BUILD PROMPT                                                 │
│    - First: original prompt                                     │
│    - Next: buildContinuationContext(lastRawResponse)            │
├─────────────────────────────────────────────────────────────────┤
│ 2. CALL AI → response fragment                                  │
├─────────────────────────────────────────────────────────────────┤
│ 4. MERGE jsonBase + response                                    │
│    ├─ FAILS: repeat prompt, fails++ (if >=3 return fallback)    │
│    └─ SUCCEEDS: try parse                                       │
│        ├─ SUCCEEDS: FINISHED                                    │
│        └─ FAILS: → step 5                                       │
├─────────────────────────────────────────────────────────────────┤
│ 5. GET CONTEXTS (merge OK, parse failed)                        │
│    getContexts(mergedJson) →                                    │
│    - If no cut point: overlapContext = ""                       │
│    - Store contexts for next iteration                          │
├─────────────────────────────────────────────────────────────────┤
│ 6. DECIDE                                                       │
│    ├─ jsonParsingSuccess=true AND overlapContext="":            │
│    │   FINISHED. return completePart                            │
│    ├─ jsonParsingSuccess=true AND overlapContext!="":           │
│    │   CONTINUE, fails=0                                        │
│    └─ ELSE: repeat prompt, fails++                              │
└─────────────────────────────────────────────────────────────────┘


"""

import json
import logging
from typing import Dict, Any, List, Optional, Callable

from modules.datamodels.datamodelAi import (
    AiCallRequest, AiCallOptions
)
from modules.datamodels.datamodelExtraction import ContentPart
from .subJsonResponseHandling import JsonResponseHandler
from .subLoopingUseCases import LoopingUseCaseRegistry
from modules.workflows.processing.shared.stateTools import checkWorkflowStopped
from modules.shared.jsonContinuation import getContexts
from modules.shared.jsonUtils import buildContinuationContext, extractJsonString, tryParseJson
from modules.shared.jsonUtils import tryParseJson
from modules.shared.jsonUtils import closeJsonStructures
from modules.shared.jsonUtils import stripCodeFences, normalizeJsonText

logger = logging.getLogger(__name__)


class AiCallLooper:
    """Handles AI calls with looping and repair logic."""
    
    def __init__(self, services, aiService, responseParser):
        """Initialize AiCallLooper with service center, AI service, and response parser access."""
        self.services = services
        self.aiService = aiService
        self.responseParser = responseParser
        self.useCaseRegistry = LoopingUseCaseRegistry()  # Initialize use case registry
    
    async def callAiWithLooping(
        self,
        prompt: str,
        options: AiCallOptions,
        debugPrefix: str = "ai_call",
        promptBuilder: Optional[Callable] = None,
        promptArgs: Optional[Dict[str, Any]] = None,
        operationId: Optional[str] = None,
        userPrompt: Optional[str] = None,
        contentParts: Optional[List[ContentPart]] = None,  # ARCHITECTURE: Support ContentParts for large content
        useCaseId: str = None  # REQUIRED: Explicit use case ID - no auto-detection, no fallback
    ) -> str:
        """
        Shared core function for AI calls with repair-based looping system.
        Automatically repairs broken JSON and continues generation seamlessly.
        
        Args:
            prompt: The prompt to send to AI
            options: AI call configuration options
            debugPrefix: Prefix for debug file names
            promptBuilder: Optional function to rebuild prompts for continuation
            promptArgs: Optional arguments for prompt builder
            operationId: Optional operation ID for progress tracking
            userPrompt: Optional user prompt for KPI definition
            contentParts: Optional content parts for first iteration
            useCaseId: REQUIRED: Explicit use case ID - no auto-detection, no fallback
            
        Returns:
            Complete AI response after all iterations
        """
        # REQUIRED: useCaseId must be provided - no auto-detection, no fallback
        if not useCaseId:
            errorMsg = (
                "useCaseId is REQUIRED for callAiWithLooping. "
                "No auto-detection - must explicitly specify use case ID. "
                f"Available use cases: {list(self.useCaseRegistry.useCases.keys())}"
            )
            logger.error(errorMsg)
            raise ValueError(errorMsg)
        
        # Validate use case exists
        useCase = self.useCaseRegistry.get(useCaseId)
        if not useCase:
            errorMsg = (
                f"Use case '{useCaseId}' not found in registry. "
                f"Available use cases: {list(self.useCaseRegistry.useCases.keys())}"
            )
            logger.error(errorMsg)
            raise ValueError(errorMsg)
        
        maxIterations = 50  # Prevent infinite loops
        iteration = 0
        allSections = []  # Accumulate all sections across iterations
        lastRawResponse = None  # Store last raw JSON response for continuation
        
        # JSON Base Iteration System:
        # - jsonBase: the merged JSON string (replaces accumulatedDirectJson array)
        # - After each iteration, new response is merged with jsonBase
        # - On merge success: check if complete, store contexts for next iteration
        # - On merge fail: retry with same prompt, increment fails
        jsonBase = None  # Merged JSON string (starts None, set on first response)
        
        # Merge fail tracking - stop after 3 consecutive merge failures
        MAX_MERGE_FAILS = 3
        mergeFailCount = 0  # Global counter for merge failures across entire loop
        lastValidCompletePart = None  # Store last successfully parsed completePart for fallback
        
        # Get parent operation ID for iteration operations (parentId should be operationId, not log entry ID)
        parentOperationId = operationId  # Use the parent's operationId directly
        
        while iteration < maxIterations:
            iteration += 1
            
            # Create separate operation for each iteration with parent reference
            iterationOperationId = None
            if operationId:
                iterationOperationId = f"{operationId}_iter_{iteration}"
                self.services.chat.progressLogStart(
                    iterationOperationId,
                    "AI Call",
                    f"Iteration {iteration}",
                    "",
                    parentOperationId=parentOperationId
                )
            
            # Build iteration prompt
            # CRITICAL: Build continuation prompt if we have sections OR if we have a previous response (even if broken)
            # This ensures continuation prompts are built even when JSON is so broken that no sections can be extracted
            if (len(allSections) > 0 or lastRawResponse) and promptBuilder and promptArgs:
                # Extract templateStructure and basePrompt from promptArgs (REQUIRED)
                templateStructure = promptArgs.get("templateStructure")
                if not templateStructure:
                    raise ValueError(
                        f"templateStructure is REQUIRED in promptArgs for use case '{useCaseId}'. "
                        "Prompt creation functions must return (prompt, templateStructure) tuple."
                    )
                
                basePrompt = promptArgs.get("basePrompt")
                if not basePrompt:
                    # Fallback: use prompt parameter (should be the same)
                    basePrompt = prompt
                    logger.warning(
                        f"basePrompt not found in promptArgs for use case '{useCaseId}', "
                        "using prompt parameter instead. This may indicate a bug."
                    )
                
                # This is a continuation - build continuation context with raw JSON and rebuild prompt
                continuationContext = buildContinuationContext(
                    allSections, lastRawResponse, useCaseId, templateStructure
                )
                if not lastRawResponse:
                    logger.warning(f"Iteration {iteration}: No previous response available for continuation!")
                
                # Store valid completePart from continuation context for fallback on merge failures
                # Use getContexts to check if completePart is parseable and store it
                if lastRawResponse and not lastValidCompletePart:
                    try:
                        contexts = getContexts(lastRawResponse)
                        if contexts.jsonParsingSuccess and contexts.completePart:
                            lastValidCompletePart = contexts.completePart
                            logger.debug(f"Iteration {iteration}: Stored initial valid completePart ({len(lastValidCompletePart)} chars)")
                    except Exception as e:
                        logger.debug(f"Iteration {iteration}: Failed to extract completePart: {e}")
                
                # Unified prompt builder call: Continuation builders only need continuationContext, templateStructure, and basePrompt
                # All initial context (section, userPrompt, etc.) is already in basePrompt, so promptArgs is not needed
                # Extract templateStructure and basePrompt from promptArgs (they're explicit parameters)
                iterationPrompt = await promptBuilder(
                    continuationContext=continuationContext,
                    templateStructure=templateStructure,
                    basePrompt=basePrompt
                )
            else:
                # First iteration - use original prompt
                iterationPrompt = prompt
            
            # Make AI call
            try:
                checkWorkflowStopped(self.services)
                if iterationOperationId:
                    self.services.chat.progressLogUpdate(iterationOperationId, 0.3, "Calling AI model")
                # ARCHITECTURE: Pass ContentParts directly to AiCallRequest
                # This allows model-aware chunking to handle large content properly
                # ContentParts are only passed in first iteration (continuations don't need them)
                request = AiCallRequest(
                    prompt=iterationPrompt,
                    context="",
                    options=options,
                    contentParts=contentParts if iteration == 1 else None  # Only pass ContentParts in first iteration
                )
                
                # Write the ACTUAL prompt sent to AI
                # For section content generation: write prompt for first iteration and continuation iterations
                # For document generation: write prompt for each iteration
                isSectionContent = "_section_" in debugPrefix
                if iteration == 1:
                    self.services.utils.writeDebugFile(iterationPrompt, f"{debugPrefix}_prompt")
                elif isSectionContent:
                    # Save continuation prompts for section_content debugging
                    self.services.utils.writeDebugFile(iterationPrompt, f"{debugPrefix}_prompt_iteration_{iteration}")
                else:
                    # Document generation - save all iteration prompts
                    self.services.utils.writeDebugFile(iterationPrompt, f"{debugPrefix}_prompt_iteration_{iteration}")
                
                response = await self.aiService.callAi(request)
                result = response.content
                
                # Track bytes for progress reporting
                bytesReceived = len(result.encode('utf-8')) if result else 0
                totalBytesSoFar = sum(len(section.get('content', '').encode('utf-8')) if isinstance(section.get('content'), str) else 0 for section in allSections) + bytesReceived
                
                # Update progress after AI call with byte information
                if iterationOperationId:
                    # Format bytes for display (kB or MB)
                    if totalBytesSoFar < 1024:
                        bytesDisplay = f"{totalBytesSoFar}B"
                    elif totalBytesSoFar < 1024 * 1024:
                        bytesDisplay = f"{totalBytesSoFar / 1024:.1f}kB"
                    else:
                        bytesDisplay = f"{totalBytesSoFar / (1024 * 1024):.1f}MB"
                    self.services.chat.progressLogUpdate(iterationOperationId, 0.6, f"AI response received ({bytesDisplay})")
                
                # Write raw AI response to debug file
                # For section content generation: write response for first iteration and continuation iterations
                # For document generation: write response for each iteration
                if iteration == 1:
                    self.services.utils.writeDebugFile(result, f"{debugPrefix}_response")
                elif isSectionContent:
                    # Save continuation responses for section_content debugging
                    self.services.utils.writeDebugFile(result, f"{debugPrefix}_response_iteration_{iteration}")
                else:
                    # Document generation - save all iteration responses
                    self.services.utils.writeDebugFile(result, f"{debugPrefix}_response_iteration_{iteration}")
                
                # Note: Stats are now stored centrally in callAi() - no need to duplicate here
                
                # Check for error response using generic error detection (errorCount > 0 or modelName == "error")
                if hasattr(response, 'errorCount') and response.errorCount > 0:
                    errorMsg = f"Iteration {iteration}: Error response detected (errorCount={response.errorCount}), stopping loop: {result[:200] if result else 'empty'}"
                    logger.error(errorMsg)
                    break
                
                if hasattr(response, 'modelName') and response.modelName == "error":
                    errorMsg = f"Iteration {iteration}: Error response detected (modelName=error), stopping loop: {result[:200] if result else 'empty'}"
                    logger.error(errorMsg)
                    break
                
                if not result or not result.strip():
                    logger.warning(f"Iteration {iteration}: Empty response, stopping")
                    break
                
                # Check if this is a text response (not document generation)
                # Text responses don't need JSON parsing - return immediately after first successful response
                isTextResponse = (promptBuilder is None and promptArgs is None) or debugPrefix == "text"
                
                if isTextResponse:
                    # For text responses, return the text immediately - no JSON parsing needed
                    logger.info(f"Iteration {iteration}: Text response received, returning immediately")
                    if iterationOperationId:
                        self.services.chat.progressLogFinish(iterationOperationId, True)
                    return result
                
                # NOTE: Do NOT update lastRawResponse here!
                # lastRawResponse should only be updated after successful merge
                # This ensures retry iterations use the correct base context
                
                # Handle use cases that return JSON directly (no section extraction needed)
                # Check if use case supports direct return (all registered use cases do)
                if useCase and not useCase.requiresExtraction:
                    # =====================================================================
                    # ITERATION FLOW (Simplified)
                    # =====================================================================
                    # Step 4: MERGE jsonBase + new response
                    #         - FAILS: repeat prompt, increment fails cont (if >=3 return fallback)
                    #         - SUCCEEDS: try parse
                    #           - SUCCEEDS: FINISHED
                    #           - FAILS: proceed to Step 5
                    # Step 5: GET CONTEXTS (merge OK, parse failed)
                    #         - getContexts() with repair
                    #         - If no cut point: overlapContext = ""
                    # Step 6: DECIDE
                    #         - jsonParsingSuccess=true AND overlapContext="": FINISHED
                    #         - jsonParsingSuccess=true AND overlapContext!="": continue, fails=0
                    #         - ELSE: repeat prompt, increment fails count
                    # =====================================================================
                                       
                    # STEP 4: MERGE jsonBase + new response
                    # Use candidateJson to hold merged result until we confirm it's valid
                    candidateJson = None
                    
                    if jsonBase is None:
                        # First iteration - candidate is the current result
                        candidateJson = result
                        logger.debug(f"Iteration {iteration}: First response, candidateJson ({len(candidateJson)} chars)")
                    else:
                        # Merge jsonBase with new response
                        logger.info(f"Iteration {iteration}: Merging jsonBase ({len(jsonBase)} chars) with new response ({len(result)} chars)")
                        mergedJsonString, hasOverlap = JsonResponseHandler.mergeJsonStringsWithOverlap(jsonBase, result)
                        
                        if not hasOverlap:
                            # MERGE FAILED - repeat prompt with unchanged jsonBase
                            mergeFailCount += 1
                            logger.warning(
                                f"Iteration {iteration}: Merge failed, no overlap found "
                                f"(fail {mergeFailCount}/{MAX_MERGE_FAILS})"
                            )
                            
                            if mergeFailCount >= MAX_MERGE_FAILS:
                                # Max failures reached - return last valid completePart
                                logger.error(
                                    f"Iteration {iteration}: Max merge failures ({MAX_MERGE_FAILS}) reached, "
                                    "returning last valid completePart"
                                )
                                if iterationOperationId:
                                    self.services.chat.progressLogFinish(iterationOperationId, False)
                                
                                if lastValidCompletePart:
                                    try:
                                        extracted = extractJsonString(lastValidCompletePart)
                                        parsed, parseErr, _ = tryParseJson(extracted)
                                        if parseErr is None and parsed:
                                            normalized = self._normalizeJsonStructure(parsed, useCase)
                                            return json.dumps(normalized, indent=2, ensure_ascii=False)
                                    except Exception:
                                        pass
                                    return lastValidCompletePart
                                else:
                                    # No valid fallback - return whatever we have
                                    return jsonBase if jsonBase else ""
                            
                            # Not at max failures - retry with same prompt (jsonBase unchanged)
                            if iterationOperationId:
                                self.services.chat.progressLogUpdate(
                                    iterationOperationId, 0.7, 
                                    f"Merge failed ({mergeFailCount}/{MAX_MERGE_FAILS}), retrying"
                                )
                                self.services.chat.progressLogFinish(iterationOperationId, True)
                            continue
                        
                        # MERGE SUCCEEDED - set candidate (don't update jsonBase yet!)
                        candidateJson = mergedJsonString
                        logger.debug(f"Iteration {iteration}: Merge succeeded, candidateJson ({len(candidateJson)} chars)")
                    
                    # Update lastRawResponse ONLY after we have a valid candidateJson
                    # (first iteration or successful merge - NOT on merge failure!)
                    # This ensures retry iterations use the correct base context
                    lastRawResponse = candidateJson
                    
                    # Try direct parse of candidate
                    try:
                        extracted = extractJsonString(candidateJson)
                        parsed, parseErr, _ = tryParseJson(extracted)
                        if parseErr is None and parsed:
                            # Direct parse succeeded - FINISHED
                            # Commit candidate to jsonBase
                            jsonBase = candidateJson
                            logger.info(f"Iteration {iteration}: Direct parse succeeded, JSON is complete")
                            normalized = self._normalizeJsonStructure(parsed, useCase)
                            result = json.dumps(normalized, indent=2, ensure_ascii=False)
                            
                            if iterationOperationId:
                                self.services.chat.progressLogFinish(iterationOperationId, True)
                            
                            if not useCase.finalResultHandler:
                                raise ValueError(
                                    f"Use case '{useCaseId}' is missing required 'finalResultHandler' callback."
                                )
                            return useCase.finalResultHandler(
                                result, normalized, extracted, debugPrefix, self.services
                            )
                    except Exception as e:
                        logger.debug(f"Iteration {iteration}: Direct parse failed: {e}")
                    
                    # STEP 5: GET CONTEXTS (merge OK, parse failed = cut JSON)
                    # Use candidateJson for context extraction
                    contexts = getContexts(candidateJson)
                    overlapInfo = "(empty=complete)" if contexts.overlapContext == "" else f"({len(contexts.overlapContext)} chars)"
                    logger.debug(
                        f"Iteration {iteration}: getContexts() -> "
                        f"jsonParsingSuccess={contexts.jsonParsingSuccess}, "
                        f"overlapContext={overlapInfo}"
                    )
                    
                    # STEP 6: DECIDE based on jsonParsingSuccess and overlapContext
                    if contexts.jsonParsingSuccess and contexts.overlapContext == "":
                        # JSON is complete (no cut point) - FINISHED
                        # Use completePart for final result (closed, repaired JSON)
                        # No more merging needed, so we don't need the cut version
                        jsonBase = contexts.completePart
                        logger.info(f"Iteration {iteration}: jsonParsingSuccess=true, overlapContext='', JSON complete")
                        
                        # Store and parse completePart
                        lastValidCompletePart = contexts.completePart
                        
                        try:
                            extracted = extractJsonString(contexts.completePart)
                            parsed, parseErr, _ = tryParseJson(extracted)
                            if parseErr is None and parsed:
                                normalized = self._normalizeJsonStructure(parsed, useCase)
                                result = json.dumps(normalized, indent=2, ensure_ascii=False)
                                
                                if iterationOperationId:
                                    self.services.chat.progressLogFinish(iterationOperationId, True)
                                
                                if not useCase.finalResultHandler:
                                    raise ValueError(
                                        f"Use case '{useCaseId}' is missing required 'finalResultHandler' callback."
                                    )
                                return useCase.finalResultHandler(
                                    result, normalized, extracted, debugPrefix, self.services
                                )
                        except Exception as e:
                            logger.warning(f"Iteration {iteration}: Failed to parse completePart: {e}")
                        
                        # Fallback: return completePart as-is
                        if iterationOperationId:
                            self.services.chat.progressLogFinish(iterationOperationId, True)
                        return contexts.completePart
                    
                    elif contexts.jsonParsingSuccess and contexts.overlapContext != "":
                        # JSON parseable but has cut point - CONTINUE to next iteration
                        # CRITICAL: Use hierarchyContext (CUT json) as jsonBase for next merge!
                        # - hierarchyContext = the truncated JSON at cut point (needed for overlap matching)
                        # - completePart = closed JSON (for validation/fallback only)
                        # The next AI fragment's overlap must match the CUT point, not closed structures
                        jsonBase = contexts.hierarchyContext
                        logger.info(
                            f"Iteration {iteration}: jsonParsingSuccess=true, overlapContext not empty, "
                            f"continuing iteration (jsonBase updated to hierarchyContext: {len(jsonBase)} chars)"
                        )
                        
                        # Store valid completePart as fallback (different from jsonBase!)
                        lastValidCompletePart = contexts.completePart
                        
                        # Reset fail counter on successful progress
                        mergeFailCount = 0
                        
                        # Update lastRawResponse for continuation prompt building
                        # Use the CUT version for prompt context as well
                        lastRawResponse = jsonBase
                        
                        if iterationOperationId:
                            self.services.chat.progressLogUpdate(iterationOperationId, 0.7, "JSON incomplete, requesting continuation")
                            self.services.chat.progressLogFinish(iterationOperationId, True)
                        continue
                    
                    else:
                        # JSON not parseable after repair - repeat prompt, increment fails
                        # Do NOT update jsonBase - keep previous valid state
                        mergeFailCount += 1
                        logger.warning(
                            f"Iteration {iteration}: jsonParsingSuccess=false, "
                            f"repeat prompt (fail {mergeFailCount}/{MAX_MERGE_FAILS})"
                        )
                        
                        if mergeFailCount >= MAX_MERGE_FAILS:
                            # Max failures reached - return last valid completePart
                            logger.error(
                                f"Iteration {iteration}: Max failures ({MAX_MERGE_FAILS}) reached, "
                                "returning last valid completePart"
                            )
                            if iterationOperationId:
                                self.services.chat.progressLogFinish(iterationOperationId, False)
                            
                            if lastValidCompletePart:
                                try:
                                    extracted = extractJsonString(lastValidCompletePart)
                                    parsed, parseErr, _ = tryParseJson(extracted)
                                    if parseErr is None and parsed:
                                        normalized = self._normalizeJsonStructure(parsed, useCase)
                                        return json.dumps(normalized, indent=2, ensure_ascii=False)
                                except Exception:
                                    pass
                                return lastValidCompletePart
                            else:
                                return jsonBase if jsonBase else ""
                        
                        # Not at max - retry with same prompt
                        # Do NOT update jsonBase or lastRawResponse - keep previous for retry
                        if iterationOperationId:
                            self.services.chat.progressLogUpdate(
                                iterationOperationId, 0.7, 
                                f"Parse failed ({mergeFailCount}/{MAX_MERGE_FAILS}), retrying"
                            )
                            self.services.chat.progressLogFinish(iterationOperationId, True)
                        continue
                    
            except Exception as e:
                logger.error(f"Error in AI call iteration {iteration}: {str(e)}")
                if iterationOperationId:
                    self.services.chat.progressLogFinish(iterationOperationId, False)
                break
        
        if iteration >= maxIterations:
            logger.warning(f"AI call stopped after maximum iterations ({maxIterations})")
        
        # This code path should never be reached because all registered use cases
        # return early when JSON is complete. This would only execute for use cases that
        # require section extraction, but no such use cases are currently registered.
        logger.error(f"Unexpected code path: reached end of loop without return for use case '{useCaseId}'")
        return result if result else ""
    
    def _isJsonStringIncomplete(self, jsonString: str) -> bool:
        """
        Check if JSON string is incomplete (truncated) BEFORE closing/parsing.
        
        This is critical because if JSON is truncated, closing it makes it appear complete,
        but we need to detect the truncation to continue iteration.
        
        Args:
            jsonString: JSON string to check
            
        Returns:
            True if JSON string appears incomplete/truncated, False otherwise
        """
        if not jsonString or not jsonString.strip():
            return False
                
        # Normalize JSON string
        normalized = stripCodeFences(normalizeJsonText(jsonString)).strip()
        if not normalized:
            return False
        
        # Find first '{' or '[' to start
        startIdx = -1
        for i, char in enumerate(normalized):
            if char in '{[':
                startIdx = i
                break
        
        if startIdx == -1:
            return False
        
        jsonContent = normalized[startIdx:]
        
        # Check if structures are balanced (all opened structures are closed)
        braceCount = 0
        bracketCount = 0
        inString = False
        escapeNext = False
        
        for char in jsonContent:
            if escapeNext:
                escapeNext = False
                continue
            
            if char == '\\':
                escapeNext = True
                continue
            
            if char == '"':
                inString = not inString
                continue
            
            if not inString:
                if char == '{':
                    braceCount += 1
                elif char == '}':
                    braceCount -= 1
                elif char == '[':
                    bracketCount += 1
                elif char == ']':
                    bracketCount -= 1
        
        # If structures are unbalanced, JSON is incomplete
        if braceCount > 0 or bracketCount > 0:
            return True
        
        # Check if JSON ends with incomplete value (e.g., unclosed string, incomplete number, trailing comma)
        trimmed = jsonContent.rstrip()
        if not trimmed:
            return False
        
        # Check for trailing comma (might indicate incomplete)
        if trimmed.endswith(','):
            # Trailing comma might indicate incomplete, but could also be valid
            # Check if there's a closing bracket/brace after the comma
            return False  # Trailing comma alone doesn't mean incomplete
        
        # Check if ends with incomplete string (odd number of quotes)
        quoteCount = jsonContent.count('"')
        if quoteCount % 2 == 1:
            # Odd number of quotes - string is not closed
            return True
        
        # Check if ends mid-value (e.g., ends with "417 instead of "4170. 41719"])
        # Look for patterns that suggest truncation:
        # - Ends with incomplete number (e.g., "417)
        # - Ends with incomplete array element (e.g., ["417)
        # - Ends with incomplete object property (e.g., {"key": "val)
        
        # If JSON parses successfully without closing, it's complete
        parsed, parseErr, _ = tryParseJson(jsonContent)
        if parseErr is None:
            # Parses successfully - it's complete
            return False
        
        # If it doesn't parse, try closing it and see if that helps
        closed = closeJsonStructures(jsonContent)
        parsedClosed, parseErrClosed, _ = tryParseJson(closed)
        
        if parseErrClosed is None:
            # Only parses after closing - it was incomplete
            return True
        
        # Doesn't parse even after closing - might be malformed, but assume incomplete to be safe
        return True
    
    def _normalizeJsonStructure(self, parsed: Any, useCase) -> Any:
        """
        Normalize JSON structure to ensure consistent format before merging.
        Handles different response formats and converts them to expected structure.
        
        Args:
            parsed: Parsed JSON object (can be dict, list, or primitive)
            useCase: LoopingUseCase instance with jsonNormalizer callback
            
        Returns:
            Normalized JSON structure
        """
        # Use callback to normalize JSON structure (REQUIRED - no fallback)
        if not useCase or not useCase.jsonNormalizer:
            raise ValueError(
                f"Use case '{useCase.useCaseId if useCase else 'unknown'}' is missing required 'jsonNormalizer' callback. "
                "All use cases must provide a jsonNormalizer function."
            )
        return useCase.jsonNormalizer(parsed, useCase.useCaseId)

