import os
import sys
import time
import asyncio
import traceback
import threading
import faulthandler

# crawlee imports
from crawlee.crawlers import PlaywrightCrawler, PlaywrightCrawlingContext, PlaywrightPreNavCrawlingContext
from crawlee.sessions import SessionPool, SessionCookies
from crawlee.browsers import BrowserPool
from crawlee.browsers._playwright_browser_plugin import PlaywrightBrowserPlugin
from crawlee.sessions import CookieParam
from crawlee.storages import RequestQueue
from playwright.async_api import expect, Locator

# kivy imports
from kivy.app import App
from kivy.clock import Clock
from kivy.uix.button import Button
from kivy.uix.label import Label
from kivy.uix.textinput import TextInput
from kivy.uix.popup import Popup
from kivy.uix.spinner import Spinner
from kivy.uix.gridlayout import GridLayout
from kivy.uix.scrollview import ScrollView
from kivy.uix.screenmanager import ScreenManager, Screen

# modules for the window popping up
import tkinter
from tkinter.filedialog import askopenfilename
from enum import Enum, auto
from functools import partial
from threading import Thread
from typing import Any, Callable, Dict, Iterable, List, Optional

# ============================================================
#                     DIAGNOSTIC INFRASTRUCTURE
# ============================================================

HOME = os.path.expanduser("~")
DIAG_LOG_PATH = os.path.join(HOME, "kivy_crawlee_diag.log")
_diag_lock = threading.Lock()

def _ts() -> str:
    return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + f".{int((time.time() % 1)*1000):03d}"

def diag(msg: str) -> None:
    """Append diagnostics to a file, thread-safe, never raises."""
    line = f"{_ts()} [{threading.current_thread().name}] {msg}"
    try:
        with _diag_lock:
            with open(DIAG_LOG_PATH, "a", encoding="utf-8") as f:
                f.write(line + "\n")
                f.flush()
    except Exception:
        pass

# enable faulthandler to the same log file
_FAULT_FILE = None
try:
    _FAULT_FILE = open(DIAG_LOG_PATH, "a", encoding="utf-8")
    faulthandler.enable(file=_FAULT_FILE, all_threads=True)
    diag("Faulthandler enabled.")
except Exception as e:
    diag(f"Faulthandler enable failed: {e!r}")

def _global_excepthook(exc_type, exc, tb):
    diag(f"UNHANDLED (sys.excepthook): {exc_type.__name__}: {exc!r}")
    diag("".join(traceback.format_exception(exc_type, exc, tb)))
    try:
        faulthandler.dump_traceback(file=_FAULT_FILE, all_threads=True)
    except Exception:
        pass

sys.excepthook = _global_excepthook

def _thread_excepthook(args):
    diag(f"THREAD EXCEPTION in {args.thread.name}: {args.exc_type.__name__}: {args.exc_value!r}")
    diag("".join(traceback.format_exception(args.exc_type, args.exc_value, args.exc_traceback)))
    try:
        faulthandler.dump_traceback(file=_FAULT_FILE, all_threads=True)
    except Exception:
        pass

if hasattr(threading, "excepthook"):
    threading.excepthook = _thread_excepthook

diag(f"Python {sys.version.split()[0]} on {sys.platform}")
diag(f"Diagnostic log path: {DIAG_LOG_PATH}")

# ============================================================
#                     ORIGINAL APP STRUCTURE
# ============================================================

class OrganizedListOfExternalVariables():
    """
    class for handling global properties that need to be accessed in a whole bunch of places
    """

    def __init__(self, spaceConst, titleText = "") -> None:
        # using the underscore prefix to abide by the naming convention for priv variables
        self.spaceConst = spaceConst
        self.titleText = titleText
        self.websiteProcessingNum = 1

    def __getattr__(self, name: str):
        return self.__dict__[name]

    def __setattr__(self, name: str, value) -> None:
        if name in self.__dict__:
            self.__dict__[name] = value
        else:
            self.__dict__.update({name: value})

# enumerator sets up an easy way to add more screens while keeping them named in a semi-sane fashion
class ScreenList(Enum):
    MAIN = "main"
    VALIDATOR = "validator"
    END = "end"

externalVariables = OrganizedListOfExternalVariables(spaceConst=100)

# Handles screen navigation
class ScreenController:
    def __init__(self, manager):
        # manager var is the instance of ScreenManagerClass that we have below
        self.manager = manager
    # switches screens based on the enum value
    def switch_to(self, screen_type: ScreenList):
        self.manager.current = screen_type.name.lower()

