from __future__ import annotations

import logging
import os
import platform
import json
import shutil
import sys
import threading
import uuid
from datetime import datetime
from logging.handlers import RotatingFileHandler
from pathlib import Path
from typing import Any, Mapping


LOG_DIRNAME = "Logs"
LOG_FILENAME_PREFIX = "LiveryLab_"
MAX_LOG_FILES = 30
MAX_LOG_AGE_DAYS = 30
MAX_LOG_BYTES = 5 * 1024 * 1024
MAX_TEXT_LENGTH = 12_000

_LOGGER = logging.getLogger("liverylab")
_LOGGER.addHandler(logging.NullHandler())
_LOGGER.propagate = False
_LOCK = threading.RLock()
_HANDLER: logging.Handler | None = None
_LOG_PATH: Path | None = None
_LOG_DIR: Path | None = None
_REPLACEMENTS: list[tuple[str, str]] = []
_DIAGNOSTIC_CONTEXT: dict[str, str] = {"workflow": "startup"}
_ORIGINAL_SYS_EXCEPTHOOK = sys.excepthook
_ORIGINAL_THREAD_EXCEPTHOOK = getattr(threading, "excepthook", None)
_HOOKS_INSTALLED = False
_SESSION_MARKER: Path | None = None


class _SessionFormatter(logging.Formatter):
    def formatTime(self, record: logging.LogRecord, datefmt: str | None = None) -> str:
        timestamp = datetime.fromtimestamp(record.created).astimezone()
        return timestamp.isoformat(timespec="milliseconds")

    def format(self, record: logging.LogRecord) -> str:
        if not hasattr(record, "component"):
            record.component = "app"
        return sanitize_text(super().format(record))


def _runtime_root() -> Path:
    if getattr(sys, "frozen", False):
        return Path(sys.executable).resolve().parent
    return Path(__file__).resolve().parent.parent


def _read_version(root: Path) -> str:
    candidates = [root / "VERSION", Path(__file__).resolve().parent.parent / "VERSION"]
    for candidate in candidates:
        try:
            value = candidate.read_text(encoding="utf-8").strip()
        except OSError:
            continue
        if value:
            return value if value.startswith("v") else f"v{value}"
    return "unknown"


def _cleanup_logs(log_dir: Path, now: datetime) -> None:
    try:
        files = sorted(
            (path for path in log_dir.glob(f"{LOG_FILENAME_PREFIX}*.log*") if path.is_file()),
            key=lambda path: path.stat().st_mtime,
            reverse=True,
        )
    except OSError:
        return
    cutoff = now.timestamp() - (MAX_LOG_AGE_DAYS * 24 * 60 * 60)
    # Keep one slot free for the session file created immediately afterwards.
    for index, path in enumerate(files):
        try:
            if index >= MAX_LOG_FILES - 1 or path.stat().st_mtime < cutoff:
                path.unlink(missing_ok=True)
        except OSError:
            continue


def _process_is_running(pid: int) -> bool:
    if pid <= 0:
        return False
    try:
        os.kill(pid, 0)
    except OSError:
        return False
    return True


def _inspect_previous_sessions(log_dir: Path) -> list[dict[str, Any]]:
    stale: list[dict[str, Any]] = []
    for marker in log_dir.glob(f"{LOG_FILENAME_PREFIX}*.active.json"):
        try:
            data = json.loads(marker.read_text(encoding="utf-8"))
        except (OSError, ValueError, TypeError):
            data = {}
        pid = int(data.get("pid") or 0) if isinstance(data, dict) else 0
        if pid and _process_is_running(pid):
            continue
        stale.append(data if isinstance(data, dict) else {})
        try:
            marker.unlink(missing_ok=True)
        except OSError:
            pass
    return stale


def _write_session_marker(log_dir: Path, log_path: Path, now: datetime) -> Path | None:
    marker = log_dir / f"{LOG_FILENAME_PREFIX}{os.getpid()}.active.json"
    try:
        marker.write_text(
            json.dumps({"pid": os.getpid(), "startedAt": now.isoformat(), "logPath": str(log_path)}, ensure_ascii=False),
            encoding="utf-8",
        )
    except OSError:
        return None
    return marker


