import mss
from PIL import Image, ImageEnhance, ImageFilter
import pytesseract
import speech_recognition as sr
import pyttsx3
import asyncio
import aiohttp
from bs4 import BeautifulSoup
import keyboard
import threading
import os
import re
import logging
import tkinter as tk
from word2number import w2n  # Optional for number word conversion
import ctypes
import sys
import ollama
from datetime import datetime

# -------------------- Configuration --------------------

# Configure logging to capture DEBUG level logs
logging.basicConfig(
    level=logging.DEBUG,  # Set to DEBUG for detailed logs
    format='%(asctime)s [%(levelname)s] %(message)s',
    handlers=[
        logging.StreamHandler(),  # Outputs to console
        # Uncomment the next line to enable logging to a file
        # logging.FileHandler("qa_assistant.log"),
    ]
)

# Initialize the TTS engine globally
tts_engine = pyttsx3.init()
is_listening = False  # Initialize this global variable at the start
speech_text = ""  # Initialize this global variable at the start

# Define global locks
lock = threading.Lock()
tts_lock = threading.Lock()

# -------------------- Preferred Sites Configuration --------------------

# Editable list of preferred sites for prioritized search
PREFERRED_SITES = [
    "civiljungle.org",
    "www.nwcg.gov",
    "the.constructor.org",
    "definecivil.com",
    "testbook.com/civil-engineering",
    "sanfoundry.com",
    # Add or remove sites as needed
]
# Define the output directory for saved images
OUTPUT_DIR = (r"D:\\AI\\EXP2\\TempS")


def set_dpi_awareness():
    """
    Sets the DPI awareness of the process to prevent scaling issues.
    """
    try:
        if sys.platform.startswith('win'):
            awareness = ctypes.c_int()
            # PROCESS_SYSTEM_DPI_AWARE = 1
            ctypes.windll.shcore.SetProcessDpiAwareness(1)
            logging.info("Process DPI awareness set to SYSTEM_AWARE.")
    except Exception as e:
        logging.warning(f"Could not set DPI awareness: {e}. Continuing without setting DPI awareness.")


def get_scaling_factor():
    """
    Retrieves the current DPI scaling factor.
    Returns:
        float: Scaling factor (e.g., 1.25 for 125%)
    """
    try:
        if sys.platform.startswith('win'):
            user32 = ctypes.windll.user32
            try:
                # Windows 10 and above
                user32.SetProcessDPIAware()
                dpi = user32.GetDpiForWindow(user32.GetForegroundWindow())
                scaling_factor = dpi / 96  # 96 DPI is 100%
                logging.info(f"DPI Scaling Factor detected: {scaling_factor}")
                return scaling_factor
            except AttributeError:
                # For older Windows versions
                user32.SetProcessDPIAware()
                hdc = user32.GetDC(0)
                LOGPIXELSX = 88
                dpi = ctypes.windll.gdi32.GetDeviceCaps(hdc, LOGPIXELSX)
                scaling_factor = dpi / 96
                ctypes.windll.user32.ReleaseDC(0, hdc)
                logging.info(f"DPI Scaling Factor detected via fallback: {scaling_factor}")
                return scaling_factor
        else:
            # For other operating systems, default to 1.0
            logging.info("Non-Windows OS detected. Defaulting DPI scaling factor to 1.0.")
            return 1.0
    except Exception as e:
        logging.warning(f"Could not retrieve DPI scaling factor: {e}. Defaulting to 1.0.")
        return 1.0


def convert_number_words(text):
    """
    Converts number words in the speech text to digits.
    Example: "five hundred ninety-seven" -> "597"
    """
    try:
        # Attempt to convert the entire text
        number = w2n.word_to_num(text)
        return str(number)
    except:
        # Attempt to extract digits directly if conversion fails
        digits = re.findall(r'\d+', text)
        return ''.join(digits) if digits else text  # Return the number if found, else original text


def normalize_option_label(label):
    """
    Normalizes OCR-misread option labels to standard A-E.
    """
    label = label.upper()
    if label in ['A', 'B', 'C', 'D', 'E']:
        return label
    # Handle common OCR misinterpretations
    ocr_misreads = {
        '©': 'C',
        'S': '5',
        'À': 'A',
        'ß': 'B',
        '(': '',  # Remove any stray characters
        ')': '',
        '-': '',
        # Add more mappings if needed
    }
    return ocr_misreads.get(label, label)  # Default to original if no mapping found