class ScreenClass(Screen):
    def __init__(self, screenList: ScreenList, controller: ScreenController, screenManager: ScreenManager, **kwargs):
        super(ScreenClass, self).__init__(**kwargs)
        self.name = screenList.name.lower()
        self.screenList = screenList
        self.controller = controller
        self.screenManager = screenManager
        self.buildInstructions()

    def buildInstructions(self):
        baseLayout = GridLayout(cols=2, spacing=[externalVariables.spaceConst, externalVariables.spaceConst])

        transitionLabel = Label(text="Press this button once you're ready")
        transitionButton = self.genericButtonFunction(text='Click me!', event=self.screenTransitioner)
        baseLayout.add_widget(transitionLabel)
        baseLayout.add_widget(transitionButton)

        self.add_widget(baseLayout)

    def genericButtonFunction(self, text, event):
        button = Button(text=text)
        button.bind(on_press=event)  # type: ignore
        return button

    def screenTransitioner(self, instance):
        validatorScreen = ValidatorScreen(ScreenList.VALIDATOR, self.controller, self.screenManager)
        self.screenManager.add_widget(validatorScreen)
        # either use controller.switch_to(ScreenList.VALIDATOR) or switch_to(screen) — both OK
        self.screenManager.switch_to(validatorScreen)

class ValidatorScreen(Screen):
    """
    NOTE: the crawler is now started on a BACKGROUND THREAD to keep Kivy responsive.
    The thread creates a Proactor event loop on Windows so Playwright can spawn its driver.
    Diagnostics are logged to ~/kivy_crawlee_diag.log.
    """

    def __init__(self, screenList: ScreenList, controller: ScreenController, screenManager: ScreenManager, **kwargs):
        super(ValidatorScreen, self).__init__(**kwargs)  # fixed super() target
        self.screenList = screenList
        self.controller = controller
        self.screenManager = screenManager
        self._crawler_thread: Optional[Thread] = None
        self._build_ui_and_start()

    def _build_ui_and_start(self):
        baseLayout = GridLayout(cols=1, spacing=[externalVariables.spaceConst, externalVariables.spaceConst])
        # simple scrollable log so you see progress (and avoid any label recursion problems)
        scroll = ScrollView(size_hint=(1, 1))
        self.log_label = Label(
            text="[b]Crawler log[/b]\nStarting…",
            markup=True,
            halign="left",
            valign="top",
            size_hint=(1, None),
        )
        # recursion-safe bindings: width -> text_size; texture_size -> height
        self.log_label.bind(width=lambda inst, w: setattr(inst, "text_size", (w, None)))
        self.log_label.bind(texture_size=lambda inst, sz: setattr(inst, "height", sz[1]))

        scroll.add_widget(self.log_label)
        baseLayout.add_widget(scroll)

        # transition button (optional)
        transitionButton = self.genericButtonFunction(text='Done', event=self.screenTransitioner)
        baseLayout.add_widget(transitionButton)

        self.add_widget(baseLayout)

        # ---- start crawler in a worker thread ----
        self._crawler_thread = Thread(target=self._run_crawler_thread, name="CrawlerThread", daemon=False)
        self._crawler_thread.start()
        diag("ValidatorScreen: crawler thread started.")

    def genericButtonFunction(self, text, event):
        button = Button(text=text, size_hint=(1, None), height=48)
        button.bind(on_press=event)  # type: ignore
        return button

    def _append_line(self, msg: str):
        self.log_label.text += f"\n{msg}"

    def screenTransitioner(self, instance):
        self.screenManager.add_widget(ScreenClass(ScreenList.END, self.controller, self.screenManager))

    def _run_crawler_thread(self):
        """
        Worker thread body: create MainProgram and run it on a dedicated (Proactor) event loop.
        """
        diag("Crawler thread: begin")
        mainClass = MainProgram()
        try:
            mainClass.run_blocking(validatorBool=True, ui_callback=lambda s: Clock.schedule_once(lambda dt, m=s: self._append_line(m), 0))
            Clock.schedule_once(lambda dt: self._append_line("[b]Crawler finished.[/b]"), 0)
        except RecursionError as e:
            diag("!!! RECURSION ERROR in _run_crawler_thread")
            diag("".join(traceback.format_exception(type(e), e, e.__traceback__)))
            # capture text NOW to avoid NameError later
            err_msg = f"[color=ff3333]RecursionError[/color]: {repr(e)}"
            Clock.schedule_once(lambda dt, m=err_msg: self._append_line(m), 0)
            raise
        except Exception as e:
            # capture text NOW to avoid the lambda 'e' scoping bug
            tb = "".join(traceback.format_exception(type(e), e, e.__traceback__))
            diag(f"Crawler thread exception: {repr(e)}\n{tb}")
            err_msg = f"[color=ff3333]Error[/color]: {repr(e)}"
            Clock.schedule_once(lambda dt, m=err_msg: self._append_line(m), 0)
        finally:
            diag("Crawler thread: end")

class ScreenManagerClass(ScreenManager):
    def __init__(self, **kwargs):
        super(ScreenManagerClass, self).__init__(**kwargs)
        self.controller = ScreenController(self)
        self.transitionBool = False
        self.screenLoader()

    def screenLoader(self):
        mainScreen = ScreenClass(ScreenList.MAIN, self.controller, self)
        self.add_widget(mainScreen)

class ScrapingScriptApp(App):
    def build(self):
        return ScreenManagerClass()