def _set_replacements(app_root: Path, extra_roots: Mapping[str, str | Path] | None = None) -> None:
    replacements: list[tuple[str, str]] = [(str(app_root.resolve()), "{APP_ROOT}")]
    try:
        replacements.append((str(Path.home().resolve()), "{USERPROFILE}"))
    except OSError:
        pass
    for label, value in (extra_roots or {}).items():
        try:
            path_text = str(Path(value).expanduser().resolve())
        except (OSError, TypeError, ValueError):
            continue
        if path_text:
            replacements.append((path_text, "{" + str(label).upper() + "}"))
    unique = {source: replacement for source, replacement in replacements if source}
    global _REPLACEMENTS
    _REPLACEMENTS = sorted(unique.items(), key=lambda item: len(item[0]), reverse=True)


def sanitize_text(value: Any, limit: int = MAX_TEXT_LENGTH) -> str:
    text = str(value if value is not None else "")
    for source, replacement in _REPLACEMENTS:
        text = text.replace(source, replacement)
        text = text.replace(source.replace("\\", "/"), replacement)
    if len(text) > limit:
        return f"{text[:limit]}\n... [truncated {len(text) - limit} characters]"
    return text


def configure_error_logging(
    storage_root: str | Path | None = None,
    *,
    app_version: str = "",
    app_root: str | Path | None = None,
    extra_roots: Mapping[str, str | Path] | None = None,
) -> Path | None:
    global _HANDLER, _LOG_DIR, _LOG_PATH, _SESSION_MARKER
    with _LOCK:
        if _HANDLER is not None and _LOG_PATH is not None:
            if extra_roots:
                _set_replacements(Path(app_root or _runtime_root()), extra_roots)
            return _LOG_PATH

        root = Path(storage_root or _runtime_root()).expanduser()
        log_dir = root if root.name.lower() == LOG_DIRNAME.lower() else root / LOG_DIRNAME
        runtime_root = Path(app_root or _runtime_root()).expanduser()
        now = datetime.now().astimezone()
        try:
            log_dir.mkdir(parents=True, exist_ok=True)
            _cleanup_logs(log_dir, now)
            stale_sessions = _inspect_previous_sessions(log_dir)
            filename = f"{LOG_FILENAME_PREFIX}{now.strftime('%Y-%m-%d_%H-%M-%S')}_{os.getpid()}.log"
            log_path = log_dir / filename
            handler = RotatingFileHandler(
                log_path,
                maxBytes=MAX_LOG_BYTES,
                backupCount=1,
                encoding="utf-8",
            )
            handler.setFormatter(_SessionFormatter("%(asctime)s %(levelname)s [%(component)s] %(message)s"))
        except OSError:
            return None

        _LOGGER.handlers.clear()
        _LOGGER.setLevel(logging.INFO)
        _LOGGER.propagate = False
        _LOGGER.addHandler(handler)
        _HANDLER = handler
        _LOG_DIR = log_dir
        _LOG_PATH = log_path
        _SESSION_MARKER = _write_session_marker(log_dir, log_path, now)
        _set_replacements(runtime_root, extra_roots)
        version = app_version or _read_version(runtime_root)
        _LOGGER.info(
            "Session started | version=%s | platform=%s | python=%s | frozen=%s | pid=%s",
            sanitize_text(version, 120),
            sanitize_text(platform.platform(), 500),
            platform.python_version(),
            bool(getattr(sys, "frozen", False)),
            os.getpid(),
            extra={"component": "startup"},
        )
        for stale in stale_sessions:
            _LOGGER.warning(
                "Previous session did not end normally | previousPid=%s | previousStartedAt=%s | previousLog=%s%s",
                stale.get("pid") or "unknown",
                sanitize_text(stale.get("startedAt") or "unknown", 200),
                sanitize_text(stale.get("logPath") or "unknown", 2000),
                _format_context(),
                extra={"component": "startup"},
            )
        return log_path


def _webview2_runtime_version() -> str:
    if not sys.platform.startswith("win"):
        return "not-applicable"
    try:
        import winreg
    except ImportError:
        return "unknown"
    client_id = "{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"
    candidates = (
        (winreg.HKEY_CURRENT_USER, rf"SOFTWARE\Microsoft\EdgeUpdate\Clients\{client_id}"),
        (winreg.HKEY_LOCAL_MACHINE, rf"SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{client_id}"),
        (winreg.HKEY_LOCAL_MACHINE, rf"SOFTWARE\Microsoft\EdgeUpdate\Clients\{client_id}"),
    )
    for hive, key_path in candidates:
        try:
            with winreg.OpenKey(hive, key_path) as key:
                value, _ = winreg.QueryValueEx(key, "pv")
        except OSError:
            continue
        if str(value or "").strip():
            return str(value).strip()
    return "unknown"