def speak_text(text):
    """
    Speaks out the provided text using TTS.
    Adds hotkeys for pausing (F4) and stopping (F5) speech.
    """
    global tts_engine
    logging.info(f"Speaking out the response: {text}")
    with tts_lock:
        try:
            # Speak the text
            tts_engine.say(text)

            # Add hotkeys for pausing (F4) and stopping (F5)
            keyboard.add_hotkey('f4', pause_speaking)
            keyboard.add_hotkey('f5', stop_speaking)

            tts_engine.runAndWait()
            logging.info("Text-to-Speech completed.")
        except RuntimeError as e:
            logging.error(f"TTS Error: {e}")
        finally:
            # Remove hotkeys after completion to avoid conflicts
            keyboard.remove_hotkey('f4')
            keyboard.remove_hotkey('f5')


def pause_speaking():
    """
    Pauses the TTS engine.
    """
    global tts_engine
    logging.info("Paused speaking...")
    tts_engine.pause()


def stop_speaking():
    """
    Stops the TTS engine.
    """
    global tts_engine
    logging.info("Stopped speaking.")
    tts_engine.stop()


def save_captured_image(image, output_dir, prefix="captured_region"):
    """
    Saves the captured image for debugging purposes with a timestamp.
    
    Args:
        image (PIL.Image): The image to save.
        output_dir (str): Directory where the image will be saved.
        prefix (str): Prefix for the filename.
    """
    try:
        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        filename = os.path.join(output_dir, f"{prefix}_{timestamp}.png")
        image.save(filename)
        logging.info(f"Captured image saved as {filename}")
    except Exception as e:
        logging.error(f"Failed to save captured image: {e}")

def save_preprocessed_image(image, output_dir, prefix="preprocessed_image"):
    """
    Saves the preprocessed image for debugging purposes with a timestamp.
    
    Args:
        image (PIL.Image): The image to save.
        output_dir (str): Directory where the image will be saved.
        prefix (str): Prefix for the filename.
    """
    try:
        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        filename = os.path.join(output_dir, f"{prefix}_{timestamp}.png")
        image.save(filename)
        logging.info(f"Preprocessed image saved as {filename}")
    except Exception as e:
        logging.error(f"Failed to save preprocessed image: {e}")

def perform_ocr(image):
    """
    Performs OCR on the captured image after preprocessing.
    Uses the preprocessed image for better accuracy.
    """
    global ocr_text
    try:
        logging.info("Starting OCR process with image preprocessing...")

        # Step 1: Save the original captured image for verification
        save_captured_image(image, OUTPUT_DIR)

        # Step 2: Convert to grayscale
        gray = image.convert('L')
        logging.debug("Converted image to grayscale.")

        # Step 3: Enhance contrast
        enhancer = ImageEnhance.Contrast(gray)
        gray_enhanced = enhancer.enhance(1.0)  # Adjust contrast factor as needed
        logging.debug("Enhanced image contrast.")

        # Step 4: Apply thresholding
        threshold = 190  # Adjust based on testing
        gray_threshold = gray_enhanced.point(lambda x: 0 if x < threshold else 255, '1')
        logging.debug("Applied thresholding to binarize the image.")

        # Step 5: Reduce noise with a median filter
        final_image = gray_threshold
        #final_image = gray_threshold.filter(ImageFilter.MedianFilter())
        #logging.debug("Applied median filter to reduce noise.")

        # Step 6: Save preprocessed image for debugging
        save_preprocessed_image(final_image, OUTPUT_DIR)

        logging.info("Image preprocessing complete. Performing OCR...")

        # Define Tesseract configuration
        custom_config = r'--oem 3 --psm 6'  # Adjust as needed

        # Perform OCR
        raw_text = pytesseract.image_to_string(final_image, lang='eng', config=custom_config)

        if not raw_text.strip():
            logging.warning("OCR returned empty text.")
            speak_text("I couldn't detect any text in the selected area. Please try again with a different region.")
        else:
            ocr_text = raw_text
            logging.debug("Raw OCR Text:")
            logging.debug(ocr_text)
            logging.info("OCR process completed successfully.")
    except pytesseract.TesseractNotFoundError:
        logging.error("Tesseract executable not found. Please ensure Tesseract is installed and PATH is set correctly.")
        speak_text("Tesseract is not installed or not found. Please install Tesseract and try again.")
        ocr_text = ""
    except Exception as e:
        logging.error(f"OCR Error: {e}")
        speak_text("An error occurred during OCR processing. Please try again.")
        ocr_text = ""