# ============================================================
#                    CRAWLER IMPLEMENTATION
# ============================================================

class MainProgram():
    def __init__(self):
        diag("MainProgram.__init__")

    async def mainFunction(self, validatorBool: bool, ui_callback: Optional[Callable[[str], None]] = None):
        # Browser pool etc.
        headLessBool = True
        browserPoolInit = BrowserPool()
        browserPoolVar = browserPoolInit.with_default_plugin(
            headless=headLessBool,
            browser_type='firefox',  # ensure 'python -m playwright install firefox' if you use firefox
            browser_new_context_options={"is_mobile": False}
        )
        crawler = PlaywrightCrawler(
            use_session_pool=True,
            max_session_rotations=0,
            browser_pool=browserPoolVar,
            session_pool=SessionPool(max_pool_size=1)
        )

        @crawler.router.default_handler
        async def request_handler(context: PlaywrightCrawlingContext) -> None:
            try:
                url = context.request.url
                msg = f"Processing: {url}"
                diag(msg)
                if ui_callback:
                    ui_callback(f"Scraped: {url} • (fetching title...)")

                currentPage = context.page
                websiteProcessingNum = externalVariables.websiteProcessingNum

                match websiteProcessingNum:
                    case 1:
                        titleLocator = currentPage.locator('//*[@id="productTitle"]')
                        externalVariables.titleText = await titleLocator.inner_text()
                        if ui_callback:
                            ui_callback(f"Title: {externalVariables.titleText.strip()[:80]}")
                    case _:
                        if ui_callback:
                            ui_callback("Unexpected websiteProcessingNum")
                diag("request_handler finished.")
            except RecursionError as e:
                diag("!!! RECURSION ERROR in request_handler")
                diag("".join(traceback.format_exception(type(e), e, e.__traceback__)))
                raise

        # Run the crawler
        diag("MainProgram: starting crawler.run")
        await crawler.run(['https://www.amazon.ca/dp/B0BBBTVFRD'])
        externalVariables.websiteProcessingNum = 2
        diag("MainProgram: crawler.run finished")

    def run_blocking(
        self,
        validatorBool: bool,
        ui_callback: Optional[Callable[[str], None]] = None
    ) -> None:
        """
        Run mainFunction inside a DEDICATED EVENT LOOP created in THIS THREAD.
        On Windows, build a Proactor loop so Playwright can spawn its driver.
        """
        diag("MainProgram.run_blocking: begin")
        # ---- Build the right event loop for Windows ----
        if sys.platform.startswith("win"):
            try:
                import asyncio.windows_events as windows_events  # type: ignore
                loop = windows_events.ProactorEventLoop()
                diag(f"Created event loop: {type(loop).__name__}")
            except Exception as e:
                # last-resort fallback: switch policy, then create new loop
                diag(f"ProactorEventLoop creation failed: {e!r} — using WindowsProactorEventLoopPolicy fallback")
                prev_policy = asyncio.get_event_loop_policy()
                try:
                    asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())  # type: ignore[attr-defined]
                    loop = asyncio.new_event_loop()
                finally:
                    # restore the previous policy after loop closes
                    asyncio.set_event_loop_policy(prev_policy)
        else:
            loop = asyncio.new_event_loop()
            diag(f"Created event loop (non-Windows): {type(loop).__name__}")

        try:
            asyncio.set_event_loop(loop)
            loop.set_debug(True)

            def _loop_ex_handler(lp, context):
                diag(f"ASYNCIO LOOP EXCEPTION: {context.get('message', '<no message>')}")
                exc = context.get("exception")
                if exc:
                    diag("".join(traceback.format_exception(type(exc), exc, exc.__traceback__)))

            loop.set_exception_handler(_loop_ex_handler)

            loop.run_until_complete(self.mainFunction(validatorBool=validatorBool, ui_callback=ui_callback))

            # graceful shutdown
            try:
                loop.run_until_complete(loop.shutdown_asyncgens())
                if hasattr(loop, "shutdown_default_executor"):
                    loop.run_until_complete(loop.shutdown_default_executor())
            finally:
                pass

        except RecursionError as e:
            diag("!!! RECURSION ERROR in run_blocking")
            diag("".join(traceback.format_exception(type(e), e, e.__traceback__)))
            raise
        except Exception as e:
            diag(f"run_blocking exception: {repr(e)}")
            diag("".join(traceback.format_exception(type(e), e, e.__traceback__)))
            raise
        finally:
            try:
                loop.close()
                diag("MainProgram.run_blocking: loop closed")
            except Exception as e:
                diag(f"Loop close exception: {e!r}")
            diag("MainProgram.run_blocking: end")

# ============================================================
#                         APP ENTRYPOINT
# ============================================================

if __name__ == '__main__':
    diag("===== APP START =====")
    try:
        ScrapingScriptApp().run()
    finally:
        diag("===== APP EXIT =====")
        try:
            if _FAULT_FILE:
                _FAULT_FILE.flush()
        except Exception:
            pass