def runtime_diagnostics(storage_root: str | Path, *, debug: bool = False) -> dict[str, Any]:
    root = Path(storage_root).expanduser()
    try:
        from importlib.metadata import PackageNotFoundError, version

        try:
            pywebview_version = version("pywebview")
        except PackageNotFoundError:
            pywebview_version = "unknown"
    except ImportError:
        pywebview_version = "unknown"
    try:
        free_bytes = shutil.disk_usage(root).free
    except OSError:
        free_bytes = "unknown"
    return {
        "pywebviewVersion": pywebview_version,
        "webView2Version": _webview2_runtime_version(),
        "windowsBuild": platform.version(),
        "debugMode": bool(debug),
        "safeWebview": "--disable-gpu" in os.getenv("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS", ""),
        "userDataWritable": os.access(root, os.W_OK),
        "userDataFreeBytes": free_bytes,
    }


def log_runtime_diagnostics(storage_root: str | Path, *, debug: bool = False) -> dict[str, Any]:
    details = runtime_diagnostics(storage_root, debug=debug)
    _LOGGER.info("Runtime diagnostics%s", _format_context(details), extra={"component": "startup.runtime"})
    return details


def filesystem_error_context(exc: BaseException) -> dict[str, Any]:
    context: dict[str, Any] = {}
    errno_value = getattr(exc, "errno", None)
    winerror_value = getattr(exc, "winerror", None)
    if errno_value is not None:
        context["errno"] = errno_value
    if winerror_value is not None:
        context["winerror"] = winerror_value
    filename = getattr(exc, "filename", None)
    if not filename:
        return context
    try:
        path = Path(filename).expanduser()
        parent = path if path.is_dir() else path.parent
        context.update(
            {
                "filePath": path,
                "pathLength": len(str(path)),
                "pathExists": path.exists(),
                "parentWritable": parent.exists() and os.access(parent, os.W_OK),
            }
        )
        if parent.exists():
            context["freeBytes"] = shutil.disk_usage(parent).free
    except OSError:
        context["fileDiagnostics"] = "unavailable"
    return context


def install_exception_hooks() -> None:
    global _HOOKS_INSTALLED
    with _LOCK:
        if _HOOKS_INSTALLED:
            return

        def handle_exception(exc_type: type[BaseException], exc: BaseException, tb: Any) -> None:
            error_id = new_error_id()
            _LOGGER.critical(
                "Unhandled exception | error_id=%s | type=%s | message=%s%s",
                error_id,
                exc_type.__name__,
                sanitize_text(exc, 2000),
                _format_context(),
                exc_info=(exc_type, exc, tb),
                extra={"component": "python.unhandled"},
            )
            if _ORIGINAL_SYS_EXCEPTHOOK not in {None, handle_exception}:
                _ORIGINAL_SYS_EXCEPTHOOK(exc_type, exc, tb)

        def handle_thread_exception(args: Any) -> None:
            thread_name = getattr(getattr(args, "thread", None), "name", "unknown")
            exc = getattr(args, "exc_value", None)
            exc_type = getattr(args, "exc_type", type(exc) if exc is not None else Exception)
            tb = getattr(args, "exc_traceback", None)
            _LOGGER.error(
                "Unhandled thread exception | error_id=%s | thread=%s | type=%s | message=%s%s",
                new_error_id(),
                sanitize_text(thread_name, 200),
                getattr(exc_type, "__name__", "Exception"),
                sanitize_text(exc, 2000),
                _format_context(),
                exc_info=(exc_type, exc, tb) if exc is not None else None,
                extra={"component": "python.thread"},
            )

        sys.excepthook = handle_exception
        if hasattr(threading, "excepthook"):
            threading.excepthook = handle_thread_exception
        _HOOKS_INSTALLED = True


def new_error_id() -> str:
    timestamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S")
    return f"{timestamp}-{uuid.uuid4().hex[:6].upper()}"


def log_checkpoint(stage: str, details: Any = "") -> None:
    message = f"Checkpoint: {sanitize_text(stage, 200)}"
    if details:
        message += f" | {sanitize_text(details, 2000)}"
    _LOGGER.info(message, extra={"component": "startup"})