def get_selected_region(scaling_factor):
    """
    Allows the user to select a screen region by drawing a rectangle.
    Returns the coordinates of the selected region adjusted for DPI scaling.
    """
    # Initialize the tkinter window
    root = tk.Tk()
    root.title("Select Region (Press Esc to Cancel)")
    root.attributes('-alpha', 0.3)  # Transparency
    root.attributes("-topmost", True)
    root.overrideredirect(True)  # Remove window decorations

    screen_width = root.winfo_screenwidth()
    screen_height = root.winfo_screenheight()
    root.geometry(f"{screen_width}x{screen_height}")

    canvas = tk.Canvas(root, cursor="cross", bg="grey")
    canvas.pack(fill=tk.BOTH, expand=True)

    rect = None
    start_x = start_y = end_x = end_y = 0

    def on_button_press(event):
        nonlocal start_x, start_y, rect
        start_x = int(event.x * scaling_factor)
        start_y = int(event.y * scaling_factor)
        rect = canvas.create_rectangle(event.x, event.y, event.x, event.y, outline='red', width=2)
        logging.debug(f"Button pressed at ({start_x}, {start_y})")

    def on_move_press(event):
        nonlocal end_x, end_y
        end_x = int(event.x * scaling_factor)
        end_y = int(event.y * scaling_factor)
        canvas.coords(rect, start_x / scaling_factor, start_y / scaling_factor, event.x, event.y)
        logging.debug(f"Mouse moved to ({end_x}, {end_y})")

    def on_button_release(event):
        root.quit()
        root.destroy()  # Destroy the window to remove the overlay

    def on_escape(event):
        logging.info("Capture cancelled by user.")
        root.quit()
        root.destroy()
        sys.exit()

    canvas.bind("<ButtonPress-1>", on_button_press)
    canvas.bind("<B1-Motion>", on_move_press)
    canvas.bind("<ButtonRelease-1>", on_button_release)
    root.bind("<Escape>", on_escape)

    logging.info("Please select the region by clicking and dragging the mouse.")
    root.mainloop()

    # Calculate absolute coordinates
    left = min(start_x, end_x)
    top = min(start_y, end_y)
    right = max(start_x, end_x)
    bottom = max(start_y, end_y)

    logging.debug(f"Selected region coordinates (scaled): Left={left}, Top={top}, Right={right}, Bottom={bottom}")
    return (left, top, right, bottom)


# -------------------- Modified Web Search Function --------------------

async def async_web_search(query):
    """
    Performs a web search using DuckDuckGo and retrieves the top 3 results
    from the preferred sites.
    
    Args:
        query (str): The search query.
    
    Returns:
        list: A list of dictionaries containing 'title' and 'snippet' for each result.
    """
    logging.info(f"Searching the web for query: {query}")
    
    # Construct the site-limited query
    if PREFERRED_SITES:
        site_query = " OR ".join([f"site:{site}" for site in PREFERRED_SITES])
        combined_query = f"{query} ({site_query})"
    else:
        combined_query = query  # If no preferred sites, perform a general search
    
    logging.debug(f"Combined search query with preferred sites: {combined_query}")
    
    # Encode the query for URL
    encoded_query = aiohttp.helpers.quote(combined_query)
    url = f"https://html.duckduckgo.com/html/?q={encoded_query}"
    
    try:
        async with aiohttp.ClientSession() as session:
            async with session.get(url) as response:
                html = await response.text()
    except Exception as e:
        logging.error(f"Web search failed: {e}")
        return []
    
    soup = BeautifulSoup(html, 'html.parser')
    results = []
    
    # DuckDuckGo's HTML structure can vary; adjust selectors as needed
    for result in soup.find_all('div', class_='result__body', limit=3):
        title_element = result.find('a', class_='result__a')
        snippet_element = result.find('a', class_='result__snippet')
        title = title_element.get_text(strip=True) if title_element else "No Title"
        snippet = snippet_element.get_text(strip=True) if snippet_element else "No Snippet"
        results.append({'title': title, 'snippet': snippet})
        logging.debug(f"Found result - Title: {title}, Snippet: {snippet}")
    
    if not results:
        logging.warning("No search results found for the preferred sites.")
    
    logging.info(f"Web search complete. Found {len(results)} results.")
    return results


def generate_final_response(search_results, ocr_text, user_input):
    """
    Generates the final response based on web search results and OCR text.
    """
    logging.info("Generating final response...")
    # Format the search results for better readability
    formatted_results = "\n".join([f"Title: {res['title']}\nSnippet: {res['snippet']}\n" for res in search_results])

    if user_input:
        context = f"""You are an intelligent assistant specialized in answering questions based on OCR text and web search results.

OCR Text:
{ocr_text}

User Query:
{user_input}

Web Search Results:
{formatted_results}

Instructions:
1. Analyze the OCR text and the user's query.
2. Utilize the web search results to inform your answer.
3. Provide a well-informed, concise, and accurate response to the user's question based on the available information.
4. Ensure that your answer directly addresses the user's intent.

Provide the answer below:
"""
    else:
        context = f"""You are an intelligent assistant specialized in answering questions based solely on OCR text and web search results.

OCR Text:
{ocr_text}

Web Search Results:
{formatted_results}

Instructions:
1. Analyze the OCR text.
2. Utilize the web search results to inform your answer.
3. Provide a well-informed, concise, and accurate response based on the available information.
4. Ensure that your answer directly reflects the content of the OCR text and the search results.

Provide the answer below:
"""

    try:
        response = ollama.generate(model='llama3.2', prompt=context)
        final_response = response.get('response', '').strip()
        logging.info(f"Generated Final Response: {final_response}")
        return final_response
    except Exception as e:
        logging.error(f"LLM Error during final response generation: {e}")
        return ""


def reset_state():
    """
    Resets the global state variables.
    """
    global speech_text, ocr_text
    speech_text = ""
    ocr_text = ""


# -------------------- Helper Functions --------------------


def transcribe_speech(short_timeout=5, phrase_time_limit=5):
    """
    Transcribes speech from the microphone with a short timeout.
    If no speech is detected within the timeout, proceeds without user input.

    Args:
        short_timeout (int): Maximum number of seconds to wait for a phrase to start.
        phrase_time_limit (int): Maximum number of seconds a phrase can last.
    """
    global is_listening, speech_text
    recognizer = sr.Recognizer()
    with sr.Microphone() as source:
        logging.info("Listening for voice input... Please say your query or question.")
        recognizer.adjust_for_ambient_noise(source, duration=1)  # Adjust for noise for 1 second
        try:
            # Listen for speech with shorter timeout and phrase time limit
            audio = recognizer.listen(source, timeout=short_timeout, phrase_time_limit=phrase_time_limit)
            logging.info("Processing the audio with Google Speech Recognition...")
            text = recognizer.recognize_google(audio)
            # Convert number words to digits if necessary
            speech_text = convert_number_words(text)
            logging.info(f"Speech Transcribed: {speech_text}")

            # Provide confirmation
            speak_text(f"I heard: {speech_text}.")
        except sr.WaitTimeoutError:
            logging.warning("Listening timed out, no speech detected. Proceeding without voice input.")
            speech_text = ""  # Ensure speech_text is empty
        except sr.UnknownValueError:
            logging.warning("Speech recognition could not understand audio. Proceeding without voice input.")
            speech_text = ""  # Ensure speech_text is empty
        except sr.RequestError as e:
            logging.error(f"Could not request results from speech recognition service; {e}")
            speech_text = ""  # Ensure speech_text is empty
        finally:
            with lock:
                is_listening = False  # Ensure listening flag is reset


def configure_tts_voice():
    """
    Configures the TTS engine's voice, rate, and volume.
    """
    global tts_engine
    voices = tts_engine.getProperty('voices')

    # List available voices (optional)
    for index, voice in enumerate(voices):
        logging.debug(f"Voice {index}: {voice.name}, {voice.gender}, {voice.languages}")

    # Change voice (select male/female, adjust the index as per your preference)
    if len(voices) > 1:
        tts_engine.setProperty('voice', voices[1].id)  # Change index to select a different voice
        logging.info(f"TTS voice set to: {voices[1].name}")
    else:
        logging.info("Only one voice available. Using default voice.")

    # Adjust speaking rate (lower is slower and more human-like)
    tts_engine.setProperty('rate', 160)  # Default is 200, lowering makes it more human-like

    # Adjust volume (0.0 to 1.0)
    tts_engine.setProperty('volume', 1.0)