def log_operation(
    operation: str,
    status: str,
    *,
    duration_ms: int | None = None,
    context: Mapping[str, Any] | None = None,
) -> None:
    details: dict[str, Any] = {"operation": operation, "status": status}
    if duration_ms is not None:
        details["durationMs"] = max(0, int(duration_ms))
    if context:
        details.update(context)
    level = logging.WARNING if status in {"failed", "aborted"} else logging.INFO
    _LOGGER.log(level, "Critical operation%s", _format_context(details), extra={"component": "operation"})


def log_warning(component: str, message: Any, *, context: Mapping[str, Any] | None = None) -> None:
    suffix = _format_context(context)
    _LOGGER.warning("%s%s", sanitize_text(message, 4000), suffix, extra={"component": sanitize_text(component, 160)})


def log_error(component: str, message: Any, *, context: Mapping[str, Any] | None = None) -> str:
    error_id = new_error_id()
    suffix = _format_context(context)
    _LOGGER.error(
        "error_id=%s | %s%s",
        error_id,
        sanitize_text(message, 4000),
        suffix,
        extra={"component": sanitize_text(component, 160)},
    )
    return error_id


def log_exception(
    exc: BaseException,
    *,
    component: str,
    operation: str = "",
    context: Mapping[str, Any] | None = None,
) -> str:
    error_id = new_error_id()
    operation_text = f" | operation={sanitize_text(operation, 200)}" if operation else ""
    _LOGGER.error(
        "error_id=%s%s | type=%s | message=%s%s",
        error_id,
        operation_text,
        exc.__class__.__name__,
        sanitize_text(exc, 4000),
        _format_context(context),
        exc_info=(type(exc), exc, exc.__traceback__),
        extra={"component": sanitize_text(component, 160)},
    )
    return error_id


def log_frontend_error(payload: Mapping[str, Any] | None) -> str:
    data = payload if isinstance(payload, Mapping) else {}
    allowed = ("kind", "message", "source", "line", "column", "stack", "stage", "operation", "workflow")
    context = {key: data.get(key) for key in allowed if data.get(key) is not None and data.get(key) != ""}
    return log_error("frontend", context.pop("message", "Unknown frontend error"), context=context)


def set_diagnostic_context(*, workflow: str) -> dict[str, str]:
    clean_workflow = str(workflow or "unknown").strip().lower()
    if clean_workflow not in {"startup", "official", "advanced", "unknown"}:
        clean_workflow = "unknown"
    with _LOCK:
        _DIAGNOSTIC_CONTEXT["workflow"] = clean_workflow
        return dict(_DIAGNOSTIC_CONTEXT)


def get_diagnostic_context() -> dict[str, str]:
    with _LOCK:
        return dict(_DIAGNOSTIC_CONTEXT)


def _format_context(context: Mapping[str, Any] | None = None) -> str:
    merged: dict[str, Any] = get_diagnostic_context()
    if context:
        merged.update(context)
    if not merged:
        return ""
    parts: list[str] = []
    for key, value in merged.items():
        if value is None or value == "":
            continue
        parts.append(f"{sanitize_text(key, 100)}={sanitize_text(value, 2000)}")
    return " | " + " | ".join(parts) if parts else ""


def get_log_status() -> dict[str, Any]:
    return {
        "enabled": _HANDLER is not None,
        "folder": str(_LOG_DIR) if _LOG_DIR else "",
        "path": str(_LOG_PATH) if _LOG_PATH else "",
    }


def close_error_logging(status: str = "normal") -> None:
    global _HANDLER, _LOG_PATH, _LOG_DIR, _SESSION_MARKER
    with _LOCK:
        if _HANDLER is not None:
            try:
                clean_status = str(status or "normal").strip().lower()[:80]
                _LOGGER.info("Session ended%s", _format_context({"status": clean_status}), extra={"component": "shutdown"})
                _HANDLER.flush()
                _HANDLER.close()
            finally:
                _LOGGER.removeHandler(_HANDLER)
        if not _LOGGER.handlers:
            _LOGGER.addHandler(logging.NullHandler())
        _HANDLER = None
        _LOG_PATH = None
        _LOG_DIR = None
        if _SESSION_MARKER is not None:
            try:
                _SESSION_MARKER.unlink(missing_ok=True)
            except OSError:
                pass
        _SESSION_MARKER = None
        _DIAGNOSTIC_CONTEXT.clear()
        _DIAGNOSTIC_CONTEXT["workflow"] = "startup"