def capture_and_save_image():
    """
    Coordinates the screen capture and OCR process.
    Adjusts for DPI scaling if necessary.
    """
    global speech_text, ocr_text

    # Step 1: Let the user select a region
    region = get_selected_region(scaling_factor)
    left, top, right, bottom = region
    width = right - left
    height = bottom - top

    logging.debug(f"Width: {width}, Height: {height}, Scaling Factor: {scaling_factor}")

    if width <= 0 or height <= 0:
        logging.error("Invalid region selected. Please try again.")
        speak_text("Invalid region selected. Please try again by pressing F2.")
        reset_state()
        return

    # Step 2: Capture the selected region
    with mss.mss() as sct:
        monitor = {
            "top": int(top / scaling_factor),
            "left": int(left / scaling_factor),
            "width": int(width / scaling_factor),
            "height": int(height / scaling_factor)
        }

        logging.debug(f"Monitor for capture: {monitor}")

        try:
            screenshot = sct.grab(monitor)
            img = Image.frombytes("RGB", screenshot.size, screenshot.bgra, "raw", "BGRX")
        except Exception as e:
            logging.error(f"Screen capture failed: {e}")
            speak_text("Screen capture failed. Please ensure the selected area is correct and try again.")
            reset_state()
            return
    
    # Step 3: Perform OCR on the captured region
    perform_ocr(img)
    if not ocr_text.strip():
        logging.error("OCR returned empty text. Please ensure the selected region contains readable text.")
        speak_text("OCR did not detect any text. Please ensure the selected area contains readable text and try again.")
        reset_state()
        return

    # Step 4: Listen for user input (optional) with shorter timeout
    transcribe_speech(short_timeout=5, phrase_time_limit=5)

    # Step 5: Generate and execute the search query
    generate_and_execute_search(speech_text, ocr_text)


def generate_and_execute_search(user_input, ocr_text):
    """
    Generates and executes the search query using OCR text directly.
    """
    # Use OCR text directly as search query
    search_query = ocr_text.strip()
    if not search_query:
        logging.error("No OCR text available for search query.")
        speak_text("No text detected from the screen. Please try again by pressing F2.")
        reset_state()
        return

    logging.info(f"Using OCR text as search query: {search_query}")

    # Perform web search and process the answer
    asyncio.run(async_process_search(search_query, ocr_text, user_input))


async def async_process_search(search_query, ocr_text, user_input):
    """
    Asynchronously processes the web search and generates the final response.
    """
    # Perform web search
    search_results = await async_web_search(search_query)
    if not search_results:
        logging.warning("No search results found. Unable to answer the question.")
        speak_text("I'm sorry, I couldn't find an answer to your question.")
        reset_state()
        return

    # Generate final response using LLM
    final_response = generate_final_response(search_results, ocr_text, user_input)
    if not final_response:
        logging.error("Failed to generate a final response.")
        speak_text("I couldn't determine the answer to your question. Please try again by pressing F2.")
        reset_state()
        return

    # Speak out the answer
    speak_text(final_response)
    reset_state()


# -------------------- Core Functions --------------------


def start_listening(scaling):
    """
    Starts the listening process by initiating region selection and OCR.
    Runs in a separate thread to prevent blocking.
    """
    global is_listening
    with lock:
        if not is_listening:
            is_listening = True
            logging.info("Started listening...")
            # Perform region selection and OCR in a separate thread
            threading.Thread(target=capture_and_save_image, args=(), daemon=True).start()


def stop_listening():
    """
    Stops the listening process if it's currently active.
    """
    global is_listening
    with lock:
        if is_listening:
            is_listening = False
            logging.info("Stopped listening.")
            speak_text("Stopped listening.")


# -------------------- Main Function --------------------


async def main():
    """
    Main asynchronous function that sets up the hotkeys and runs the event loop.
    """
    global scaling_factor

    configure_tts_voice()  # Voice configuration happens before anything else

    # Set DPI awareness and get scaling factor
    set_dpi_awareness()
    scaling_factor = get_scaling_factor()

    # Set up hotkeys
    keyboard.add_hotkey('f2', start_listening, args=(scaling_factor,))
    keyboard.add_hotkey('f3', stop_listening)

    logging.info("QA Assistant is running.")
    logging.info("Press 'F2' to select a region and answer a question, 'F3' to stop listening, 'Esc' to exit.")

    while True:
        # Wait for a short duration to avoid busy waiting
        await asyncio.sleep(0.1)

        # Check for exit condition
        if keyboard.is_pressed('esc'):
            logging.info("Exiting...")
            speak_text("Exiting the QA Assistant. Goodbye!")
            break

        # Reset speech_text and ocr_text is handled after processing

    # Clean up keyboard hooks before exiting
    keyboard.unhook_all()


# -------------------- Entry Point --------------------

if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        logging.info("Program terminated by user.")
    except Exception as e:
        logging.error(f"An unexpected error occurred: {e}")
    finally:
        # Ensure all keyboard hooks are removed before exiting
        keyboard.unhook_all() : remove all the we search and related features, send the ocr text directly to the llm and ask for response with explanantion considering the text to be question